standard_id 0.49.1 → 0.51.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: 8b5e02f9651266077753a98125e3772a99bd2cf935da68d17f98df4bfd59505e
4
- data.tar.gz: e703964c3b6c2c0ffd10a5475f370c9dea0f6f4873fd6827606ee9869a12726d
3
+ metadata.gz: 70dbbe49421726d198ba1ee5502ec80abcce347b4ed006073ed81c99de743321
4
+ data.tar.gz: dcb5337f9cfc348a256f8317dbb5a946f3b8d5e10f64829287cceb1108d38ac4
5
5
  SHA512:
6
- metadata.gz: 4091588b0c3b2cc3f06c25682e840cb2a1293a94d67abaf25eb81cce6331fe7a8325aea577fd69ceb5405c09dd0aa39fc0e70ed17df493a92da566fd8da10343
7
- data.tar.gz: a10b76363a599b3c5fc29681665f8a20d564476cb6682a32c8555f4e2464945f41e0ccfcc7897db8c65df57483f8be3b927294282121d47bbc3226564e3dc0b2
6
+ metadata.gz: c469c67c58017cd97529b1e1eb47c5368c1c0e878f1123806796267f31c7791e4233bdb57597958fb460fb838ff8ae2f9ddf50864671d981f04796d5372ddfa5
7
+ data.tar.gz: bb7d5b2686415c1aa2d0198da73cb2df31372fc7ff36454ac879959bc0628552aafba5da7cd2aedf43bbd47801a673c6e172762abba4c8d3eabdbc6abb557534
data/CHANGELOG.md CHANGED
@@ -7,6 +7,33 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.51.0] - 2026-10-20
11
+
12
+ ### Added
13
+
14
+ - **Dynamic-client lifecycle events**: `oauth.client.registered`, `oauth.client.updated` and `oauth.client.deregistered` (`StandardId::Events::OAUTH_CLIENT_*`, in `OAUTH_EVENTS` and `SECURITY_EVENTS`), published by `Oauth::ClientRegistration` once the change has committed, with the client, its registered metadata, the owner by `owner_type` / `owner_id` (never the record, so no lazy load under strict loading) and, on registration and update, the RFC 7591 document as sent. A raising subscriber is logged and never fails the registration, whose one-time secrets are already committed. Consumers can move their registration audit off the owner row's `after_create_commit` and gain update/deregistration rows.
15
+ - **`protected_resources` entries can say what `aud` their tokens carry and which origin they live on.** `audience: :resource` binds the resource's tokens to its absolute URL (`resource`, RFC 8707 style) instead of the key, or a String names that `aud`; `origin: ->(request:) { ... }` builds `resource`, the metadata URL and the challenge's `resource_metadata` on a public origin other than `request.base_url` (a rewriting proxy). The key stays the resource's name for `protected_resource`, `audience_scopes` and the realm. `StandardId::ResourceServer` checks the per-request audience, so a host whose audience is a URL (jumpdrive-web) can serve its RFC 9728 document from the gem. `verify_audience` accepts a zero-argument callable for an audience known only per request.
16
+ - **`TokenMinter.mint(profile:)`**: mint for a specific profile of the account instead of the strict audience → profile lookup, which refuses an account holding several active profiles of the bound type. The profile must belong to the account, be active, and be of the bound type when the audience has a binding (`InvalidGrantError` otherwise); claim resolvers receive it as `profile:` and `OAUTH_TOKEN_ISSUED` reports its id. For hosts that choose the profile themselves (sidekick-web's Slack and trigger mints).
17
+ - **`TokenMinter.mint(audit_errors: :report)`**: an `OAUTH_TOKEN_ISSUED` subscriber that raises no longer costs the caller a token that is already signed; the error is logged and returned as the new `Result#audit_error`. The default stays `:raise`, like every grant. `OAUTH_TOKEN_ISSUING`, whose subscribers are the account-status and locking guards, always raises. `AudienceProfileResolver.profile_type_for` is now public.
18
+ - **`StandardId::RequestRefused`** (`status:`, `body:`, `headers:`): raised from a host hook, the API engine renders the host's own JSON response verbatim after the usual refusal cleanup — for refusals a client must tell apart from an OAuth error (a region block, a pending deletion) in the host's error envelope.
19
+ - **`c.social.before_account`**: runs on the API social callback once the provider has vouched for the user and before the account is found or created, with `provider:`, `social_info:`, `provider_tokens:`, `params:` and `request:`. Raising refuses with no account row and no account events.
20
+ - **`c.social.completion_before_mint`** (default false): the API social callback publishes `SOCIAL_AUTH_COMPLETED` after `before_mint` and before the mint, then reloads the account, for a host whose completion subscriber creates what the mint depends on (an invitation's profile). With these three and `before_mint`, sidekick-web can delete its subclass of `Api::Oauth::Callback::ProvidersController` (rarebit-sre#478).
21
+ - `TokenMinter` details: `Result#audit_error` is optional when building a `Result` by hand (host stubs keep working); `audit_errors: nil` raises `ArgumentError` like any unknown mode; a reported audit error is logged at `error` with its first backtrace line; an explicit `profile:` documents that `oauth.audience_profile_resolver` is not consulted; the bound-audience rule lives in one place (`TokenGrantFlow#profile_bound_audience`).
22
+ - `before_account` and `before_mint` both receive `audience:` and `scopes:`.
23
+ - **An `audience_profile_types` binding keyed by the resource name still applies under `audience: :resource`.** The binding is looked up under the matched `aud` first and, in a `StandardId::ResourceServer`, falls back to the resource's name, so switching a resource to URL audiences no longer silently drops a binding configured as `"mcp" => [...]`.
24
+ - **Dynamic-client lifecycle events wait for the outermost transaction** (`ActiveRecord.after_all_transactions_commit`): a host that wraps `ClientRegistration` in its own transaction and rolls it back no longer gets an event for a client that never existed.
25
+ - `StandardId::ResourceServer` resolves a resource's `origin:` callable once per request.
26
+
27
+ ## [0.50.0] - 2026-10-20
28
+
29
+ ### Changed
30
+
31
+ - **`AudienceResolver`: the client's registered `metadata["audience"]` now outranks the `audience` parameter.** The registered default is set by the server (`dynamic_registration_attributes`) or an operator; letting the request's `audience` beat it meant an openly registered client could ask `/authorize` for another allowed audience, which every consumer's shim had prevented by stripping the parameter. New order: `audience_resolver` hook → registered default → `audience` param → RFC 8707 `resource`. Hosts that set `audience_resolver` to return the registered default are unaffected; a client with no registered default behaves as before.
32
+
33
+ ### Added
34
+
35
+ - **`c.oauth.authorize_scope_resolver`**: normalise the requested scope at `/authorize` once the client and audience are known and before `audience_scopes` is enforced — collapse a hierarchy, narrow to what the audience consumes, append a default. Callable `(scope:, audience:, client:, request:)` returning a String, Array or nil (unchanged); the answer is what the code is issued with and what consent shows. Validated at boot.
36
+
10
37
  ## [0.49.1] - 2026-10-20
11
38
 
12
39
  ### Fixed
data/README.md CHANGED
@@ -478,6 +478,25 @@ refuses the sign-in on an account that already existed, what the hook wrote
478
478
  stays: make it idempotent. It covers the API callback only; the WebEngine's
479
479
  social callback does not run it.
480
480
 
481
+ Two more knobs on the same callback. `config.social.before_account` runs before
482
+ the account is found or created (same keywords minus `account:`), for a refusal
483
+ that must leave no account row and fire no account events — and no event of its
484
+ own, so audit it in the hook. `config.social.completion_before_mint = true`
485
+ publishes `SOCIAL_AUTH_COMPLETED` after `before_mint` and before the mint (then
486
+ reloads the account), for a completion subscriber that creates what the mint
487
+ depends on; if the mint then refuses on an account that already existed, the
488
+ subscriber's writes stay, so make it idempotent. Either hook can refuse in the
489
+ host's own error envelope:
490
+
491
+ ```ruby
492
+ config.social.before_account = ->(social_info:, request:) {
493
+ if RegionBlock.blocked?(request)
494
+ raise StandardId::RequestRefused.new(status: :forbidden,
495
+ body: { error: { code: "REGION_BLOCKED", message: "Sign-in is not available here" } })
496
+ end
497
+ }
498
+ ```
499
+
481
500
 
482
501
  The `social.google_*` and `social.apple_*` fields are declared by the **provider
483
502
  plugin gems**, not by `standard_id` itself — add `standard_id-google` and/or
@@ -909,6 +928,9 @@ Every StandardId event automatically carries tracing metadata (`event_id`, `time
909
928
  | | `oauth.token.issued` | `access_token_id`, `grant_type`, `client_id`, `account`, `expires_in` | After tokens are generated |
910
929
  | | `oauth.token.refreshed` | `old_token_id`, `new_token_id`, `client_id`, `account` | After a refresh token is redeemed |
911
930
  | | `oauth.code.consumed` | `authorization_code`, `client_id`, `account` | After an authorization code is exchanged |
931
+ | | `oauth.client.registered` | `client_application`, `client_id`, `client_name`, `client_type`, `redirect_uris`, `scope`, `owner_type`, `owner_id`, `client_metadata` | After a dynamic registration (RFC 7591) commits. Never fails the registration: a raising subscriber is logged |
932
+ | | `oauth.client.updated` | same as `registered` | After an RFC 7592 update commits |
933
+ | | `oauth.client.deregistered` | same, without `client_metadata` | After an RFC 7592 deregistration commits |
912
934
  | Passwordless | `passwordless.code.requested` | `identifier`, `channel` (email/sms), `realm` | After the challenge row is committed, before `code.generated` / delivery. Subscribers should not raise: the previous code is already invalidated by then |
913
935
  | | `passwordless.code.generated` | `code_challenge`, `identifier`, `channel`, `expires_at` | After an OTP is created |
914
936
  | | `passwordless.code.sent` | `identifier`, `channel`, `realm`, `delivery_status` | After an OTP is handed to delivery (not for `Otp.issue(delivery: :manual)`). `delivery_status`: `"sent"` (built-in mailer enqueued it), `"failed"` (built-in mailer was responsible but did not enqueue), `"delegated"` (a host `PASSWORDLESS_CODE_GENERATED` subscriber delivers — the engine can't confirm it) |
@@ -1367,9 +1389,9 @@ redirect_to "/api/authorize?" + {
1367
1389
 
1368
1390
  **Which audience is the token for?** `/authorize` resolves it server-side
1369
1391
  (`StandardId::Oauth::AudienceResolver`), in this order: `config.oauth.audience_resolver`
1370
- when set; the `audience` parameter; the RFC 8707 `resource` parameter mapped through
1371
- `config.oauth.resource_audiences`; the client's registered default
1372
- `metadata["audience"]`. The result is bound to the authorization code and reaches
1392
+ when set; the client's registered default `metadata["audience"]` (server-set, so it
1393
+ outranks the request); the `audience` parameter; the RFC 8707 `resource` parameter
1394
+ mapped through `config.oauth.resource_audiences`. The result is bound to the authorization code and reaches
1373
1395
  the token as `aud`, and `config.oauth.audience_scopes` is enforced for it at
1374
1396
  `/authorize` (a scope outside the audience's vocabulary is `invalid_scope`). So a
1375
1397
  dynamically registered MCP client whose registration stamps
@@ -1378,10 +1400,28 @@ in front of the endpoint. The `resource` parameter is ignored until
1378
1400
  `resource_audiences` is set; once it is, an unmapped resource is `invalid_target`
1379
1401
  (RFC 8707 §2).
1380
1402
 
1381
- A client-supplied `audience` still outranks the registered default, so
1382
- `audience_scopes` enforcement is only as strong as `config.oauth.allowed_audiences`
1383
- at the token endpoint. A host that wants the registered default to be the only
1384
- answer sets `audience_resolver` to `->(client:) { client.metadata["audience"] }`.
1403
+ For a client with no registered default, a client-supplied `audience` is taken as
1404
+ is, so `audience_scopes` enforcement for such clients is only as strong as
1405
+ `config.oauth.allowed_audiences` at the token endpoint.
1406
+
1407
+ **Which scope?** `config.oauth.authorize_scope_resolver` normalises the requested
1408
+ scope once the client and audience are known and before `audience_scopes` is
1409
+ enforced — collapse a hierarchy, narrow to what the audience consumes, append a
1410
+ default — and its answer is what the code is issued with and the consent screen
1411
+ shows:
1412
+
1413
+ ```ruby
1414
+ config.oauth.authorize_scope_resolver = ->(scope:, audience:) {
1415
+ tokens = scope.to_s.split
1416
+ next nil unless audience == "mcp" && tokens.include?("mcp") # nil leaves the request as is
1417
+
1418
+ tokens - ["mcp:read"] # "mcp" already implies read; an Array or a String is fine
1419
+ }
1420
+ ```
1421
+
1422
+ It runs again when a consent resumes, so make it idempotent (its own output must
1423
+ come back unchanged); it also decides what the consent screen shows and what the
1424
+ grant records.
1385
1425
 
1386
1426
  ```ruby
1387
1427
  StandardId.configure do |config|
@@ -75,9 +75,13 @@ module StandardId
75
75
  # Declare the allowed audiences for this controller.
76
76
  # The token's `aud` claim must include at least one of these values.
77
77
  #
78
- # @param audiences [Array<String>] allowed JWT `aud` claim values
78
+ # An audience known only per request (a resource identified by its own
79
+ # absolute URL behind a host-dependent origin) may be given as a
80
+ # zero-argument callable, evaluated on the controller instance.
81
+ #
82
+ # @param audiences [Array<String, Proc>] allowed JWT `aud` claim values
79
83
  def verify_audience(*audiences)
80
- self._required_audiences = audiences.flatten.map(&:to_s)
84
+ self._required_audiences = audiences.flatten.map { |a| a.respond_to?(:call) ? a : a.to_s }
81
85
  end
82
86
  end
83
87
 
@@ -97,12 +101,13 @@ module StandardId
97
101
  # layer handle 401 — don't mask it with a 403.
98
102
  return unless current_session
99
103
 
104
+ required = required_audiences
100
105
  token_audiences = Array(current_session.aud)
101
- matched = (token_audiences & _required_audiences).first
106
+ matched = (token_audiences & required).first
102
107
 
103
108
  if matched.nil?
104
109
  raise StandardId::InvalidAudienceError.new(
105
- required: _required_audiences,
110
+ required: required,
106
111
  actual: token_audiences
107
112
  )
108
113
  end
@@ -110,15 +115,22 @@ module StandardId
110
115
  enforce_audience_profile_binding!(matched, token_audiences)
111
116
  end
112
117
 
118
+ # `_required_audiences` with any callable entries evaluated for this request.
119
+ def required_audiences
120
+ _required_audiences.flat_map { |a| a.respond_to?(:call) ? Array(instance_exec(&a)) : [a] }
121
+ .map(&:to_s).reject(&:blank?).uniq
122
+ end
123
+
113
124
  # Enforce `audience_profile_types[matched]` against the current account.
114
125
  # No-op when the audience has no binding configured (back-compat).
115
126
  def enforce_audience_profile_binding!(matched_audience, token_audiences)
116
- expected_types = StandardId::Oauth::AudienceProfileResolver.profile_types_for(matched_audience)
127
+ binding_audience = profile_binding_audience(matched_audience)
128
+ expected_types = StandardId::Oauth::AudienceProfileResolver.profile_types_for(binding_audience)
117
129
  return if expected_types.empty?
118
130
 
119
131
  profile = StandardId::Oauth::AudienceProfileResolver.call(
120
132
  account: current_account,
121
- audience: matched_audience
133
+ audience: binding_audience
122
134
  )
123
135
  actual_type = profile_type_name(profile)
124
136
 
@@ -133,13 +145,28 @@ module StandardId
133
145
  audience: matched_audience,
134
146
  expected_profile_types: expected_types,
135
147
  actual_profile_type: actual_type,
136
- required: _required_audiences,
148
+ required: required_audiences,
137
149
  actual: token_audiences
138
150
  )
139
151
  emit_audience_mismatch(error)
140
152
  raise error
141
153
  end
142
154
 
155
+ # The key `audience_profile_types` is looked up under: the matched `aud`,
156
+ # or — for a StandardId::ResourceServer whose tokens carry the resource's
157
+ # URL (`protected_resources` entry with `audience: :resource`) — the
158
+ # resource's name when only that is configured. Without the fallback a
159
+ # binding configured under the name would silently stop applying once the
160
+ # resource switched to URL audiences.
161
+ def profile_binding_audience(matched_audience)
162
+ return matched_audience if StandardId::Oauth::AudienceProfileResolver.profile_types_for(matched_audience).any?
163
+
164
+ resource_name = respond_to?(:_protected_resource_audience) ? _protected_resource_audience.presence : nil
165
+ return matched_audience if resource_name.nil?
166
+
167
+ StandardId::Oauth::AudienceProfileResolver.profile_types_for(resource_name).any? ? resource_name : matched_audience
168
+ end
169
+
143
170
  def profile_type_name(profile)
144
171
  return nil if profile.nil?
145
172
  return profile.profileable_type.to_s if profile.respond_to?(:profileable_type)
@@ -70,7 +70,9 @@ module StandardId
70
70
  def protected_resource(audience, scopes: nil)
71
71
  self._protected_resource_audience = audience.to_s
72
72
  self._protected_resource_scopes = Array(scopes).flat_map { |s| s.to_s.split }.reject(&:blank?).uniq
73
- verify_audience audience.to_s
73
+ # Per request: with `audience: :resource` the `aud` is the resource's
74
+ # absolute URL, which depends on the origin.
75
+ verify_audience -> { protected_resource_token_audience }
74
76
  end
75
77
  end
76
78
 
@@ -91,8 +93,23 @@ module StandardId
91
93
  require_scopes!(*_protected_resource_scopes)
92
94
  end
93
95
 
96
+ # The `aud` a token for this resource must carry (the audience itself,
97
+ # unless its `protected_resources` entry says otherwise).
98
+ def protected_resource_token_audience
99
+ StandardId::Oauth::ProtectedResourceDocument.token_audience(
100
+ audience: protected_resource_audience, origin: protected_resource_origin
101
+ )
102
+ end
103
+
104
+ # Memoized: the host's `origin:` callable answers once per request, so
105
+ # the audience check, the challenge and the metadata URL agree.
106
+ def protected_resource_origin
107
+ @protected_resource_origin ||=
108
+ StandardId::Oauth::ProtectedResourceDocument.origin_for(protected_resource_audience, request: request)
109
+ end
110
+
94
111
  def protected_resource_metadata_url
95
- StandardId::Oauth::ProtectedResourceDocument.metadata_url(audience: protected_resource_audience, origin: request.base_url)
112
+ StandardId::Oauth::ProtectedResourceDocument.metadata_url(audience: protected_resource_audience, origin: protected_resource_origin)
96
113
  end
97
114
 
98
115
  # The description never carries operator- or user-authored text: it goes
@@ -133,7 +150,7 @@ module StandardId
133
150
  response.set_header(
134
151
  "WWW-Authenticate",
135
152
  StandardId::Oauth::ProtectedResourceDocument.challenge(
136
- audience: protected_resource_audience, origin: request.base_url,
153
+ audience: protected_resource_audience, origin: protected_resource_origin,
137
154
  error: error, error_description: error_description, scope: scope
138
155
  )
139
156
  )
@@ -74,7 +74,7 @@ module StandardId
74
74
  !StandardId::ClientGrant.granted?(
75
75
  account: current_account,
76
76
  client_id: client.client_id,
77
- requested_scope: params[:scope]
77
+ requested_scope: consent_scope
78
78
  )
79
79
  end
80
80
 
@@ -82,6 +82,21 @@ module StandardId
82
82
  @consent_client ||= StandardId::ClientApplication.active.find_by(client_id: params[:client_id])
83
83
  end
84
84
 
85
+ # The scope consent is checked against, shown and recorded: the request's,
86
+ # normalised by `oauth.authorize_scope_resolver` exactly as the flow will
87
+ # normalise it when the code is issued, so the screen and the grant never
88
+ # disagree with the token. Needs the client and the resolved audience, so
89
+ # it is computed here rather than inside the flow.
90
+ def consent_scope
91
+ return @consent_scope if defined?(@consent_scope)
92
+
93
+ client = consent_client
94
+ audience = StandardId::Oauth::AudienceResolver.resolve(client: client, params: params, request: request)
95
+ @consent_scope = StandardId::Oauth::AuthorizeScopeResolver.resolve(
96
+ scope: params[:scope], audience: audience, client: client, request: request
97
+ )
98
+ end
99
+
85
100
  def redirect_to_consent
86
101
  payload = StandardId::Oauth::ConsentPayload.encode(authorize_params_for_resume)
87
102
  base = StandardId.config.login_url.present? ? consent_base_from_login_url : "/consent"
@@ -98,13 +113,14 @@ module StandardId
98
113
 
99
114
  # The exact params required to resume authorization-code issuance after
100
115
  # approval. Carried through a signed payload so they cannot be tampered
101
- # with; redirect_uri + PKCE are revalidated when the flow re-runs.
116
+ # with; redirect_uri + PKCE are revalidated when the flow re-runs. The
117
+ # scope is the normalised one the user is shown (see #consent_scope).
102
118
  def authorize_params_for_resume
103
119
  {
104
120
  response_type: response_type,
105
121
  client_id: params[:client_id],
106
122
  redirect_uri: params[:redirect_uri],
107
- scope: params[:scope],
123
+ scope: consent_scope,
108
124
  audience: params[:audience],
109
125
  resource: params[:resource],
110
126
  state: params[:state],
@@ -31,6 +31,9 @@ module StandardId
31
31
  # WebEngine's existing handling covers it — so it is mapped here.
32
32
  rescue_from StandardId::LoginMethodDenied, with: :handle_login_method_denied
33
33
 
34
+ # A host hook refused the request with its own response.
35
+ rescue_from StandardId::RequestRefused, with: :handle_request_refused
36
+
34
37
  protected
35
38
 
36
39
  def validate_content_type!
@@ -90,6 +93,11 @@ module StandardId
90
93
  }, status: error.http_status
91
94
  end
92
95
 
96
+ def handle_request_refused(error)
97
+ error.headers.each { |name, value| response.set_header(name.to_s, value.to_s) }
98
+ render json: error.body, status: error.status
99
+ end
100
+
93
101
  def handle_oauth_error(error)
94
102
  # A lib-level rate limit (StandardId::RateLimiter) knows its window;
95
103
  # say so, as the controller-level limits do.
@@ -22,6 +22,7 @@ module StandardId
22
22
  provider_response = fetch_provider_user_info
23
23
  social_info = provider_response[:user_info]
24
24
  provider_tokens = provider_response[:tokens]
25
+ run_before_social_account(social_info:, provider_tokens:)
25
26
  account = find_or_create_account_from_social(social_info)
26
27
  newly_created = account.previously_new_record?
27
28
 
@@ -33,8 +34,16 @@ module StandardId
33
34
  # new account, and no usable token or session from this request.
34
35
  token_response = nil
35
36
  flow = nil
37
+ completion_before_mint = StandardId.config.social.completion_before_mint
36
38
  begin
37
39
  run_before_social_mint(account:, social_info:, provider_tokens:)
40
+ if completion_before_mint
41
+ complete_social_sign_in(account:, social_info:, provider_tokens:)
42
+ # The subscriber may have created records hanging off +account+
43
+ # (a profile); the mint, and AccountCleanup on a refusal, must
44
+ # see the database rather than those in-memory objects.
45
+ account.reload
46
+ end
38
47
  flow = StandardId::Oauth::SocialFlow.new(
39
48
  params,
40
49
  request,
@@ -43,13 +52,7 @@ module StandardId
43
52
  scopes: params[:scope]
44
53
  )
45
54
  token_response = flow.execute
46
- run_social_callback(
47
- provider: provider.provider_name,
48
- social_info:,
49
- provider_tokens:,
50
- account:,
51
- original_request_params: forwarded_request_params
52
- )
55
+ complete_social_sign_in(account:, social_info:, provider_tokens:) unless completion_before_mint
53
56
  commit_social_link!
54
57
  rescue StandardError
55
58
  removed = social_account_removed_concurrently?(account, newly_created:)
@@ -72,25 +75,54 @@ module StandardId
72
75
  true
73
76
  end
74
77
 
78
+ def complete_social_sign_in(account:, social_info:, provider_tokens:)
79
+ run_social_callback(
80
+ provider: provider.provider_name,
81
+ social_info:,
82
+ provider_tokens:,
83
+ account:,
84
+ original_request_params: forwarded_request_params
85
+ )
86
+ end
87
+
88
+ # config.social.before_account: before find-or-create, so a refusal
89
+ # here has nothing to clean up. The hook audits its own refusals: no
90
+ # event is published for them (none would have an account to name).
91
+ def run_before_social_account(social_info:, provider_tokens:)
92
+ call_social_hook(StandardId.config.social.before_account,
93
+ provider: provider.provider_name,
94
+ social_info: social_info,
95
+ provider_tokens: provider_tokens,
96
+ **requested_audience_and_scopes,
97
+ params: forwarded_request_params,
98
+ request: request)
99
+ end
100
+
75
101
  # config.social.before_mint, inside the same begin/rescue as the mint
76
- # so a refusal here is cleaned up exactly like one from the grant.
102
+ # so a refusal here is cleaned up exactly like one from the grant —
103
+ # except in the concurrent-removal race, where the retryable
104
+ # InvalidGrantError replaces even a StandardId::RequestRefused.
77
105
  def run_before_social_mint(account:, social_info:, provider_tokens:)
78
- hook = StandardId.config.social.before_mint
79
- return unless hook.respond_to?(:call)
80
-
81
- # audience: and scopes: are passed explicitly: forwarded_request_params
82
- # strips both (RESERVED_CALLBACK_PARAMS), and the audience is the
83
- # thing the headline use case keys on.
84
- context = {
106
+ call_social_hook(StandardId.config.social.before_mint,
85
107
  account: account,
86
108
  provider: provider.provider_name,
87
109
  social_info: social_info,
88
110
  provider_tokens: provider_tokens,
89
- audience: params[:audience].presence,
90
- scopes: params[:scope].presence,
111
+ **requested_audience_and_scopes,
91
112
  params: forwarded_request_params,
92
- request: request
93
- }
113
+ request: request)
114
+ end
115
+
116
+ # audience: and scopes: are passed explicitly: forwarded_request_params
117
+ # strips both (RESERVED_CALLBACK_PARAMS), and the audience is the
118
+ # thing the headline use cases key on.
119
+ def requested_audience_and_scopes
120
+ { audience: params[:audience].presence, scopes: params[:scope].presence }
121
+ end
122
+
123
+ def call_social_hook(hook, **context)
124
+ return unless hook.respond_to?(:call)
125
+
94
126
  hook.call(**StandardId::Utils::CallableParameterFilter.filter(hook, context))
95
127
  end
96
128
 
@@ -28,7 +28,9 @@ module StandardId
28
28
 
29
29
  response.headers["Cache-Control"] = "public, max-age=3600"
30
30
  render json: StandardId::Oauth::ProtectedResourceDocument.build(
31
- audience: audience, origin: request.base_url, request: request
31
+ audience: audience,
32
+ origin: StandardId::Oauth::ProtectedResourceDocument.origin_for(audience, request: request),
33
+ request: request
32
34
  )
33
35
  end
34
36
 
@@ -61,6 +61,11 @@ module StandardId
61
61
  kind: :keyword,
62
62
  keywords: %i[metadata request]
63
63
  },
64
+ "oauth.authorize_scope_resolver" => {
65
+ signature: "(scope:, audience:, client:, request:)",
66
+ kind: :keyword,
67
+ keywords: %i[scope audience client request]
68
+ },
64
69
  "oauth.audience_scope_resolver" => {
65
70
  signature: "(audience:, client:, configured_scopes:)",
66
71
  kind: :keyword,
@@ -421,13 +421,31 @@ StandardId::ConfigSchema.define do
421
421
  # instead of trusting a client-supplied `audience` alone. Resolution order
422
422
  # (StandardId::Oauth::AudienceResolver): this callable when set (any
423
423
  # subset of client:, audience:, resource:, request:; a non-nil answer is
424
- # final), else the `audience` param, else the RFC 8707 `resource` param
425
- # mapped through `resource_audiences`, else the client's registered
426
- # default `metadata["audience"]`. The resolved audience is bound to the
427
- # authorization code and reaches the token as `aud`; `audience_scopes`
424
+ # final), else the client's registered default `metadata["audience"]`
425
+ # (server-set, so it outranks the request), else the `audience` param,
426
+ # else the RFC 8707 `resource` param mapped through `resource_audiences`.
427
+ # The resolved audience is bound to the authorization code and reaches the
428
+ # token as `aud`; `audience_scopes`
428
429
  # (above) is then enforced at /authorize for it.
429
430
  field :audience_resolver, type: :any, default: nil
430
431
 
432
+ # Normalise the scope an /authorize request asks for, once the client and
433
+ # audience are known and before `audience_scopes` is enforced — the place
434
+ # for what hosts used to do in a shim: collapse a hierarchy ("mcp mcp:read"
435
+ # → "mcp"), narrow a request to what the chosen audience consumes, append
436
+ # a default. A callable taking any subset of `scope:` (the raw param),
437
+ # `audience:` (resolved), `client:` and `request:`; returns the scope as a
438
+ # String or Array, or nil to leave the request unchanged. The result is
439
+ # what the code is issued with, what the consent screen shows and what the
440
+ # grant is checked against and recorded as. It runs again on the consent
441
+ # resume, so it must be idempotent (its own output must come back unchanged).
442
+ #
443
+ # c.oauth.authorize_scope_resolver = ->(scope:, audience:) {
444
+ # tokens = scope.to_s.split
445
+ # audience == "mcp" && tokens.include?("mcp") ? tokens - ["mcp:read"] : nil
446
+ # }
447
+ field :authorize_scope_resolver, type: :any, default: nil
448
+
431
449
  # RFC 8707: resource indicator → audience, e.g.
432
450
  # { "https://app.example.com/mcp" => "mcp" }. Matched with the trailing
433
451
  # slash removed. Empty (the default) means the `resource` parameter is
@@ -443,6 +461,10 @@ StandardId::ConfigSchema.define do
443
461
  # emitted as given (callables receive `{ origin:, audience:, issuer:,
444
462
  # request: }`; nil removes a member). `root: true` picks the resource the
445
463
  # bare /.well-known/oauth-protected-resource serves when several exist.
464
+ # `origin: ->(request:) { ... }` builds `resource` and the metadata URL on
465
+ # a public origin other than `request.base_url` (a rewriting proxy);
466
+ # `audience: :resource` says tokens for the resource carry its absolute URL
467
+ # as `aud` rather than the key (or a String names that `aud`).
446
468
  #
447
469
  # c.oauth.protected_resources = { "mcp" => { path: "/mcp", scopes_supported: %w[mcp mcp:read] } }
448
470
  #
@@ -712,10 +734,30 @@ StandardId::ConfigSchema.define do
712
734
  # if those refuse the sign-in on an account that already existed, whatever
713
735
  # the hook wrote stays (make it idempotent); and it covers the API
714
736
  # callback only — the WebEngine's social callback does not run it.
715
- # SOCIAL_AUTH_COMPLETED still fires after the mint as before. Replaces the
737
+ # SOCIAL_AUTH_COMPLETED fires after the mint unless completion_before_mint
738
+ # is set; raise StandardId::RequestRefused to answer in the host's own
739
+ # error envelope. Replaces the
716
740
  # host subclass of the callback controller that reordered these steps by
717
741
  # copying its body.
718
742
  field :before_mint, type: :any, default: nil
743
+ # Runs on the API social callback once the provider has vouched for the
744
+ # user and BEFORE the account is found or created — for a refusal that
745
+ # must leave no account row and fire no account events (a region block).
746
+ # A callable taking any subset of `provider:`, `social_info:`,
747
+ # `provider_tokens:`, `audience:`, `scopes:`, `params:` (forwarded, as for
748
+ # before_mint) and `request:`. Raising refuses the sign-in with nothing
749
+ # written; raise StandardId::RequestRefused to answer with the host's own
750
+ # response. No event is published for the refusal: audit it in the hook.
751
+ # API callback only, like before_mint.
752
+ field :before_account, type: :any, default: nil
753
+ # When true, the API social callback publishes SOCIAL_AUTH_COMPLETED after
754
+ # before_mint and BEFORE the mint (then reloads the account), instead of
755
+ # after it — for a host whose completion subscriber writes what the mint
756
+ # depends on (accepting an invitation that creates the profile the
757
+ # audience is bound to). A refusal at the mint then comes after the
758
+ # subscriber's writes, which stay on an account that already existed, so
759
+ # the subscriber must be idempotent. The web callback is unchanged.
760
+ field :completion_before_mint, type: :boolean, default: false
719
761
  field :allowed_redirect_url_prefixes, type: :array, default: []
720
762
  field :available_scopes, type: :array, default: -> { [] }
721
763
  # How a social login whose (provider, sub) is not linked yet may link to
@@ -191,6 +191,27 @@ module StandardId
191
191
  # Lifecycle hook errors
192
192
  class AuthenticationDenied < StandardError; end
193
193
 
194
+ # Raised by a host hook (`config.social.before_account`,
195
+ # `config.social.before_mint`) to refuse a request with the host's own
196
+ # response: the API engine renders `body` as JSON with `status` (and any
197
+ # `headers`) verbatim, after the refusing flow has cleaned up as for any
198
+ # other refusal. For a refusal the host's clients must be able to tell apart
199
+ # from an OAuth error — a region block, a pending deletion — in the host's
200
+ # error envelope.
201
+ #
202
+ # raise StandardId::RequestRefused.new(status: :forbidden,
203
+ # body: { error: { code: "REGION_BLOCKED", message: "..." } })
204
+ class RequestRefused < StandardError
205
+ attr_reader :status, :body, :headers
206
+
207
+ def initialize(status:, body:, headers: {}, message: nil)
208
+ @status = status
209
+ @body = body
210
+ @headers = headers.to_h
211
+ super(message || "Request refused (#{status})")
212
+ end
213
+ end
214
+
194
215
  # Raised when `config.login_method_policy` refuses an authentication method
195
216
  # for an account (e.g. "staff must sign in with the org IdP"). See
196
217
  # StandardId::LoginMethodPolicy.
@@ -46,6 +46,9 @@ module StandardId
46
46
  OAUTH_REFRESH_TOKEN_REUSE_DETECTED = "oauth.refresh_token.reuse_detected"
47
47
  OAUTH_REFRESH_TOKEN_REUSE_GRACED = "oauth.refresh_token.reuse_graced"
48
48
  OAUTH_AUDIENCE_MISMATCH = "oauth.audience.mismatch"
49
+ OAUTH_CLIENT_REGISTERED = "oauth.client.registered"
50
+ OAUTH_CLIENT_UPDATED = "oauth.client.updated"
51
+ OAUTH_CLIENT_DEREGISTERED = "oauth.client.deregistered"
49
52
 
50
53
  PASSWORDLESS_CODE_REQUESTED = "passwordless.code.requested"
51
54
  PASSWORDLESS_CODE_GENERATED = "passwordless.code.generated"
@@ -122,7 +125,10 @@ module StandardId
122
125
  OAUTH_TOKEN_REVOKED,
123
126
  OAUTH_REFRESH_TOKEN_REUSE_DETECTED,
124
127
  OAUTH_REFRESH_TOKEN_REUSE_GRACED,
125
- OAUTH_AUDIENCE_MISMATCH
128
+ OAUTH_AUDIENCE_MISMATCH,
129
+ OAUTH_CLIENT_REGISTERED,
130
+ OAUTH_CLIENT_UPDATED,
131
+ OAUTH_CLIENT_DEREGISTERED
126
132
  ].freeze
127
133
 
128
134
  PASSWORDLESS_EVENTS = [
@@ -184,6 +190,9 @@ module StandardId
184
190
  OAUTH_TOKEN_REVOKED,
185
191
  OAUTH_REFRESH_TOKEN_REUSE_DETECTED,
186
192
  OAUTH_AUDIENCE_MISMATCH,
193
+ OAUTH_CLIENT_REGISTERED,
194
+ OAUTH_CLIENT_UPDATED,
195
+ OAUTH_CLIENT_DEREGISTERED,
187
196
  # Passwordless
188
197
  PASSWORDLESS_CODE_FAILED,
189
198
  PASSWORDLESS_ACCOUNT_CREATED,
@@ -40,6 +40,9 @@ module StandardId
40
40
  "oauth.token.issued" => :info,
41
41
  "oauth.token.refreshed" => :debug,
42
42
  "oauth.code.consumed" => :debug,
43
+ "oauth.client.registered" => :info,
44
+ "oauth.client.updated" => :info,
45
+ "oauth.client.deregistered" => :info,
43
46
  "passwordless.code.requested" => :debug,
44
47
  "passwordless.code.generated" => :debug,
45
48
  "passwordless.code.sent" => :info,
@@ -83,6 +83,18 @@ module StandardId
83
83
  ) { resolve_bound_profile!(account, audience) }
84
84
  end
85
85
 
86
+ # The type name a profile is matched on: `profileable_type`, else
87
+ # `type`, else its class name.
88
+ def profile_type_for(profile)
89
+ if profile.respond_to?(:profileable_type)
90
+ profile.profileable_type.to_s
91
+ elsif profile.respond_to?(:type)
92
+ profile.type.to_s
93
+ else
94
+ profile.class.name.to_s
95
+ end
96
+ end
97
+
86
98
  private
87
99
 
88
100
  def resolve_bound_profile!(account, audience)
@@ -163,16 +175,6 @@ module StandardId
163
175
  )
