keycardai-oauth 0.2.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +41 -0
- data/README.md +20 -2
- data/lib/keycardai/oauth/authenticate.rb +7 -6
- data/lib/keycardai/oauth/authorization_code.rb +8 -9
- data/lib/keycardai/oauth/client_credentials_client.rb +8 -3
- data/lib/keycardai/oauth/discovery.rb +9 -14
- data/lib/keycardai/oauth/token_exchange_client.rb +8 -3
- data/lib/keycardai/oauth/token_requests.rb +30 -9
- data/lib/keycardai/oauth/userinfo.rb +129 -0
- data/lib/keycardai/oauth/version.rb +1 -1
- data/lib/keycardai/oauth.rb +4 -2
- metadata +4 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 377cff115fcfdf240200ae12b5d76d9c395e7a88a7e68cada08eb2bbe2dedaa2
|
|
4
|
+
data.tar.gz: 2d225d8679d1d4a901419aece54de5aceed70ef6998bb6f58966fda86512450d
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: cbdc7911049fbf06b769cf708b3be1f53c8a5dcfb6f2db3e9a920ad050b31526b245e0558d3a90d405a612713985c82ea4cd6f51cde7e23e0a64acc2591ed18d
|
|
7
|
+
data.tar.gz: 84047019c88c419316be53c29b79acee9558cdcf657b1297d2ee840426f5459dbc8759943f9737f8d6f3a86ed08f2be8d349925a99697ab1d9d720477ebc3a5a
|
data/CHANGELOG.md
CHANGED
|
@@ -15,6 +15,47 @@ the loopback flow (RFC 8252), JWT signing and verification with a caching JWKS
|
|
|
15
15
|
keyring, the three application credentials (ClientSecret with multi-zone,
|
|
16
16
|
WebIdentity, WorkloadIdentity with pluggable token sources), and AccessContext.
|
|
17
17
|
|
|
18
|
+
## 0.4.0-keycardai-oauth (2026-09-05)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
- feat(keycardai-oauth): expire the cached token endpoint after discovery_ttl
|
|
22
|
+
- ECO-366 Ruby leg, audited against keycard-sdk-spec's metadata-failures-are-not-sticky contract. The sticky-failure defect does not exist in Ruby: token-endpoint discovery stored outcomes with ||= under a mutex, so a raise cached nothing and an interrupted caller left nothing behind. The one gap was the endpoint being cached for the client lifetime; it is now cached for discovery_ttl (3600s default, the JWKS keyring's knob and default) with a clock: keyword mirroring TokenVerifier. No negative cache and no retryable field: the spec makes deterministic-failure caching optional and Ruby never stored failures, and retryability classification is ECO-360's Ruby leg. Conformance tests cover the spec's discovery rows on both clients via one shared example group.
|
|
23
|
+
|
|
24
|
+
## 0.3.0-keycardai-oauth (2026-09-01)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
- feat(keycardai-oauth): UserInfo, typed OIDC discovery fields, multi-resource authorize (#29)
|
|
28
|
+
- * feat(keycardai-oauth): UserInfo, typed OIDC discovery fields, multi-resource authorize
|
|
29
|
+
- Three capabilities from the specs of record, at their current spec versions:
|
|
30
|
+
authorization-server-discovery v2, userinfo, authorization-code-pkce v3.
|
|
31
|
+
- Changed signatures, all in Keycardai::OAuth:
|
|
32
|
+
- build_authorize_url(..., resource: nil) -> build_authorize_url(..., resources: [])
|
|
33
|
+
exchange_authorization_code(..., resource: nil) -> resource removed outright
|
|
34
|
+
authenticate(..., resource: nil) -> authenticate(..., resources: [])
|
|
35
|
+
AuthorizationServerMetadata gains userinfo_endpoint and end_session_endpoint
|
|
36
|
+
- The single-resource arguments are removed, not deprecated, per spec-version 3.
|
|
37
|
+
Each entry of resources becomes its own RFC 8707 resource parameter on the
|
|
38
|
+
authorize URL; the authorization server binds them into the code at authorize
|
|
39
|
+
time, so the code exchange sends no resource at all.
|
|
40
|
+
- fetch_userinfo GETs the discovered userinfo_endpoint with a Bearer credential
|
|
41
|
+
and Accept: application/json, fails with a configuration error before any
|
|
42
|
+
request when metadata advertises no endpoint, rejects application/jwt bodies,
|
|
43
|
+
requires a non-empty sub, and parses the RFC 6750 WWW-Authenticate challenge on
|
|
44
|
+
a 401 into a typed OAuthError, defaulting to invalid_token when the challenge
|
|
45
|
+
carries no error.
|
|
46
|
+
- No version or changelog edits: the version pipeline is dormant until ECO-291
|
|
47
|
+
and per-gem breaking-change scoping is unaudited, so this carries no
|
|
48
|
+
breaking-change marker despite the changed signatures.
|
|
49
|
+
- Co-Authored-By: Larry Osakwe <larry@keycard.ai>
|
|
50
|
+
- * test(keycardai-oauth): pin the no-resources case, report the web-app gap
|
|
51
|
+
- Row 3 now asserts a resource-less authorize URL carries no resource
|
|
52
|
+
parameter, and the conformance report summary names the unshipped
|
|
53
|
+
web-app begin/complete rows the way it names the as-itself gap.
|
|
54
|
+
- ---------
|
|
55
|
+
- Co-authored-by: devin-ai-keycard <devin-ai@keycard.ai>
|
|
56
|
+
Co-authored-by: Larry Osakwe <larry@keycard.ai>
|
|
57
|
+
Co-authored-by: Larry-Osakwe <larryosak@gmail.com>
|
|
58
|
+
|
|
18
59
|
## 0.2.0-keycardai-oauth (2026-08-19)
|
|
19
60
|
|
|
20
61
|
|
data/README.md
CHANGED
|
@@ -4,7 +4,6 @@ OAuth 2.0 primitives for the Keycard platform. The foundation gem of the
|
|
|
4
4
|
[Keycard Ruby SDK](https://github.com/keycardai/ruby-sdk); `keycardai-mcp` and
|
|
5
5
|
`keycardai-a2a` build on it.
|
|
6
6
|
|
|
7
|
-
> **Preview.** APIs may change between minor versions while the surface settles.
|
|
8
7
|
> Conformance against the cross-SDK contract is tracked in the
|
|
9
8
|
> [conformance report](https://github.com/keycardai/ruby-sdk/blob/main/docs/conformance-report.md).
|
|
10
9
|
|
|
@@ -18,7 +17,8 @@ Capabilities (per [keycard-sdk-spec](https://github.com/keycardai/keycard-sdk-sp
|
|
|
18
17
|
- Client credentials grant (RFC 6749 §4.4)
|
|
19
18
|
- Authorization code + PKCE, including the challenge-driven loopback flow (RFC 8252)
|
|
20
19
|
- Dynamic client registration (RFC 7591)
|
|
21
|
-
- Authorization server discovery (RFC 8414)
|
|
20
|
+
- Authorization server discovery (RFC 8414), including the OIDC Discovery 1.0 §3 endpoints
|
|
21
|
+
- UserInfo: the signed-in user's identity claims (OIDC Core 1.0 §5.3)
|
|
22
22
|
- JWT signing and verification, JWKS keyring with caching
|
|
23
23
|
- Application credentials: ClientSecret (incl. multi-zone), WebIdentity (RFC 7523),
|
|
24
24
|
WorkloadIdentity with pluggable identity token sources
|
|
@@ -86,6 +86,24 @@ context.failed_resources
|
|
|
86
86
|
One resource failing never takes down the others; the failure lands on the
|
|
87
87
|
context rather than raising, and `access` is where you choose to raise.
|
|
88
88
|
|
|
89
|
+
### Read the signed-in user's claims
|
|
90
|
+
|
|
91
|
+
```ruby
|
|
92
|
+
user = Keycardai::OAuth.fetch_userinfo(
|
|
93
|
+
"https://your-zone.keycard.cloud",
|
|
94
|
+
access_token: user_access_token,
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
user.sub
|
|
98
|
+
user["email"]
|
|
99
|
+
user["groups"]
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
Zone access tokens are authorization-only, so identity claims live behind the
|
|
103
|
+
issuer's `userinfo_endpoint` rather than in the token. Pass `metadata:` to reuse
|
|
104
|
+
metadata you already discovered; nothing is cached, because group membership can
|
|
105
|
+
change server-side.
|
|
106
|
+
|
|
89
107
|
### Act as a named user
|
|
90
108
|
|
|
91
109
|
```ruby
|
|
@@ -20,7 +20,8 @@ module Keycardai
|
|
|
20
20
|
# @param issuer [String] the zone's issuer URL
|
|
21
21
|
# @param client_id [String]
|
|
22
22
|
# @param scope [String, nil] space-separated scopes
|
|
23
|
-
# @param
|
|
23
|
+
# @param resources [Array<String>] RFC 8707 resource indicators requested
|
|
24
|
+
# at authorize time, one resource parameter per entry
|
|
24
25
|
# @param port [Integer] loopback port; 0 binds an ephemeral port
|
|
25
26
|
# @param callback_timeout [Numeric] seconds to wait for the redirect
|
|
26
27
|
# @param client_secret [String, nil] confidential clients only
|
|
@@ -33,7 +34,7 @@ module Keycardai
|
|
|
33
34
|
# @raise [InteractionTimeoutError] the user did not complete the redirect
|
|
34
35
|
# @raise [OAuthError] the authorization server denied the request
|
|
35
36
|
# @raise [ProtocolError] the redirect's state did not match (code state_mismatch)
|
|
36
|
-
def self.authenticate(issuer:, client_id:, scope: nil,
|
|
37
|
+
def self.authenticate(issuer:, client_id:, scope: nil, resources: [], port: DEFAULT_CALLBACK_PORT,
|
|
37
38
|
callback_timeout: DEFAULT_CALLBACK_TIMEOUT, client_secret: nil,
|
|
38
39
|
verifier_length: PKCE::DEFAULT_VERIFIER_LENGTH,
|
|
39
40
|
http_client: HTTP::NetHTTPClient.new, browser_opener: nil, timeout: nil)
|
|
@@ -43,13 +44,13 @@ module Keycardai
|
|
|
43
44
|
|
|
44
45
|
Loopback::CallbackServer.open(port: port) do |server|
|
|
45
46
|
open_authorize_page(server, authorization_endpoint, pair, state,
|
|
46
|
-
client_id: client_id, scope: scope,
|
|
47
|
+
client_id: client_id, scope: scope, resources: resources,
|
|
47
48
|
browser_opener: browser_opener)
|
|
48
49
|
code = server.wait_for_code(state: state, timeout: callback_timeout)
|
|
49
50
|
exchange_authorization_code(
|
|
50
51
|
issuer,
|
|
51
52
|
code: code, code_verifier: pair.code_verifier, redirect_uri: server.redirect_uri,
|
|
52
|
-
client_id: client_id, client_secret: client_secret,
|
|
53
|
+
client_id: client_id, client_secret: client_secret,
|
|
53
54
|
http_client: http_client, timeout: timeout
|
|
54
55
|
)
|
|
55
56
|
end
|
|
@@ -62,11 +63,11 @@ module Keycardai
|
|
|
62
63
|
end
|
|
63
64
|
private_class_method :authorization_endpoint_for
|
|
64
65
|
|
|
65
|
-
def self.open_authorize_page(server, endpoint, pair, state, client_id:, scope:,
|
|
66
|
+
def self.open_authorize_page(server, endpoint, pair, state, client_id:, scope:, resources:, browser_opener:)
|
|
66
67
|
url = build_authorize_url(
|
|
67
68
|
endpoint,
|
|
68
69
|
client_id: client_id, redirect_uri: server.redirect_uri, code_challenge: pair.code_challenge,
|
|
69
|
-
code_challenge_method: pair.code_challenge_method, scope: scope, state: state,
|
|
70
|
+
code_challenge_method: pair.code_challenge_method, scope: scope, state: state, resources: resources
|
|
70
71
|
)
|
|
71
72
|
(browser_opener || Loopback.method(:open_browser)).call(url)
|
|
72
73
|
end
|
|
@@ -15,10 +15,12 @@ module Keycardai
|
|
|
15
15
|
# @param code_challenge_method [String] the method the challenge was derived with
|
|
16
16
|
# @param scope [String, nil] space-separated scopes
|
|
17
17
|
# @param state [String, nil] CSRF state value
|
|
18
|
-
# @param
|
|
18
|
+
# @param resources [Array<String>] RFC 8707 resource indicators; each entry
|
|
19
|
+
# becomes its own resource parameter, and the authorization server binds
|
|
20
|
+
# them into the authorization code at authorize time
|
|
19
21
|
# @return [String] the authorize URL
|
|
20
22
|
def self.build_authorize_url(authorization_endpoint, client_id:, redirect_uri:, code_challenge:,
|
|
21
|
-
code_challenge_method: "S256", scope: nil, state: nil,
|
|
23
|
+
code_challenge_method: "S256", scope: nil, state: nil, resources: [])
|
|
22
24
|
params = {
|
|
23
25
|
"response_type" => "code",
|
|
24
26
|
"client_id" => client_id,
|
|
@@ -26,12 +28,11 @@ module Keycardai
|
|
|
26
28
|
"code_challenge" => code_challenge,
|
|
27
29
|
"code_challenge_method" => code_challenge_method,
|
|
28
30
|
"scope" => scope,
|
|
29
|
-
"state" => state
|
|
30
|
-
"resource" => resource
|
|
31
|
+
"state" => state
|
|
31
32
|
}.compact
|
|
32
33
|
|
|
33
34
|
uri = URI(authorization_endpoint)
|
|
34
|
-
query = URI.encode_www_form(params)
|
|
35
|
+
query = URI.encode_www_form(params.to_a + Array(resources).map { |resource| ["resource", resource] })
|
|
35
36
|
uri.query = uri.query.nil? || uri.query.empty? ? query : "#{uri.query}&#{query}"
|
|
36
37
|
uri.to_s
|
|
37
38
|
end
|
|
@@ -46,14 +47,13 @@ module Keycardai
|
|
|
46
47
|
# @param redirect_uri [String] must match the authorization request
|
|
47
48
|
# @param client_id [String, nil] public-client identifier
|
|
48
49
|
# @param client_secret [String, nil] confidential-client secret; requires client_id
|
|
49
|
-
# @param resource [String, nil] RFC 8707 resource indicator
|
|
50
50
|
# @param http_client [#get, #post_form] pluggable transport
|
|
51
51
|
# @param timeout [Numeric, nil]
|
|
52
52
|
# @return [TokenResponse]
|
|
53
53
|
# @raise [OAuthError] an RFC 6749 §5.2 error response (invalid_grant, ...)
|
|
54
54
|
# @raise [HTTPError, ProtocolError, NetworkError, ConfigurationError]
|
|
55
55
|
def self.exchange_authorization_code(issuer, code:, code_verifier:, redirect_uri:, client_id: nil,
|
|
56
|
-
client_secret: nil,
|
|
56
|
+
client_secret: nil,
|
|
57
57
|
http_client: HTTP::NetHTTPClient.new, timeout: nil)
|
|
58
58
|
raise ConfigurationError, "client_secret requires client_id" if client_secret && client_id.nil?
|
|
59
59
|
|
|
@@ -68,8 +68,7 @@ module Keycardai
|
|
|
68
68
|
"code" => code,
|
|
69
69
|
"code_verifier" => code_verifier,
|
|
70
70
|
"redirect_uri" => redirect_uri,
|
|
71
|
-
"client_id" => client_secret ? nil : client_id
|
|
72
|
-
"resource" => resource
|
|
71
|
+
"client_id" => client_secret ? nil : client_id
|
|
73
72
|
}.compact
|
|
74
73
|
headers = { "Accept" => "application/json" }
|
|
75
74
|
headers["Authorization"] = HTTP.basic_authorization(client_id, client_secret) if client_secret
|
|
@@ -8,7 +8,8 @@ module Keycardai
|
|
|
8
8
|
# client assertion carried on the request.
|
|
9
9
|
#
|
|
10
10
|
# The token endpoint is discovered from the issuer on first use and
|
|
11
|
-
# cached
|
|
11
|
+
# cached for discovery_ttl; a failed discovery is not cached. Requests do
|
|
12
|
+
# not retry transparently.
|
|
12
13
|
class ClientCredentialsClient
|
|
13
14
|
include TokenRequests
|
|
14
15
|
|
|
@@ -21,12 +22,16 @@ module Keycardai
|
|
|
21
22
|
# provide both or neither
|
|
22
23
|
# @param http_client [#get, #post_form] pluggable transport
|
|
23
24
|
# @param timeout [Numeric, nil] request timeout in seconds
|
|
25
|
+
# @param discovery_ttl [Numeric] token-endpoint cache lifetime in seconds
|
|
26
|
+
# @param clock [#call] returns the current Time; override in tests
|
|
24
27
|
# @raise [ConfigurationError] when only one of client_id/client_secret is
|
|
25
28
|
# given, or a credential is combined with a raw pair
|
|
26
29
|
def initialize(issuer:, credential: nil, client_id: nil, client_secret: nil,
|
|
27
|
-
http_client: HTTP::NetHTTPClient.new, timeout: nil
|
|
30
|
+
http_client: HTTP::NetHTTPClient.new, timeout: nil,
|
|
31
|
+
discovery_ttl: TokenRequests::DEFAULT_DISCOVERY_TTL, clock: -> { Time.now })
|
|
28
32
|
initialize_token_client(issuer: issuer, credential: credential, client_id: client_id,
|
|
29
|
-
client_secret: client_secret, http_client: http_client, timeout: timeout
|
|
33
|
+
client_secret: client_secret, http_client: http_client, timeout: timeout,
|
|
34
|
+
discovery_ttl: discovery_ttl, clock: clock)
|
|
30
35
|
end
|
|
31
36
|
|
|
32
37
|
# Request a token for the client itself.
|
|
@@ -7,11 +7,15 @@ module Keycardai
|
|
|
7
7
|
# Authorization-server discovery (RFC 8414): the operation, its metadata
|
|
8
8
|
# type, and the URL/parsing internals shared with JWKSKeyring.
|
|
9
9
|
module OAuth
|
|
10
|
-
# OAuth 2.0 authorization-server metadata (RFC 8414)
|
|
11
|
-
#
|
|
12
|
-
#
|
|
10
|
+
# OAuth 2.0 authorization-server metadata (RFC 8414), including the OpenID
|
|
11
|
+
# Connect Discovery 1.0 §3 members Keycard zones serve from the same
|
|
12
|
+
# document. Standard fields are first-class members; the complete
|
|
13
|
+
# document, including unknown fields, is preserved in +raw+ and reachable
|
|
14
|
+
# through +[]+. Every field but +issuer+ is optional and nil when the
|
|
15
|
+
# document omits it.
|
|
13
16
|
AuthorizationServerMetadata = Data.define(
|
|
14
17
|
:issuer, :token_endpoint, :authorization_endpoint, :jwks_uri, :registration_endpoint,
|
|
18
|
+
:userinfo_endpoint, :end_session_endpoint,
|
|
15
19
|
:grant_types_supported, :token_endpoint_auth_methods_supported, :response_types_supported, :raw
|
|
16
20
|
) do
|
|
17
21
|
# @param field [String] a metadata field name
|
|
@@ -68,17 +72,8 @@ module Keycardai
|
|
|
68
72
|
document = parse_document(issuer, body)
|
|
69
73
|
validate_issuer(issuer, document)
|
|
70
74
|
|
|
71
|
-
AuthorizationServerMetadata.
|
|
72
|
-
|
|
73
|
-
token_endpoint: document["token_endpoint"],
|
|
74
|
-
authorization_endpoint: document["authorization_endpoint"],
|
|
75
|
-
jwks_uri: document["jwks_uri"],
|
|
76
|
-
registration_endpoint: document["registration_endpoint"],
|
|
77
|
-
grant_types_supported: document["grant_types_supported"],
|
|
78
|
-
token_endpoint_auth_methods_supported: document["token_endpoint_auth_methods_supported"],
|
|
79
|
-
response_types_supported: document["response_types_supported"],
|
|
80
|
-
raw: document
|
|
81
|
-
)
|
|
75
|
+
fields = (AuthorizationServerMetadata.members - [:raw]).to_h { |name| [name, document[name.to_s]] }
|
|
76
|
+
AuthorizationServerMetadata.new(**fields, raw: document)
|
|
82
77
|
end
|
|
83
78
|
|
|
84
79
|
def parse_document(issuer, body)
|
|
@@ -9,7 +9,8 @@ module Keycardai
|
|
|
9
9
|
# authentication.
|
|
10
10
|
#
|
|
11
11
|
# The token endpoint is discovered from the issuer on first use and
|
|
12
|
-
# cached
|
|
12
|
+
# cached for discovery_ttl; a failed discovery is not cached. Requests do
|
|
13
|
+
# not retry transparently.
|
|
13
14
|
class TokenExchangeClient
|
|
14
15
|
include TokenRequests
|
|
15
16
|
|
|
@@ -21,12 +22,16 @@ module Keycardai
|
|
|
21
22
|
# provide both or neither
|
|
22
23
|
# @param http_client [#get, #post_form] pluggable transport
|
|
23
24
|
# @param timeout [Numeric, nil] request timeout in seconds
|
|
25
|
+
# @param discovery_ttl [Numeric] token-endpoint cache lifetime in seconds
|
|
26
|
+
# @param clock [#call] returns the current Time; override in tests
|
|
24
27
|
# @raise [ConfigurationError] when only one of client_id/client_secret is
|
|
25
28
|
# given, or a credential is combined with a raw pair
|
|
26
29
|
def initialize(issuer:, credential: nil, client_id: nil, client_secret: nil,
|
|
27
|
-
http_client: HTTP::NetHTTPClient.new, timeout: nil
|
|
30
|
+
http_client: HTTP::NetHTTPClient.new, timeout: nil,
|
|
31
|
+
discovery_ttl: TokenRequests::DEFAULT_DISCOVERY_TTL, clock: -> { Time.now })
|
|
28
32
|
initialize_token_client(issuer: issuer, credential: credential, client_id: client_id,
|
|
29
|
-
client_secret: client_secret, http_client: http_client, timeout: timeout
|
|
33
|
+
client_secret: client_secret, http_client: http_client, timeout: timeout,
|
|
34
|
+
discovery_ttl: discovery_ttl, clock: clock)
|
|
30
35
|
end
|
|
31
36
|
|
|
32
37
|
# Exchange a subject token (RFC 8693).
|
|
@@ -8,6 +8,10 @@ module Keycardai
|
|
|
8
8
|
# endpoint discovery with caching, shared-secret (HTTP Basic) client
|
|
9
9
|
# authentication, and RFC 6749 §5.2 error parsing. Not public API.
|
|
10
10
|
module TokenRequests
|
|
11
|
+
# Default lifetime of a discovered token endpoint, in seconds; the same
|
|
12
|
+
# knob and default as the JWKS keyring's jwks_uri cache.
|
|
13
|
+
DEFAULT_DISCOVERY_TTL = 3600
|
|
14
|
+
|
|
11
15
|
# Parse a token-endpoint response: a TokenResponse on 2xx, a raised
|
|
12
16
|
# typed error otherwise.
|
|
13
17
|
#
|
|
@@ -52,13 +56,16 @@ module Keycardai
|
|
|
52
56
|
|
|
53
57
|
private
|
|
54
58
|
|
|
55
|
-
def initialize_token_client(issuer:, credential:, client_id:, client_secret:, http_client:, timeout
|
|
59
|
+
def initialize_token_client(issuer:, credential:, client_id:, client_secret:, http_client:, timeout:,
|
|
60
|
+
discovery_ttl: DEFAULT_DISCOVERY_TTL, clock: -> { Time.now })
|
|
56
61
|
validate_client_auth(credential, client_id, client_secret)
|
|
57
62
|
|
|
58
63
|
@issuer = issuer
|
|
59
64
|
@credential = credential || (client_id ? ClientSecret.new(client_id, client_secret) : nil)
|
|
60
65
|
@http_client = http_client
|
|
61
66
|
@timeout = timeout
|
|
67
|
+
@discovery_ttl = discovery_ttl
|
|
68
|
+
@clock = clock
|
|
62
69
|
@token_endpoints = {}
|
|
63
70
|
@token_endpoint_mutex = Mutex.new
|
|
64
71
|
end
|
|
@@ -72,19 +79,33 @@ module Keycardai
|
|
|
72
79
|
raise ConfigurationError, "client_id and client_secret must be provided together"
|
|
73
80
|
end
|
|
74
81
|
|
|
75
|
-
# Discover a zone's token endpoint
|
|
76
|
-
# so multi-zone clients never reuse another zone's endpoint.
|
|
82
|
+
# Discover a zone's token endpoint and cache it for discovery_ttl, keyed
|
|
83
|
+
# by issuer so multi-zone clients never reuse another zone's endpoint.
|
|
84
|
+
# Only a success is recorded: a failed discovery raises to its caller and
|
|
85
|
+
# the next call discovers again. The mutex serializes cold-cache callers
|
|
86
|
+
# so concurrent first calls perform a single fetch; an interrupted caller
|
|
87
|
+
# releases it and leaves nothing behind for the others.
|
|
77
88
|
def token_endpoint(issuer = @issuer)
|
|
78
89
|
@token_endpoint_mutex.synchronize do
|
|
79
|
-
|
|
80
|
-
metadata = OAuth.fetch_authorization_server_metadata(issuer, http_client: @http_client,
|
|
81
|
-
timeout: @timeout)
|
|
82
|
-
metadata.token_endpoint ||
|
|
83
|
-
raise(ProtocolError.new("metadata for #{issuer} has no token_endpoint", code: "invalid_metadata"))
|
|
84
|
-
end
|
|
90
|
+
fresh_token_endpoint(issuer) || discover_token_endpoint(issuer)
|
|
85
91
|
end
|
|
86
92
|
end
|
|
87
93
|
|
|
94
|
+
def fresh_token_endpoint(issuer)
|
|
95
|
+
entry = @token_endpoints[issuer]
|
|
96
|
+
return nil unless entry
|
|
97
|
+
|
|
98
|
+
entry[:endpoint] if @clock.call - entry[:fetched_at] <= @discovery_ttl
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def discover_token_endpoint(issuer)
|
|
102
|
+
metadata = OAuth.fetch_authorization_server_metadata(issuer, http_client: @http_client, timeout: @timeout)
|
|
103
|
+
endpoint = metadata.token_endpoint ||
|
|
104
|
+
raise(ProtocolError.new("metadata for #{issuer} has no token_endpoint", code: "invalid_metadata"))
|
|
105
|
+
@token_endpoints[issuer] = { endpoint: endpoint, fetched_at: @clock.call }
|
|
106
|
+
endpoint
|
|
107
|
+
end
|
|
108
|
+
|
|
88
109
|
def post_token_request(params, issuer: @issuer)
|
|
89
110
|
headers = { "Accept" => "application/json" }
|
|
90
111
|
authorization = @credential&.authorization_header(issuer: issuer)
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Keycardai
|
|
6
|
+
# The OpenID Connect UserInfo call (OIDC Core 1.0 §5.3): the operation, its
|
|
7
|
+
# response type, and the response-parsing internals.
|
|
8
|
+
module OAuth
|
|
9
|
+
# The signed-in user's identity claims (OIDC Core 1.0 §5.3). +sub+ is the
|
|
10
|
+
# only claim OIDC requires and is validated present; the claims document is
|
|
11
|
+
# returned exactly as the issuer sent it, nothing filtered to a known set,
|
|
12
|
+
# and reachable through +[]+.
|
|
13
|
+
UserInfoResponse = Data.define(:sub, :claims) do
|
|
14
|
+
# @param claim [String] a claim name
|
|
15
|
+
# @return [Object, nil] the claim's value from the full document
|
|
16
|
+
def [](claim)
|
|
17
|
+
claims[claim]
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Fetch the signed-in user's identity claims from the issuer's UserInfo
|
|
22
|
+
# endpoint (OIDC Core 1.0 §5.3).
|
|
23
|
+
#
|
|
24
|
+
# Keycard zone access tokens are authorization-only, so claims such as
|
|
25
|
+
# +email+ or +groups+ live behind the issuer's +userinfo_endpoint+ rather
|
|
26
|
+
# than in the token. The endpoint comes from discovery unless +metadata+ is
|
|
27
|
+
# supplied, in which case the caller owns caching and refreshing it.
|
|
28
|
+
#
|
|
29
|
+
# The access token is presented as a Bearer credential and the request
|
|
30
|
+
# carries no client authentication: UserInfo authenticates the user, not
|
|
31
|
+
# the client. Signed (+application/jwt+) responses are not supported.
|
|
32
|
+
# Nothing is cached; claims can change server-side, so caching per token is
|
|
33
|
+
# the caller's concern.
|
|
34
|
+
#
|
|
35
|
+
# @param issuer [String] the zone's issuer URL
|
|
36
|
+
# @param access_token [String] the user's access token
|
|
37
|
+
# @param metadata [AuthorizationServerMetadata, nil] pre-discovered
|
|
38
|
+
# metadata; when given, no discovery request is made
|
|
39
|
+
# @param http_client [#get] pluggable transport
|
|
40
|
+
# @param timeout [Numeric, nil]
|
|
41
|
+
# @return [UserInfoResponse]
|
|
42
|
+
# @raise [ConfigurationError] the metadata advertises no userinfo_endpoint,
|
|
43
|
+
# raised before any request
|
|
44
|
+
# @raise [OAuthError] the endpoint rejected the token (HTTP 401), carrying
|
|
45
|
+
# the error code from the WWW-Authenticate challenge
|
|
46
|
+
# @raise [HTTPError] any other non-2xx response
|
|
47
|
+
# @raise [ProtocolError] a signed or non-JSON body, or claims without sub
|
|
48
|
+
# @raise [NetworkError] transport failure
|
|
49
|
+
def self.fetch_userinfo(issuer, access_token:, metadata: nil,
|
|
50
|
+
http_client: HTTP::NetHTTPClient.new, timeout: nil)
|
|
51
|
+
metadata ||= fetch_authorization_server_metadata(issuer, http_client: http_client, timeout: timeout)
|
|
52
|
+
endpoint = metadata.userinfo_endpoint
|
|
53
|
+
if endpoint.nil? || endpoint.empty?
|
|
54
|
+
raise ConfigurationError,
|
|
55
|
+
"authorization server #{metadata.issuer} advertises no userinfo_endpoint"
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
headers = { "Accept" => "application/json", "Authorization" => "Bearer #{access_token}" }
|
|
59
|
+
UserInfo.parse_response(http_client.get(endpoint, headers: headers, timeout: timeout))
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Internals of the UserInfo call. Not public API.
|
|
63
|
+
module UserInfo
|
|
64
|
+
module_function
|
|
65
|
+
|
|
66
|
+
# @param response [HTTP::Response]
|
|
67
|
+
# @return [UserInfoResponse]
|
|
68
|
+
def parse_response(response)
|
|
69
|
+
raise error_for(response) unless response.success?
|
|
70
|
+
|
|
71
|
+
content_type = header(response, "content-type").to_s
|
|
72
|
+
if content_type.downcase.include?("application/jwt")
|
|
73
|
+
raise ProtocolError.new("userinfo response content type #{content_type} is not supported",
|
|
74
|
+
code: "invalid_response")
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
claims = parse_claims(response.body)
|
|
78
|
+
sub = claims["sub"]
|
|
79
|
+
unless sub.is_a?(String) && !sub.empty?
|
|
80
|
+
raise ProtocolError.new("userinfo response has no sub claim", code: "invalid_response")
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
UserInfoResponse.new(sub: sub, claims: claims)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def parse_claims(body)
|
|
87
|
+
claims = JSON.parse(body)
|
|
88
|
+
unless claims.is_a?(Hash)
|
|
89
|
+
raise ProtocolError.new("userinfo response is not a JSON object", code: "invalid_response")
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
claims
|
|
93
|
+
rescue JSON::ParserError
|
|
94
|
+
raise ProtocolError.new("userinfo response is not valid JSON", code: "invalid_response")
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# RFC 6750 §3: a 401 carries the reason in the WWW-Authenticate
|
|
98
|
+
# challenge. Anything else non-2xx is a plain HTTP error.
|
|
99
|
+
#
|
|
100
|
+
# @param response [HTTP::Response]
|
|
101
|
+
# @return [OAuthError, HTTPError]
|
|
102
|
+
def error_for(response)
|
|
103
|
+
return http_error(response) unless response.status == 401
|
|
104
|
+
|
|
105
|
+
error = challenge_error(header(response, "www-authenticate"))
|
|
106
|
+
OAuthError.new("userinfo request was rejected with #{error}",
|
|
107
|
+
error: error, status: response.status, body: response.body)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def http_error(response)
|
|
111
|
+
HTTPError.new("userinfo endpoint returned HTTP #{response.status}",
|
|
112
|
+
status: response.status, body: response.body)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# The challenge's error code, defaulting to invalid_token when the
|
|
116
|
+
# challenge is absent or carries no error parameter.
|
|
117
|
+
def challenge_error(www_authenticate)
|
|
118
|
+
www_authenticate.to_s[/error\s*=\s*"?([^",\s]+)"?/, 1] || "invalid_token"
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# Header names are case-insensitive (RFC 9110 §5.1) and Net::HTTP hands
|
|
122
|
+
# back array values.
|
|
123
|
+
def header(response, name)
|
|
124
|
+
_, value = response.headers.find { |key, _| key.to_s.downcase == name }
|
|
125
|
+
value.is_a?(Array) ? value.first : value
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|
data/lib/keycardai/oauth.rb
CHANGED
|
@@ -4,6 +4,7 @@ require_relative "oauth/version"
|
|
|
4
4
|
require_relative "oauth/errors"
|
|
5
5
|
require_relative "oauth/http"
|
|
6
6
|
require_relative "oauth/discovery"
|
|
7
|
+
require_relative "oauth/userinfo"
|
|
7
8
|
require_relative "oauth/token_types"
|
|
8
9
|
require_relative "oauth/token_requests"
|
|
9
10
|
require_relative "oauth/substitute_user"
|
|
@@ -29,8 +30,9 @@ require_relative "oauth/jwt_verifier"
|
|
|
29
30
|
module Keycardai
|
|
30
31
|
# OAuth 2.0 primitives for the Keycard platform: token exchange (RFC 8693),
|
|
31
32
|
# client credentials, authorization code + PKCE, dynamic client registration
|
|
32
|
-
# (RFC 7591), authorization server discovery (RFC 8414),
|
|
33
|
-
# verification, application credentials, and
|
|
33
|
+
# (RFC 7591), authorization server discovery (RFC 8414), UserInfo (OIDC Core
|
|
34
|
+
# 1.0 §5.3), JWT/JWKS verification, application credentials, and
|
|
35
|
+
# AccessContext.
|
|
34
36
|
#
|
|
35
37
|
# Contract: https://github.com/keycardai/keycard-sdk-spec
|
|
36
38
|
module OAuth
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: keycardai-oauth
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.4.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Keycard
|
|
@@ -25,8 +25,8 @@ dependencies:
|
|
|
25
25
|
version: '2.7'
|
|
26
26
|
description: Token exchange (RFC 8693), client credentials, authorization code + PKCE,
|
|
27
27
|
dynamic client registration (RFC 7591), authorization server discovery (RFC 8414),
|
|
28
|
-
JWT/JWKS verification, application credentials, and the AccessContext
|
|
29
|
-
container.
|
|
28
|
+
UserInfo, JWT/JWKS verification, application credentials, and the AccessContext
|
|
29
|
+
delegated-access container.
|
|
30
30
|
email:
|
|
31
31
|
- support@keycard.ai
|
|
32
32
|
executables: []
|
|
@@ -58,6 +58,7 @@ files:
|
|
|
58
58
|
- lib/keycardai/oauth/token_sources.rb
|
|
59
59
|
- lib/keycardai/oauth/token_types.rb
|
|
60
60
|
- lib/keycardai/oauth/token_verifier.rb
|
|
61
|
+
- lib/keycardai/oauth/userinfo.rb
|
|
61
62
|
- lib/keycardai/oauth/version.rb
|
|
62
63
|
- lib/keycardai/oauth/web_identity.rb
|
|
63
64
|
- lib/keycardai/oauth/workload_identity.rb
|