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: 3d3af31455bcc5b445cb7398d0d4bc2d4fac8ee7e246bcdc437796419af80284
4
- data.tar.gz: 167ef5777acbc1de9bf7ecbb9d9e30f95597094566b6268f48c56d1d462c8d37
3
+ metadata.gz: faa6cfb238d38d71af682021ba3b6407cf06657c1b12e7ff29474dc97c753277
4
+ data.tar.gz: 365d86af12a4fef90b669f52114ae449d07facd5389089b438ae67174f75059e
5
5
  SHA512:
6
- metadata.gz: 68bf2f44b3791c46a08500da1c2c91e0878c4b7b009a810fc761d450aeffdc5c007bf2ecef3e2b1a1193ad09313aac528489925215ec7cb23bdbbdef8df4d3c3
7
- data.tar.gz: d5116af139011d0cc8d5955489f751bf3dc8592e5e5474b55ce62178add8d828041e952f272a79a52adf7e20238dac02cadd22a8faa28b49277d789320b5897f
6
+ metadata.gz: 4ffc829b81cb6315954c5f2b3632889a7afd3c7457c4971297698f04ec9f43682b7eb715f711e2b28ea3c40d33f51519fc7339cd8d0f4996a7adad413677e880
7
+ data.tar.gz: 6247cc6fd0deeee072d90711ae1443d01d42bb585a58330358d9596fae8e665e14af4c274a478f7bc43b4e47adc2aca05d4f2c6ab7a7dc1c4caa846f751ab917
@@ -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 nil if subject_id.blank?
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
@@ -1,3 +1,3 @@
1
1
  module StandardId
2
- VERSION = "0.19.0"
2
+ VERSION = "0.20.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.19.0
4
+ version: 0.20.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jaryl Sim