keycardai-oauth 0.1.0 → 0.3.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 33aa8b51bc6809128ee7546762842456ad9e204d0e60655217e879cb498a0b63
4
- data.tar.gz: ee37eb4012f1bf46e01058476c6ce4abd4ba23be66c83841fb5114241ec365c4
3
+ metadata.gz: fda3b3f30a1e8ccdf5d8c1dad0a9bc3ef99145990b1952c3382be766bffdd753
4
+ data.tar.gz: e4bb93fb097bc3dfd4cf894926860c241413c21b8e1cc05aff6432a632062e68
5
5
  SHA512:
6
- metadata.gz: 6f150494708f23dddb34ecdcb7bdaaac837cd9d13f6ca1e0730df19536cec26db3d497474b8484cfad13f4d640dbb1831295c5172f6203a4e8c67b6e0683adc5
7
- data.tar.gz: 9edbb0dda964b5f65eb88fae046bcb68b29e0b11f447633c27d0af9178b78ed377af2d7c42a2dcfb2fb75fbcffcda0e5c194e984a6ed8fc332ec5ecdc6e021ac
6
+ metadata.gz: 1a48955a16091ebec04bc7bef9b7effa8e1036a8dd273a9c351724bd86c729cf60f305c2d396dad3bbd50ff8b34af34bf6d8db7db6ccbd9c4d1e4fd91f058b1e
7
+ data.tar.gz: 8ed634c0e436e8a6ea9603bce24a7e861a56ac22df7caff2971e089ed641fc698d998c8b5750a2d1c746162c1437243fa0c8524bd31e617096ae00be9a5710f4
data/CHANGELOG.md CHANGED
@@ -14,3 +14,179 @@ dynamic client registration (RFC 7591), authorization code with PKCE including
14
14
  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