164
176
  end
165
177
  end
166
-
167
- def profile_type_for(profile)
168
- if profile.respond_to?(:profileable_type)
169
- profile.profileable_type.to_s
170
- elsif profile.respond_to?(:type)
171
- profile.type.to_s
172
- else
173
- profile.class.name.to_s
174
- end
175
- end
176
178
  end
177
179
  end
178
180
  end
@@ -9,9 +9,14 @@ module StandardId
9
9
  # subset of `client:`, `audience:` (the raw param), `resource:` (the
10
10
  # RFC 8707 param) and `request:`. A non-nil answer is final; nil
11
11
  # falls through to the defaults below.
12
- # 2. The `audience` parameter, when present (what hosts' shims inject
13
- # today, so nothing changes for them).
14
- # 3. The `resource` parameter (RFC 8707), mapped through
12
+ # 2. The client's registered default, `metadata["audience"]` — set by the
13
+ # server at registration (`dynamic_registration_attributes`) or by an
14
+ # operator, so it is the one input here the client did not choose, and
15
+ # it outranks what the request says (0.50.0; before that the
16
+ # `audience` parameter came first, which let an openly registered
17
+ # client ask for another allowed audience).
18
+ # 3. The `audience` parameter, when present.
19
+ # 4. The `resource` parameter (RFC 8707), mapped through
15
20
  # `config.oauth.resource_audiences` ({ "https://app.example/mcp" =>
