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 +4 -4
- data/CHANGELOG.md +17 -0
- data/README.md +22 -0
- data/app/controllers/concerns/standard_id/audience_verification.rb +34 -7
- data/app/controllers/concerns/standard_id/resource_server.rb +20 -3
- data/app/controllers/standard_id/api/base_controller.rb +8 -0
- data/app/controllers/standard_id/api/oauth/callback/providers_controller.rb +51 -19
- data/app/controllers/standard_id/api/well_known/oauth_protected_resource_controller.rb +3 -1
- data/lib/standard_id/config/schema.rb +25 -1
- data/lib/standard_id/errors.rb +21 -0
- data/lib/standard_id/events/definitions.rb +10 -1
- data/lib/standard_id/events/subscribers/logging_subscriber.rb +3 -0
- data/lib/standard_id/oauth/audience_profile_resolver.rb +12 -10
- data/lib/standard_id/oauth/client_registration.rb +33 -0
- data/lib/standard_id/oauth/direct_mint_flow.rb +71 -1
- data/lib/standard_id/oauth/protected_resource_document.rb +43 -1
- data/lib/standard_id/oauth/token_grant_flow.rb +17 -13
- data/lib/standard_id/token_minter.rb +24 -4
- data/lib/standard_id/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 70dbbe49421726d198ba1ee5502ec80abcce347b4ed006073ed81c99de743321
|
|
4
|
+
data.tar.gz: dcb5337f9cfc348a256f8317dbb5a946f3b8d5e10f64829287cceb1108d38ac4
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
#
|
|
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(
|
|
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 &
|
|
106
|
+
matched = (token_audiences & required).first
|
|
102
107
|
|
|
103
108
|
if matched.nil?
|
|
104
109
|
raise StandardId::InvalidAudienceError.new(
|
|
105
|
-
required:
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
|
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
|
data/lib/standard_id/errors.rb
CHANGED
|
@@ -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
|
-
|
|
366
|
-
return if
|
|
365
|
+
bound = profile_bound_audience
|
|
366
|
+
return if bound.nil?
|
|
367
367
|
|
|
368
|
-
|
|
369
|
-
|
|
368
|
+
@resolved_profile = StandardId::Oauth::AudienceProfileResolver.resolve!(
|
|
369
|
+
account: token_account,
|
|
370
|
+
audience: bound
|
|
371
|
+
)
|
|
372
|
+
end
|
|
370
373
|
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
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
|
-
|
|
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
|
data/lib/standard_id/version.rb
CHANGED