+
18
+ ## 0.3.0-keycardai-oauth (2026-09-01)
19
+
20
+
21
+ - feat(keycardai-oauth): UserInfo, typed OIDC discovery fields, multi-resource authorize (#29)
22
+ - * feat(keycardai-oauth): UserInfo, typed OIDC discovery fields, multi-resource authorize
23
+ - Three capabilities from the specs of record, at their current spec versions:
24
+ authorization-server-discovery v2, userinfo, authorization-code-pkce v3.
25
+ - Changed signatures, all in Keycardai::OAuth:
26
+ - build_authorize_url(..., resource: nil) -> build_authorize_url(..., resources: [])
27
+ exchange_authorization_code(..., resource: nil) -> resource removed outright
28
+ authenticate(..., resource: nil) -> authenticate(..., resources: [])
29
+ AuthorizationServerMetadata gains userinfo_endpoint and end_session_endpoint
30
+ - The single-resource arguments are removed, not deprecated, per spec-version 3.
31
+ Each entry of resources becomes its own RFC 8707 resource parameter on the
32
+ authorize URL; the authorization server binds them into the code at authorize
33
+ time, so the code exchange sends no resource at all.
34
+ - fetch_userinfo GETs the discovered userinfo_endpoint with a Bearer credential
35
+ and Accept: application/json, fails with a configuration error before any
36
+ request when metadata advertises no endpoint, rejects application/jwt bodies,
37
+ requires a non-empty sub, and parses the RFC 6750 WWW-Authenticate challenge on
38
+ a 401 into a typed OAuthError, defaulting to invalid_token when the challenge
39
+ carries no error.
40
+ - No version or changelog edits: the version pipeline is dormant until ECO-291
41
+ and per-gem breaking-change scoping is unaudited, so this carries no
42
+ breaking-change marker despite the changed signatures.
43
+ - Co-Authored-By: Larry Osakwe <larry@keycard.ai>
44
+ - * test(keycardai-oauth): pin the no-resources case, report the web-app gap
45
+ - Row 3 now asserts a resource-less authorize URL carries no resource
46
+ parameter, and the conformance report summary names the unshipped
47
+ web-app begin/complete rows the way it names the as-itself gap.
48
+ - ---------
49
+ - Co-authored-by: devin-ai-keycard <devin-ai@keycard.ai>
50
+ Co-authored-by: Larry Osakwe <larry@keycard.ai>
51
+ Co-authored-by: Larry-Osakwe <larryosak@gmail.com>
52
+
53
+ ## 0.2.0-keycardai-oauth (2026-08-19)
54
+
55
+
56
+ - feat(keycardai-oauth): expose the caller identity claims on the auth context (#24)
57
+ - Implements keycard-sdk-spec#44, which pins the auth-context identity
58
+ field set so the SDKs converge instead of each guessing.
59
+ - AccessToken gains subject_profile (the sub_profile claim) and
60
+ keycard_app_id, alongside the existing subject and client_id. The four
61
+ answer different questions and picking the wrong one is a bug, so the
62
+ class doc says which to key on: keycard_app_id is the stable
63
+ application identifier, client_id names the credential and rotates,
64
+ subject is the user on a user-present token and the application on an
65
+ application token, and subject_profile distinguishes those two directly
66
+ rather than leaving a consumer to infer it.
67
+ - Both Keycard claims are absent from a non-Keycard token, so they return
68
+ nil rather than raising, which the spec calls out as optional.
69
+ - Named subject_profile rather than sub_profile for consistency with the
70
+ existing subject accessor, which already reads sub. The spec treats
71
+ casing and naming as expression, and Go reads sub as Subject.
72
+ - Conformance row 4 in the bearer-middleware table now asserts the
73
+ identity fields, plus an application-token case where subject equals
74
+ keycard_app_id and a non-Keycard case where both are nil.
75
+
76
+ ## 0.1.0-keycardai-oauth (2026-08-19)
77
+
78
+
79
+ - feat(keycardai-oauth): authenticate loopback flow and challenge-driven entry (#7)
80
+ - Completes specs/oauth-client/authorization-code-pkce.md with the
81
+ high-level convenience layer:
82
+ - - authenticate: PKCE pair + CSRF state, authorize URL, shell-free
83
+ browser launch (open / xdg-open / cmd start), single-shot loopback
84
+ callback server (RFC 8252, converged defaults: port 8765, 300s
85
+ timeout, verifier length 128; port 0 binds ephemeral), state
86
+ validation (state_mismatch), redirect-carried OAuth errors typed,
87
+ InteractionTimeoutError on user inaction, then the code exchange
88
+ - resolve_issuer_from_challenge: RFC 9728 WWW-Authenticate
89
+ resource_metadata fetch to the first authorization server
90
+ - authenticate_from_challenge: challenge-driven entry to the same flow
91
+ - Tested against a real loopback server with a fake browser driving the
92
+ redirect (round trip, forged state, denied authorization, timeout).
93
+ - feat(keycardai-oauth): AccessContext, exchange orchestration, TokenVerifier, multi-zone (#6)
94
+ - Implements specs/delegated-access/access-context.md and
95
+ specs/multi-zone-and-ops/multi-zone-support.md, completing the oauth
96
+ core surface:
97
+ - - AccessContext: throwing access(resource) with typed
98
+ ResourceAccessError (global_error / resource_error / missing_token,
99
+ available_resources carried), non-throwing getters, status, mutators
100
+ for the grant layer, thread-safe, merge
101
+ - exchange_tokens_for_resources: non-throwing orchestration feeding an
102
+ AccessContext; per-resource scopes; impersonation path when
103
+ user_identifier is given
104
+ - TokenVerifier (server tier): JWTVerifier over a shared JWKSKeyring
105
+ returning AccessToken; verify_token_for_zone pins one zone issuer and
106
+ fails closed on unconfigured zones; caches keyed by issuer so no
107
+ cross-zone leakage
108
+ - TokenExchangeClient/ClientCredentialsClient accept an application
109
+ credential (exclusive with the raw pair, which now wraps into
110
+ ClientSecret) and a per-call issuer; token-endpoint cache is keyed by
111
+ issuer; credential-prepared params merge under caller overrides
112
+ - Conformance suites cover the full 16-row AccessContext table and the
113
+ 6-row multi-zone table, plus orchestration coverage.
114
+ - feat(keycardai-oauth): application credentials (ClientSecret, WebIdentity, WorkloadIdentity) (#5)
115
+ - Implements specs/application-credentials/{client-secret,web-identity,
116
+ workload-identity}.md. The Credential interface is two methods:
117
+ authorization_header(issuer:) and prepare_token_exchange_request.
118
+ - - ClientSecret: client_secret_basic only; single pair or issuer-keyed
119
+ multi-zone map; construction rejects empty ids/secrets/maps; unknown
120
+ zones fail closed by raising (Python shape), never falling back
121
+ - PrivateKeyManager + FilePrivateKeyStorage: RSA-2048 generate/persist/
122
+ load (./server_keys default, ./mcp_keys legacy fallback), RFC 7523
123
+ create_client_assertion (iss=sub=client_id, aud=token_endpoint, jti,
124
+ iat, exp=iat+300), public JWKS export
125
+ - WebIdentity: key-id resolution (explicit, sanitized server_name,
126
+ UUID), lazy key bootstrap, fresh assertion per request, no Basic
127
+ header, public_jwks + client_jwks_url accessors
128
+ - WorkloadIdentity: pluggable IdentityTokenSource (bare callables
129
+ adapted), token fetched fresh every request, optional client_id form
130
+ param (KEP 108), typed WorkloadIdentity{Configuration,Runtime}Error
131
+ with source ids and cause chaining
132
+ - Token sources: FileTokenSource (the one blessed env read: 4-var
133
+ discovery list at exact cross-SDK parity, construction-time
134
+ validation), GCPMetadataTokenSource, FlyTokenSource (raw HTTP over
135
+ the Fly Unix socket)
136
+ - No deprecated EKS alias: Ruby is new and ships none; FileTokenSource
137
+ covers the EKS contract. Conformance suites map to all three spec
138
+ Testing tables (workload-identity row 10 n/a per the above).
139
+ - feat(keycardai-oauth): DCR, PKCE primitives, authorize URL, code exchange (#4)
140
+ - Implements specs/oauth-client/dynamic-client-registration.md and the
141
+ primitive + building-block layers of
142
+ specs/oauth-client/authorization-code-pkce.md:
143
+ - - register_client: RFC 7591 registration with RFC-minimal request
144
+ (only caller-supplied fields), additional_metadata bag with named
145
+ fields winning, initial_access_token Bearer auth, typed RFC 7591
146
+ 3.2.2 error parsing, client_id required in the response
147
+ - PKCE module: verifier generation (43-128, unreserved charset, 128
148
+ default), S256/plain challenge derivation, generate_pair
149
+ - build_authorize_url: response_type=code plus PKCE, scope, state,
150
+ resource parameters
151
+ - exchange_authorization_code: public clients send client_id in the
152
+ body, confidential clients use HTTP Basic and omit it
153
+ - HTTP transport gains post_json; token-response parsing extracted to
154
+ TokenRequests module functions shared by the standalone exchange
155
+ - The high-level authenticate loopback flow (browser + callback server +
156
+ state validation) is the next slice; the spec unit tables do not cover
157
+ it. Conformance specs map one to one to both spec Testing tables.
158
+ - feat(keycardai-oauth): discovery, client credentials, token exchange, impersonation (#3)
159
+ - Implements specs/oauth-client/{authorization-server-discovery,
160
+ client-credentials,token-exchange}.md and
161
+ specs/delegated-access/impersonation.md:
162
+ - - fetch_authorization_server_metadata: RFC 8414 discovery with issuer
163
+ validation (issuer_mismatch), typed protocol errors, unknown fields
164
+ preserved; JWKSKeyring now discovers through this operation
165
+ - TokenResponse: shared token shape (scope parsed to array, Bearer
166
+ default, id_token, raw preserved), rejects a missing access_token
167
+ - TokenExchangeClient: RFC 8693 exchange with defaults, actor tokens,
168
+ client_assertion pass-through, Basic client auth, lazy cached token
169
+ endpoint, RFC 6749 5.2 typed OAuthError parsing
170
+ - impersonate: substitute-user exchange (vnd.kc.su+jwt unsigned subject
171
+ token, no actor_token, resource required client-side)
172
+ - ClientCredentialsClient: RFC 6749 4.4 grant on the same machinery
173
+ - Conformance specs map one to one to all four spec Testing tables.
174
+ - feat(keycardai-oauth): JWT/JWKS layer with conformance suites (#2)
175
+ - Implements specs/jwt-jwks/jwks-caching.md and
176
+ specs/jwt-jwks/jwt-signing-and-verification.md plus the shared
177
+ foundations they need:
178
+ - - Error taxonomy rooted at Keycardai::Error (configuration, network,
179
+ HTTP, invalid-token, JWKS quartet)
180
+ - Pluggable HTTP transport with a Net::HTTP default; no hidden retries
181
+ - JWKSKeyring: (issuer, kid) resolution with discovery + key TTLs,
182
+ same-origin jwks_uri enforcement, bounded cache with oldest-entry
183
+ eviction, per-issuer in-flight de-duplication
184
+ - JWTSigner: RS256, header {alg, kid}, default iss only when omitted,
185
+ temporal claims caller-managed
186
+ - JWTVerifier: fail-closed RFC 9068 verification, policy checks before
187
+ key resolution, trusted-issuer allowlist, kid required, alg allowlist
188
+ with none always rejected, clock_skew default 0 (exact comparison,
189
+ the canonical behavior per the spec Divergences; the types-table 60s
190
+ default looks stale and will be raised upstream)
191
+ - Conformance specs map one to one to the spec Testing tables; the two
192
+ live-zone integration rows are deferred to the E2E phase.
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 resource [String, nil] RFC 8707 resource indicator
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, resource: nil, port: DEFAULT_CALLBACK_PORT,
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, resource: resource,
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, resource: resource,
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:, resource:, browser_opener:)
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, resource: resource
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 resource [String, nil] RFC 8707 resource indicator
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, resource: 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, resource: 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
@@ -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). Standard fields are
11
- # first-class members; the complete document, including unknown fields, is
12
- # preserved in +raw+ and reachable through +[]+.
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.new(
72
- issuer: document["issuer"],
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)
@@ -4,13 +4,47 @@ module Keycardai
4
4
  module OAuth
5
5
  # A verified bearer token: the raw compact JWT plus its verified claims,
6
6
  # with convenience accessors for the RFC 9068 profile.
7
+ #
8
+ # Four accessors answer distinct questions about the caller, and picking
9
+ # the wrong one is a real bug rather than a style choice:
10
+ #
11
+ # - #client_id is the OAuth client that authenticated, so it names the
12
+ # credential. It rotates, so it does not stably identify an application.
13
+ # - #keycard_app_id is the stable Keycard application identifier. Key on
14
+ # this to answer "which application is calling", whatever the grant type
15
+ # or credential. For user agents it equals #client_id.
16
+ # - #subject is the user identifier on a user-present token and the
17
+ # application identifier on an application token.
18
+ # - #subject_profile distinguishes those two cases directly, rather than
19
+ # leaving a consumer to infer it from #subject against #client_id.
20
+ #
21
+ # #subject and #client_id are RFC 9068. The other two are Keycard claims
22
+ # and are absent from a non-Keycard token, so they return nil there.
7
23
  AccessToken = Data.define(:token, :claims) do
8
- # @return [String] the token's subject
24
+ # @return [String] the token's subject: the user for a user-present
25
+ # token, the application for an application token
9
26
  def subject
10
27
  claims["sub"]
11
28
  end
12
29
 
13
- # @return [String]
30
+ # Whether a user authorized this access or an application is acting on
31
+ # its own behalf. Reads the Keycard `sub_profile` claim.
32
+ #
33
+ # @return ["user", "app", nil] nil on a non-Keycard token
34
+ def subject_profile
35
+ claims["sub_profile"]
36
+ end
37
+
38
+ # The stable Keycard application identifier, which is the claim to key
39
+ # on when identifying the calling application.
40
+ #
41
+ # @return [String, nil] nil on a non-Keycard token
42
+ def keycard_app_id
43
+ claims["keycard_app_id"]
44
+ end
45
+
46
+ # @return [String] the OAuth client that authenticated, meaning the
47
+ # credential rather than the application
14
48
  def client_id
15
49
  claims["client_id"]
16
50
  end
@@ -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
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Keycardai
4
4
  module OAuth
5
- VERSION = "0.1.0"
5
+ VERSION = "0.3.0"
6
6
  end
7
7
  end
@@ -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), JWT/JWKS
33
- # verification, application credentials, and AccessContext.
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.1.0
4
+ version: 0.3.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 delegated-access
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