16
21
  # "mcp" }); compared with the trailing slash removed. With no mapping
17
22
  # configured the parameter is ignored (MCP clients send it on every
@@ -19,9 +24,6 @@ module StandardId
19
24
  # get that default). With a mapping, an unmapped resource is refused
20
25
  # with `invalid_target` (RFC 8707 §2), never silently issued without
21
26
  # an audience.
22
- # 4. The client's registered default, `metadata["audience"]` — the
23
- # convention every dynamic-registration operation in the estate
24
- # stamps, and the reason each app carried an authorize shim.
25
27
  #
26
28
  # Nil when none of these apply: audience stays optional at /authorize.
27
29
  module AudienceResolver
@@ -38,10 +40,12 @@ module StandardId
38
40
  return coerce_hook_answer(answer) unless answer.nil?
39
41
  end
40
42
 
43
+ registered = client_default(client)
44
+ return registered if registered
41
45
  return requested.to_s if requested
42
46
  return from_resource(resource) if resource && resource_mapping.any?
43
47
 
44
- client_default(client)
48
+ nil
45
49
  end
46
50
 
47
51
  # @raise [StandardId::InvalidTargetError] when a mapping is configured
@@ -92,8 +92,19 @@ module StandardId
92
92
  params[:state]
93
93
  end
