standard_id 0.19.0 → 0.20.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:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: faa6cfb238d38d71af682021ba3b6407cf06657c1b12e7ff29474dc97c753277
|
|
4
|
+
data.tar.gz: 365d86af12a4fef90b669f52114ae449d07facd5389089b438ae67174f75059e
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 4ffc829b81cb6315954c5f2b3632889a7afd3c7457c4971297698f04ec9f43682b7eb715f711e2b28ea3c40d33f51519fc7339cd8d0f4996a7adad413677e880
|
|
7
|
+
data.tar.gz: 6247cc6fd0deeee072d90711ae1443d01d42bb585a58330358d9596fae8e665e14af4c274a478f7bc43b4e47adc2aca05d4f2c6ab7a7dc1c4caa846f751ab917
|
data/lib/standard_id/errors.rb
CHANGED
|
@@ -58,6 +58,46 @@ module StandardId
|
|
|
58
58
|
def oauth_error_code = :invalid_grant
|
|
59
59
|
end
|
|
60
60
|
|
|
61
|
+
# Raised at mint time when an account has no profile of the type bound
|
|
62
|
+
# to the requested audience via `c.oauth.audience_profile_types`.
|
|
63
|
+
#
|
|
64
|
+
# Subclass of `InvalidGrantError` so existing OAuth error handling
|
|
65
|
+
# (which renders OAuthError as RFC 6749 error responses) treats this
|
|
66
|
+
# as `invalid_grant`. The audience and expected profile types are
|
|
67
|
+
# surfaced via readers for audit logging only — operators MUST NOT
|
|
68
|
+
# interpolate them into client-facing error responses, since the
|
|
69
|
+
# configured profile-type taxonomy is internal-only information.
|
|
70
|
+
class NoBoundProfileError < InvalidGrantError
|
|
71
|
+
attr_reader :audience, :expected_profile_types
|
|
72
|
+
|
|
73
|
+
def initialize(audience:, expected_profile_types:)
|
|
74
|
+
@audience = audience
|
|
75
|
+
@expected_profile_types = Array(expected_profile_types)
|
|
76
|
+
super("No profile of type [#{@expected_profile_types.join(', ')}] " \
|
|
77
|
+
"for audience '#{audience}'")
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Raised at mint time when an account has more than one active profile
|
|
82
|
+
# of the type bound to the requested audience. Selecting one silently
|
|
83
|
+
# would be non-deterministic and unauditable, so we fail closed and let
|
|
84
|
+
# the host app resolve the ambiguity (deactivate duplicates, or pass an
|
|
85
|
+
# explicit `profile_id` once that grant parameter is supported).
|
|
86
|
+
#
|
|
87
|
+
# Same audit-only/PII rules as `NoBoundProfileError`: do NOT surface
|
|
88
|
+
# profile_ids in client-facing responses.
|
|
89
|
+
class AmbiguousProfileError < InvalidGrantError
|
|
90
|
+
attr_reader :audience, :expected_profile_types, :profile_ids
|
|
91
|
+
|
|
92
|
+
def initialize(audience:, expected_profile_types:, profile_ids:)
|
|
93
|
+
@audience = audience
|
|
94
|
+
@expected_profile_types = Array(expected_profile_types)
|
|
95
|
+
@profile_ids = Array(profile_ids)
|
|
96
|
+
super("Multiple active profiles of type [#{@expected_profile_types.join(', ')}] " \
|
|
97
|
+
"for audience '#{audience}' (#{@profile_ids.length} profile(s))")
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
61
101
|
class InvalidScopeError < OAuthError
|
|
62
102
|
def oauth_error_code = :invalid_scope
|
|
63
103
|
end
|
|
@@ -59,6 +59,48 @@ module StandardId
|
|
|
59
59
|
profile_types_for(audience).any?
|
|
60
60
|
end
|
|
61
61
|
|
|
62
|
+
# Strict variant of `.call` for mint-time enforcement: returns the
|
|
63
|
+
# uniquely matching active profile, or raises a typed error so the
|
|
64
|
+
# token grant flow can fail closed.
|
|
65
|
+
#
|
|
66
|
+
# Resolution rules (deterministic, no silent fallbacks):
|
|
67
|
+
# - 0 matching active profiles → raises `NoBoundProfileError`
|
|
68
|
+
# (NB: an inactive-only match is still 0 active matches and
|
|
69
|
+
# fails closed — inactive profiles cannot mint tokens)
|
|
70
|
+
# - exactly 1 matching active profile → returns it
|
|
71
|
+
# - >1 matching active profile → raises `AmbiguousProfileError`
|
|
72
|
+
#
|
|
73
|
+
# The legacy `.call` API preserves its "first active else first match"
|
|
74
|
+
# behavior, since it is wired into the decode-time concern and host
|
|
75
|
+
# apps may have grown to depend on its tolerance. Migrating that path
|
|
76
|
+
# to strict mode is a separate change.
|
|
77
|
+
#
|
|
78
|
+
# @raise [StandardId::NoBoundProfileError]
|
|
79
|
+
# @raise [StandardId::AmbiguousProfileError]
|
|
80
|
+
def resolve!(account:, audience:)
|
|
81
|
+
types = profile_types_for(audience)
|
|
82
|
+
raise ArgumentError, "audience #{audience.inspect} has no profile binding" if types.empty?
|
|
83
|
+
|
|
84
|
+
# Custom resolver path: trust the host app's result. It's expected
|
|
85
|
+
# to enforce its own determinism — if it returns nil we still fail
|
|
86
|
+
# closed; if it returns a profile we use it as-is.
|
|
87
|
+
resolver = StandardId.config.oauth.audience_profile_resolver
|
|
88
|
+
if resolver.respond_to?(:call)
|
|
89
|
+
filtered = StandardId::Utils::CallableParameterFilter.filter(
|
|
90
|
+
resolver,
|
|
91
|
+
{ account: account, audience: audience, profile_types: types }
|
|
92
|
+
)
|
|
93
|
+
resolved = resolver.call(**filtered)
|
|
94
|
+
return resolved if resolved
|
|
95
|
+
raise StandardId::NoBoundProfileError.new(
|
|
96
|
+
audience: audience,
|
|
97
|
+
expected_profile_types: types
|
|
98
|
+
)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
strict_default_lookup(account, audience, types)
|
|
102
|
+
end
|
|
103
|
+
|
|
62
104
|
private
|
|
63
105
|
|
|
64
106
|
def default_lookup(account, types)
|
|
@@ -78,6 +120,44 @@ module StandardId
|
|
|
78
120
|
active || matches.first
|
|
79
121
|
end
|
|
80
122
|
|
|
123
|
+
# Strict default lookup — counterpart to `default_lookup` for mint.
|
|
124
|
+
#
|
|
125
|
+
# "Active" semantics:
|
|
126
|
+
# - if a profile responds to `active?`, only `active? == true`
|
|
127
|
+
# counts toward the match set
|
|
128
|
+
# - if it does not, treat it as active (back-compat: not all host
|
|
129
|
+
# apps have an activity predicate)
|
|
130
|
+
def strict_default_lookup(account, audience, types)
|
|
131
|
+
unless account.respond_to?(:profiles)
|
|
132
|
+
raise StandardId::NoBoundProfileError.new(
|
|
133
|
+
audience: audience,
|
|
134
|
+
expected_profile_types: types
|
|
135
|
+
)
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
candidates = account.profiles
|
|
139
|
+
candidates = candidates.to_a unless candidates.is_a?(Array)
|
|
140
|
+
|
|
141
|
+
matches = candidates.select { |p| types.include?(profile_type_for(p)) }
|
|
142
|
+
active_matches = matches.select { |p| !p.respond_to?(:active?) || p.active? }
|
|
143
|
+
|
|
144
|
+
case active_matches.length
|
|
145
|
+
when 0
|
|
146
|
+
raise StandardId::NoBoundProfileError.new(
|
|
147
|
+
audience: audience,
|
|
148
|
+
expected_profile_types: types
|
|
149
|
+
)
|
|
150
|
+
when 1
|
|
151
|
+
active_matches.first
|
|
152
|
+
else
|
|
153
|
+
raise StandardId::AmbiguousProfileError.new(
|
|
154
|
+
audience: audience,
|
|
155
|
+
expected_profile_types: types,
|
|
156
|
+
profile_ids: active_matches.map { |p| p.respond_to?(:id) ? p.id : nil }.compact
|
|
157
|
+
)
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
|
|
81
161
|
def profile_type_for(profile)
|
|
82
162
|
if profile.respond_to?(:profileable_type)
|
|
83
163
|
profile.profileable_type.to_s
|
|
@@ -36,9 +36,13 @@ module StandardId
|
|
|
36
36
|
|
|
37
37
|
def generate_token_response
|
|
38
38
|
validate_audience!
|
|
39
|
+
enforce_audience_profile_binding!
|
|
39
40
|
emit_token_issuing
|
|
40
41
|
expires_in = token_expiry
|
|
41
42
|
payload = build_jwt_payload(expires_in)
|
|
43
|
+
# Pre-generate jti so we can include it in the OAUTH_TOKEN_ISSUED
|
|
44
|
+
# event payload without re-decoding the encoded JWT.
|
|
45
|
+
@issued_jti = payload[:jti] ||= SecureRandom.uuid
|
|
42
46
|
access_token = StandardId::JwtService.encode(payload, expires_in: expires_in)
|
|
43
47
|
|
|
44
48
|
response = {
|
|
@@ -212,6 +216,51 @@ module StandardId
|
|
|
212
216
|
end
|
|
213
217
|
end
|
|
214
218
|
|
|
219
|
+
# Fail closed when the requested audience has a profile-type binding
|
|
220
|
+
# configured (`c.oauth.audience_profile_types`) but the account holds
|
|
221
|
+
# no matching active profile.
|
|
222
|
+
#
|
|
223
|
+
# Before this check existed, mints silently succeeded with `gid` (or
|
|
224
|
+
# any other profile-derived claim) resolving to `nil`. Downstream
|
|
225
|
+
# services then received a syntactically-valid bearer that pointed at
|
|
226
|
+
# nothing — a classic "fail open" foot-gun. Now: the mint raises
|
|
227
|
+
# `InvalidGrantError` (via `NoBoundProfileError` / `AmbiguousProfileError`),
|
|
228
|
+
# which the OAuth error handler renders as RFC 6749 `invalid_grant`.
|
|
229
|
+
#
|
|
230
|
+
# The resolved profile is stashed in `@resolved_profile` for two
|
|
231
|
+
# downstream uses:
|
|
232
|
+
# 1. Inclusion in the OAUTH_TOKEN_ISSUED audit event payload
|
|
233
|
+
# (so operators can correlate mints to profiles).
|
|
234
|
+
# 2. Use by host-app claim resolvers via `claim_resolvers_context`,
|
|
235
|
+
# eliminating a redundant DB lookup (and the race window in
|
|
236
|
+
# which the profile might be deactivated between lookup and use).
|
|
237
|
+
#
|
|
238
|
+
# No-op when audience is unset or no binding is configured for any of
|
|
239
|
+
# the requested audiences — preserves existing behavior for endpoints
|
|
240
|
+
# that haven't opted into the audience→profile binding feature.
|
|
241
|
+
def enforce_audience_profile_binding!
|
|
242
|
+
requested = Array(audience).reject(&:blank?)
|
|
243
|
+
return if requested.empty?
|
|
244
|
+
|
|
245
|
+
bound = requested.select { |a| StandardId::Oauth::AudienceProfileResolver.configured_for?(a) }
|
|
246
|
+
return if bound.empty?
|
|
247
|
+
|
|
248
|
+
# Multiple bound audiences in one token would imply the holder is
|
|
249
|
+
# cleared for multiple distinct profile-type contexts simultaneously
|
|
250
|
+
# — there's no coherent answer for which profile to bind. Reject
|
|
251
|
+
# rather than silently picking one. Host apps that need cross-
|
|
252
|
+
# audience tokens should issue separate tokens per audience.
|
|
253
|
+
if bound.length > 1
|
|
254
|
+
raise StandardId::InvalidGrantError,
|
|
255
|
+
"Cannot bind a single token to multiple profile-bound audiences: #{bound.join(', ')}"
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
@resolved_profile = StandardId::Oauth::AudienceProfileResolver.resolve!(
|
|
259
|
+
account: token_account,
|
|
260
|
+
audience: bound.first
|
|
261
|
+
)
|
|
262
|
+
end
|
|
263
|
+
|
|
215
264
|
def claims_from_custom_claims
|
|
216
265
|
callable = StandardId.config.oauth.custom_claims
|
|
217
266
|
return {} unless callable.respond_to?(:call)
|
|
@@ -253,12 +302,13 @@ module StandardId
|
|
|
253
302
|
end
|
|
254
303
|
|
|
255
304
|
def token_account
|
|
256
|
-
return
|
|
305
|
+
return @token_account if defined?(@token_account)
|
|
306
|
+
return (@token_account = nil) if subject_id.blank?
|
|
257
307
|
|
|
258
308
|
account_class = StandardId.account_class
|
|
259
|
-
return nil unless account_class.respond_to?(:find_by)
|
|
309
|
+
return (@token_account = nil) unless account_class.respond_to?(:find_by)
|
|
260
310
|
|
|
261
|
-
account_class.find_by(id: subject_id)
|
|
311
|
+
@token_account = account_class.find_by(id: subject_id)
|
|
262
312
|
end
|
|
263
313
|
|
|
264
314
|
def token_client
|
|
@@ -270,7 +320,13 @@ module StandardId
|
|
|
270
320
|
client: token_client,
|
|
271
321
|
account: token_account,
|
|
272
322
|
request: request,
|
|
273
|
-
audience: audience
|
|
323
|
+
audience: audience,
|
|
324
|
+
# Pre-resolved profile (when the requested audience has a binding).
|
|
325
|
+
# Host-app claim resolvers that previously looked up the profile by
|
|
326
|
+
# account+type can now accept this directly via keyword filtering,
|
|
327
|
+
# avoiding both an extra query and the race in which the profile
|
|
328
|
+
# is deactivated between resolver lookup and use.
|
|
329
|
+
profile: @resolved_profile
|
|
274
330
|
}
|
|
275
331
|
end
|
|
276
332
|
|
|
@@ -295,7 +351,16 @@ module StandardId
|
|
|
295
351
|
grant_type: grant_type,
|
|
296
352
|
client_id: client_id,
|
|
297
353
|
account: token_account,
|
|
298
|
-
expires_in: expires_in
|
|
354
|
+
expires_in: expires_in,
|
|
355
|
+
# Audit enrichment — without these, downstream subscribers
|
|
356
|
+
# (SIEM, audit log, anomaly detection) cannot meaningfully
|
|
357
|
+
# correlate a successful mint to the entity it authorizes,
|
|
358
|
+
# the resource server it targets, the specific token (jti)
|
|
359
|
+
# for revocation, or the scopes the client requested.
|
|
360
|
+
profile_id: @resolved_profile&.id,
|
|
361
|
+
audience: audience,
|
|
362
|
+
jti: @issued_jti,
|
|
363
|
+
requested_scopes: current_scopes
|
|
299
364
|
)
|
|
300
365
|
end
|
|
301
366
|
end
|
data/lib/standard_id/version.rb
CHANGED