standard_id 0.50.0 → 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: f6f93f66f3e1a4d95ac6effab590e560e39f2993dd072010676a3121efec6098
4
- data.tar.gz: 6934ab15f5b9c394db6d15eb37ca91d41965421589553135c2a05fea1ebbf551
3
+ metadata.gz: 70dbbe49421726d198ba1ee5502ec80abcce347b4ed006073ed81c99de743321
4
+ data.tar.gz: dcb5337f9cfc348a256f8317dbb5a946f3b8d5e10f64829287cceb1108d38ac4
5
5
  SHA512:
6
- metadata.gz: 73eab42991c65471c2f77d7be554b4ec1d002eacb73e62d8d77bc79003b5121f4b64bd9750ce8991113ff451556313f54165cfd75b642501251dcb947c0f3900
7
- data.tar.gz: 6a0b2b9e4628842eb1ab6d56acf348c829abc083a00e29671c3efadca5dc57e56aa63d958577c884a7ca224f6e768aebedc66755d5c1128912fe4ef1d9eed576
6
+ metadata.gz: c469c67c58017cd97529b1e1eb47c5368c1c0e878f1123806796267f31c7791e4233bdb57597958fb460fb838ff8ae2f9ddf50864671d981f04796d5372ddfa5
7
+ data.tar.gz: bb7d5b2686415c1aa2d0198da73cb2df31372fc7ff36454ac879959bc0628552aafba5da7cd2aedf43bbd47801a673c6e172762abba4c8d3eabdbc6abb557534
data/CHANGELOG.md CHANGED
@@ -7,6 +7,23 @@ 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
+
10
27
  ## [0.50.0] - 2026-10-20
11
28
 
12
29
  ### Changed
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) |
@@ -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
  )
@@ -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
 
@@ -461,6 +461,10 @@ StandardId::ConfigSchema.define do
461
461
  # emitted as given (callables receive `{ origin:, audience:, issuer:,
462
462
  # request: }`; nil removes a member). `root: true` picks the resource the
463
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`).
464
468
  #
465
469
  # c.oauth.protected_resources = { "mcp" => { path: "/mcp", scopes_supported: %w[mcp mcp:read] } }
466
470
  #
@@ -730,10 +734,30 @@ StandardId::ConfigSchema.define do
730
734
  # if those refuse the sign-in on an account that already existed, whatever
731
735
  # the hook wrote stays (make it idempotent); and it covers the API
732
736
  # callback only — the WebEngine's social callback does not run it.
733
- # 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
734
740
  # host subclass of the callback controller that reordered these steps by
735
741
  # copying its body.
736
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
737
761
  field :allowed_redirect_url_prefixes, type: :array, default: []
738
762
  field :available_scopes, type: :array, default: -> { [] }
739
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
@@ -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.50.0"
2
+ VERSION = "0.51.0"
3
3
  end
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.50.0
4
+ version: 0.51.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jaryl Sim