94
94
 
95
+ # The requested scope, normalised by `oauth.authorize_scope_resolver`
96
+ # when set (a host that collapses a scope hierarchy, or narrows a
97
+ # request to what the chosen audience consumes, does it here instead of
98
+ # in a shim in front of this endpoint). Resolved once, after the client
99
+ # and audience are known; nil from the hook leaves the request as is.
95
100
  def scope
96
- params[:scope]
101
+ return @scope if defined?(@scope)
102
+
103
+ @scope = resolve_scope(params[:scope])
104
+ end
105
+
106
+ def resolve_scope(requested)
107
+ StandardId::Oauth::AuthorizeScopeResolver.resolve(scope: requested, audience: audience, client: @client, request: request)
97
108
  end
98
109
 
99
110
  # Resolved once, after the client is known (see AudienceResolver): the
@@ -0,0 +1,36 @@
1
+ module StandardId
2
+ module Oauth
3
+ # Applies `config.oauth.authorize_scope_resolver` to an /authorize request's
4
+ # scope once the client and audience are known. Shared by the authorize
5
+ # flows (what the code is issued with) and the consent gate (what the user
6
+ # is shown, what the grant is checked against and recorded as), so the two
7
+ # cannot disagree.
8
+ #
9
+ # The consent resume re-runs the flow with the already-normalised scope, so
10
+ # a resolver must be idempotent: applying it to its own output must change
11
+ # nothing. Collapsing, filtering and appending a default all are; an
12
+ # append that does not check for presence is not (the Array/String output
13
+ # is de-duplicated, which covers the common case).
14
+ module AuthorizeScopeResolver
15
+ module_function
16
+
17
+ # @return [String, nil] the normalised scope (nil/blank in → nil/blank out
18
+ # unless the resolver answers)
19
+ def resolve(scope:, audience:, client:, request: nil)
20
+ custom = StandardId.config.oauth.authorize_scope_resolver
21
+ return scope unless custom.respond_to?(:call)
22
+
23
+ context = { scope: scope, audience: audience, client: client, request: request }
24
+ answer = custom.call(**StandardId::Utils::CallableParameterFilter.filter(custom, context))
25
+ case answer
26
+ when nil then scope
27
+ when Array then answer.map(&:to_s).reject(&:blank?).uniq.join(" ")
28
+ when String, Symbol then answer.to_s.split.uniq.join(" ")
29
+ else
30
+ raise StandardId::ConfigurationError,
31
+ "StandardId config: `oauth.authorize_scope_resolver` must return a String, Array, Symbol or nil, got #{answer.class}"
32
+ end
33
+ end
34
+ end
35
+ end
36
+ end
@@ -87,9 +87,40 @@ module StandardId
87
87
  client.refresh_tokens.where(revoked_at: nil).update_all(revoked_at: now)
88
88
  end
89
89
  end
90
+ publish_lifecycle_event(StandardId::Events::OAUTH_CLIENT_DEREGISTERED, client)
90
91
  client
91
92
  end
92
93
 
94
+ # OAUTH_CLIENT_REGISTERED / _UPDATED / _DEREGISTERED, after the change has
95
+ # committed — the outermost transaction, so a host that wraps the call in
96
+ # its own transaction gets no event for a change it then rolls back
97
+ # (and gets it immediately when no transaction is open). The owner is identified by type and id rather than published
98
+ # as a record: on update and deregister it is not loaded, and a host
99
+ # running strict_loading_by_default would refuse the lazy read. Never
100
+ # raises: the change is already committed, and a registration whose
101
+ # one-time secret and registration access token never reach the client
102
+ # because an audit subscriber failed is worse than a missing audit row.
103
+ def self.publish_lifecycle_event(event, client, client_metadata: nil)
104
+ payload = {
105
+ client_application: client,
106
+ client_id: client.client_id,
107
+ client_name: client.name,
108
+ client_type: client.client_type,
109
+ redirect_uris: client.redirect_uris_array,
110
+ scope: client.scopes,
111
+ owner_type: client.owner_type,
112
+ owner_id: client.owner_id
113
+ }
114
+ payload[:client_metadata] = client_metadata unless client_metadata.nil?
115
+ ActiveRecord.after_all_transactions_commit do
116
+ StandardId::Events.publish(event, payload)
117
+ rescue StandardError => e
118
+ StandardId.logger&.warn("[StandardId] failed to publish #{event}: #{e.class}: #{e.message}")
119
+ end
120
+ rescue StandardError => e
121
+ StandardId.logger&.warn("[StandardId] failed to publish #{event}: #{e.class}: #{e.message}")
122
+ end
123
+
93
124
  # Everything, the owner resolver included, runs inside one transaction:
94
125
  # a host whose resolver creates a per-registration owner row must not be
95
126
  # left with that row when the client itself is refused.
@@ -117,6 +148,7 @@ module StandardId
117
148
  end
118
149
  end
119
150
 
151
+ self.class.publish_lifecycle_event(StandardId::Events::OAUTH_CLIENT_REGISTERED, client, client_metadata: metadata)
120
152
  Result.new(client: client, client_secret: secret_plaintext, token_endpoint_auth_method: auth_method,
121
153
  registration_access_token: registration_access_token)
122
154
  rescue ActiveRecord::RecordInvalid => e
@@ -144,6 +176,7 @@ module StandardId
144
176
  attrs[:scopes] = scope unless pinned.key?("scopes") || metadata[:scope].blank?
145
177
 
146
178
  client.update!(attrs)
179
+ self.class.publish_lifecycle_event(StandardId::Events::OAUTH_CLIENT_UPDATED, client, client_metadata: metadata)
147
180
  Result.new(client: client, token_endpoint_auth_method: client.token_endpoint_auth_method)
148
181
  rescue ActiveRecord::RecordInvalid => e
149
182
  raise_for(e.record)
@@ -13,9 +13,16 @@ module StandardId
13
13
  # grant_type strings to flows explicitly, and this one is never in that
14
14
  # map. The caller supplies the account; the flow authenticates nothing.
15
15
  class DirectMintFlow < TokenGrantFlow
16
+ AUDIT_ERROR_MODES = %i[raise report].freeze
17
+
16
18
  def initialize(account:, audience:, grant_type:, scopes: nil, client_id: nil, claims: {}, expires_in: nil,
17
- refresh_token: false, request: nil, auth_method: nil, auth_provider: nil)
19
+ refresh_token: false, request: nil, auth_method: nil, auth_provider: nil, profile: nil, audit_errors: :raise)
18
20
  super({ audience: audience }, request || NullRequest.new)
21
+ @profile = profile
22
+ @audit_errors = audit_errors.to_s.to_sym
23
+ unless AUDIT_ERROR_MODES.include?(@audit_errors)
24
+ raise ArgumentError, "audit_errors must be one of #{AUDIT_ERROR_MODES.join(', ')} (got #{audit_errors.inspect})"
25
+ end
19
26
  @account = account
20
27
  @audience = audience
21
28
  @grant_type = grant_type.to_s
@@ -34,6 +41,10 @@ module StandardId
34
41
  @issued_jti
35
42
  end
36
43
 
44
+ # The error an OAUTH_TOKEN_ISSUED subscriber raised under
45
+ # `audit_errors: :report`, else nil.
46
+ attr_reader :audit_error
47
+
37
48
  def authenticate!
38
49
  raise StandardId::InvalidGrantError, "Account is required" if @account.nil?
39
50
  raise StandardId::InvalidGrantError, "Account must be persisted" unless @account.respond_to?(:persisted?) && @account.persisted?
@@ -51,6 +62,65 @@ module StandardId
51
62
 
52
63
  private
53
64
 
65
+ # An explicit profile replaces the strict lookup: the host already knows
66
+ # which of the account's profiles the token is for (several of one type
67
+ # is legitimate there, and AmbiguousProfileError would refuse it). It
68
+ # must still be the account's, active, and of the bound type when the
69
+ # audience has a binding — the guarantees the lookup gives. Resolvers
70
+ # receive it as `profile:` either way. config.oauth.audience_profile_resolver
71
+ # is NOT consulted: the host chose the profile, so any extra gate that
72
+ # resolver applies is the caller's to apply before minting.
73
+ def enforce_audience_profile_binding!
74
+ return super if @profile.nil?
75
+
76
+ validate_explicit_profile!
77
+ @resolved_profile = @profile
78
+ end
79
+
80
+ def validate_explicit_profile!
81
+ unless profile_belongs_to_account?(@profile)
82
+ raise StandardId::InvalidGrantError, "profile does not belong to the account"
83
+ end
84
+ if @profile.respond_to?(:active?) && !@profile.active?
85
+ raise StandardId::InvalidGrantError, "profile is not active"
86
+ end
87
+
88
+ bound = profile_bound_audience
89
+ return if bound.nil?
90
+
91
+ types = StandardId::Oauth::AudienceProfileResolver.profile_types_for(bound)
92
+ return if types.include?(StandardId::Oauth::AudienceProfileResolver.profile_type_for(@profile))
93
+
94
+ raise StandardId::InvalidGrantError,
95
+ "profile is not of a type bound to audience #{bound.inspect} (expected #{types.join(', ')})"
96
+ end
97
+
98
+ def profile_belongs_to_account?(profile)
99
+ return profile.account_id.to_s == @account.id.to_s if profile.respond_to?(:account_id)
100
+ return @account.profiles.include?(profile) if @account.respond_to?(:profiles)
101
+
102
+ false
103
+ end
104
+
105
+ # Under `audit_errors: :report` a raising OAUTH_TOKEN_ISSUED subscriber
106
+ # no longer costs the caller a token that is already signed (and, with a
107
+ # refresh token, committed): the error is logged and handed back on the
108
+ # result. OAUTH_TOKEN_ISSUING is never softened — its subscribers are the
109
+ # account-status and locking guards.
110
+ def emit_token_issued(expires_in)
111
+ return super if @audit_errors == :raise
112
+
113
+ begin
114
+ super
115
+ rescue StandardError => e
116
+ @audit_error = e
117
+ StandardId.config.logger&.error(
118
+ "[StandardId] OAUTH_TOKEN_ISSUED subscriber raised for #{grant_type} (token still issued): " \
119
+ "#{e.class}: #{e.message} at #{e.backtrace&.first}"
120
+ )
121
+ end
122
+ end
123
+
54
124
  # No new authentication happened here: the host vouches for the account.
55
125
  def login_method_policy_context
56
126
  nil
@@ -20,6 +20,19 @@ module StandardId
20
20
  # origin. Any member may be a callable taking one Hash
21
21
  # `{ origin:, audience:, issuer:, request: }`; a nil value removes the member.
22
22
  #
23
+ # Two members configure the resource rather than the document:
24
+ #
25
+ # origin: ->(request:) { ENV.fetch("PUBLIC_URL") { request.base_url } }
26
+ # the origin `resource` and the metadata URL are built on, for a
27
+ # host behind a proxy that rewrites `request.base_url`
28
+ # (default: the request's origin)
29
+ # audience: :resource
30
+ # tokens for this resource carry its absolute URL (`resource`)
31
+ # as `aud`, RFC 8707 style, instead of the key; or a String
32
+ # naming that `aud`. The key stays the resource's name: what
33
+ # `protected_resource` and `audience_scopes` refer to, and the
34
+ # challenge realm.
35
+ #
23
36
  # The document for a resource is served at
24
37
  # `<origin>/.well-known/oauth-protected-resource<path>` (RFC 9728 §3.1,
25
38
  # the path-inserted form that is the exact URL the challenge names) by
@@ -43,7 +56,7 @@ module StandardId
43
56
  WELL_KNOWN_PATH = "/.well-known/oauth-protected-resource".freeze
44
57
  # `resource` is computed, never configured: RFC 9728 §3.3 requires it to
45
58
  # match the URL the client used, which is the request origin plus `path`.
46
- RESERVED_KEYS = %w[path root resource].freeze
59
+ RESERVED_KEYS = %w[path root resource origin audience].freeze
47
60
 
48
61
  module_function
49
62
 
@@ -81,6 +94,35 @@ module StandardId
81
94
  nil
82
95
  end
83
96
 
97
+ # The origin `audience`'s documents and challenges are built on: the
98
+ # entry's `origin` callable when it returns one, else the request's.
99
+ def origin_for(audience, request:)
100
+ configured_origin = configured.dig(audience.to_s, "origin")
101
+ resolved = if configured_origin.respond_to?(:call)
102
+ args = StandardId::Utils::CallableParameterFilter.filter(configured_origin, { request: request })
103
+ configured_origin.call(**args)
104
+ else
105
+ configured_origin
106
+ end
107
+ (resolved.presence || request&.base_url).to_s.chomp("/")
108
+ end
109
+
110
+ # The `aud` tokens for `audience` carry: the key itself (also for an
111
+ # audience with no entry, as before entries could say otherwise), the
112
+ # resource's absolute URL (`audience: :resource`), or the String the
113
+ # entry names.
114
+ # @raise [StandardId::ConfigurationError] for an `audience` member of any
115
+ # other shape
116
+ def token_audience(audience:, origin:)
117
+ value = configured.dig(audience.to_s, "audience")
118
+ return audience.to_s if value.nil?
119
+ return resource_url(audience: audience, origin: origin) if value.to_s == "resource"
120
+ return value if value.is_a?(String) && value.present?
121
+
122
+ raise StandardId::ConfigurationError,
123
+ "oauth.protected_resources[#{audience.inspect}][:audience] must be :resource or a String (got #{value.inspect})"
124
+ end
125
+
84
126
  # Absolute URL of the resource (RFC 9728 `resource`).
85
127
  def resource_url(audience:, origin:)
86
128
  path = path_for!(audience)
@@ -362,26 +362,30 @@ module StandardId
362
362
  # the requested audiences — preserves existing behavior for endpoints
363
363
  # that haven't opted into the audience→profile binding feature.
364
364
  def enforce_audience_profile_binding!
365
- requested = Array(audience).reject(&:blank?)
366
- return if requested.empty?
365
+ bound = profile_bound_audience
366
+ return if bound.nil?
367
367
 
368
- bound = requested.select { |a| StandardId::Oauth::AudienceProfileResolver.configured_for?(a) }
369
- return if bound.empty?
368
+ @resolved_profile = StandardId::Oauth::AudienceProfileResolver.resolve!(
369
+ account: token_account,
370
+ audience: bound
371
+ )
372
+ end
370
373
 
371
- # Multiple bound audiences in one token would imply the holder is
372
- # cleared for multiple distinct profile-type contexts simultaneously
373
- # — there's no coherent answer for which profile to bind. Reject
374
- # rather than silently picking one. Host apps that need cross-
375
- # audience tokens should issue separate tokens per audience.
374
+ # The one requested audience with a profile-type binding, or nil.
375
+ #
376
+ # Multiple bound audiences in one token would imply the holder is
377
+ # cleared for multiple distinct profile-type contexts simultaneously
378
+ # — there's no coherent answer for which profile to bind. Reject
379
+ # rather than silently picking one. Host apps that need cross-
380
+ # audience tokens should issue separate tokens per audience.
381
+ def profile_bound_audience
382
+ bound = Array(audience).reject(&:blank?).select { |a| StandardId::Oauth::AudienceProfileResolver.configured_for?(a) }
376
383
  if bound.length > 1
377
384
  raise StandardId::InvalidGrantError,
378
385
  "Cannot bind a single token to multiple profile-bound audiences: #{bound.join(', ')}"
379
386
  end
380
387
 
381
- @resolved_profile = StandardId::Oauth::AudienceProfileResolver.resolve!(
382
- account: token_account,
383
- audience: bound.first
384
- )
388
+ bound.first
385
389
  end
386
390
 
387
391
  def claims_from_custom_claims
@@ -29,7 +29,13 @@ module StandardId
29
29
  # Raises the same errors the token endpoint would (InvalidGrantError,
30
30
  # InvalidRequestError, NoBoundProfileError, AccountDeactivatedError, …).
31
31
  module TokenMinter
32
- Result = Data.define(:access_token, :refresh_token, :token_type, :expires_in, :scope, :jti)
32
+ Result = Data.define(:access_token, :refresh_token, :token_type, :expires_in, :scope, :jti, :audit_error) do
33
+ # audit_error arrived in 0.51; optional so a host spec that builds a
34
+ # Result to stub +mint+ keeps working.
35
+ def initialize(audit_error: nil, **members)
36
+ super
37
+ end
38
+ end
33
39
 
34
40
  module_function
35
41
 
@@ -52,13 +58,26 @@ module StandardId
52
58
  # account, recorded on the refresh token for login_method_policy; nil is
53
59
  # judged as :unspecified on refresh, which a restrictive policy refuses
54
60
  # @param auth_provider [String, nil]
61
+ # @param profile [ActiveRecord::Base, nil] the account's profile the token
62
+ # is for, instead of the strict audience → profile lookup (which refuses
63
+ # an account holding several active profiles of the bound type). Must
64
+ # belong to the account, be active, and be of the bound type when the
65
+ # audience has a binding; claim resolvers receive it as `profile:`.
66
+ # oauth.audience_profile_resolver is not consulted for it — apply any
67
+ # gate that resolver enforces before calling.
68
+ # @param audit_errors [Symbol] :raise (default) re-raises an
69
+ # OAUTH_TOKEN_ISSUED subscriber's error, as every grant does; :report
70
+ # logs it and returns it as +Result#audit_error+ with the token, for a
71
+ # caller whose contract is that an audit failure never costs it the
72
+ # credential. Report it to your error tracker: the gem only logs it.
73
+ # OAUTH_TOKEN_ISSUING (the status / locking guards) always raises.
55
74
  # @return [Result]
56
75
  def mint(account:, audience:, grant_type:, scopes: nil, client_id: nil, claims: {}, expires_in: nil,
57
- refresh_token: false, request: nil, auth_method: nil, auth_provider: nil)
76
+ refresh_token: false, request: nil, auth_method: nil, auth_provider: nil, profile: nil, audit_errors: :raise)
58
77
  flow = StandardId::Oauth::DirectMintFlow.new(
59
78
  account: account, audience: audience, grant_type: grant_type, scopes: scopes, client_id: client_id,
60
79
  claims: claims, expires_in: expires_in, refresh_token: refresh_token, request: request,
61
- auth_method: auth_method, auth_provider: auth_provider
80
+ auth_method: auth_method, auth_provider: auth_provider, profile: profile, audit_errors: audit_errors
62
81
  )
63
82
  response = flow.execute
64
83
  Result.new(
@@ -67,7 +86,8 @@ module StandardId
67
86
  token_type: response[:token_type],
68
87
  expires_in: response[:expires_in],
69
88
  scope: response[:scope],
70
- jti: flow.issued_jti
89
+ jti: flow.issued_jti,
90
+ audit_error: flow.audit_error
71
91
  )
72
92
  end
73
93
  end
@@ -1,3 +1,3 @@
1
1
  module StandardId
2
- VERSION = "0.49.1"
2
+ VERSION = "0.51.0"
3
3
  end
data/lib/standard_id.rb CHANGED
@@ -39,6 +39,7 @@ require "standard_id/account_cleanup"
39
39
  require "standard_id/oauth/audience_profile_resolver"
40
40
  require "standard_id/oauth/audience_scope_resolver"
41
41
  require "standard_id/oauth/audience_resolver"
42
+ require "standard_id/oauth/authorize_scope_resolver"
42
43
  require "standard_id/oauth/base_request_flow"
43
44
  require "standard_id/oauth/token_lifetime_resolver"
44
45
  require "standard_id/oauth/oauth_session_persistence"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: standard_id
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.49.1
4
+ version: 0.51.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jaryl Sim
@@ -287,6 +287,7 @@ files:
287
287
  - lib/standard_id/oauth/authorization_code_authorization_flow.rb
288
288
  - lib/standard_id/oauth/authorization_code_flow.rb
289
289
  - lib/standard_id/oauth/authorization_flow.rb
290
+ - lib/standard_id/oauth/authorize_scope_resolver.rb
290
291
  - lib/standard_id/oauth/base_request_flow.rb
291
292
  - lib/standard_id/oauth/client_credentials_flow.rb
292
293
  - lib/standard_id/oauth/client_registration.rb