standard_id 0.19.0 → 0.20.1

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: 664c61bf0dc0dbcf758fe4d114fad3776d0327777b64dde60f6d9d9f11db14cc
4
+ data.tar.gz: c87f620480f13c64051a0d915b37e2fd295f224f0a41fe0c4820bb8131321c70
5
5
  SHA512:
6
- metadata.gz: 68bf2f44b3791c46a08500da1c2c91e0878c4b7b009a810fc761d450aeffdc5c007bf2ecef3e2b1a1193ad09313aac528489925215ec7cb23bdbbdef8df4d3c3
7
- data.tar.gz: d5116af139011d0cc8d5955489f751bf3dc8592e5e5474b55ce62178add8d828041e952f272a79a52adf7e20238dac02cadd22a8faa28b49277d789320b5897f
6
+ metadata.gz: 4531519bbd926a39fda0180809a305f1e7de319cb278d1b75b178616548b586d9b767db3eb9e58e661045bc513efbc6785b5a63fa858b91fd74a832170f96c50
7
+ data.tar.gz: b7625be363e4f848fdd7c61f51c287ed8153e2671b64e52e0acef48de4e0ff95668529e5099f7408d4713321406728c8a6d1225418bc4ba03444bd0c12916430
@@ -100,6 +100,12 @@ module StandardId
100
100
  # - :profile_type [String, nil] first configured profile type for the scope (back-compat)
101
101
  # - :profile_types [Array<String>, nil] all configured profile types for the scope
102
102
  # - :after_sign_in_path [String, nil] default redirect path for the scope
103
+ # - :redirect_uri [String, nil] caller-supplied destination (from the form
104
+ # param for password/signup, from the OAuth state cookie for social).
105
+ # Hooks that always return a default path should return nil when this is
106
+ # present so the originator's URL wins — otherwise upstream OAuth/SSO
107
+ # flows that send users to /login?redirect_uri=... will land on the
108
+ # hook's default page instead of completing the handshake.
103
109
  # @return [String, nil] redirect path override, or nil for default
104
110
  # @raise [StandardId::AuthenticationDenied] to reject the sign-in
105
111
  def invoke_after_sign_in(account, context)
@@ -118,6 +124,12 @@ module StandardId
118
124
  return result if result.present?
119
125
  end
120
126
 
127
+ # When the caller supplied a :redirect_uri and the hook returned nil, treat that
128
+ # as the documented "defer to originator" signal — do NOT shadow it with the
129
+ # scope's after_sign_in_path (which would silently break OAuth/SSO handshakes
130
+ # for any host that configures a scope default).
131
+ return nil if context[:redirect_uri].present?
132
+
121
133
  # When no hook override, use the scope's after_sign_in_path if present
122
134
  scope_config&.after_sign_in_path
123
135
  end
@@ -26,6 +26,7 @@ module StandardId
26
26
 
27
27
  begin
28
28
  extract_state_and_nonce => { state_data:, nonce: }
29
+ caller_redirect_uri = state_data&.dig("redirect_uri").presence
29
30
  redirect_uri = callback_url_for
30
31
  provider_response = get_user_info_from_provider(redirect_uri:, nonce:)
31
32
  social_info = provider_response[:user_info]
@@ -52,10 +53,19 @@ module StandardId
52
53
  original_request_params: state_data
53
54
  )
54
55
 
55
- context = { mechanism: "social", provider: provider_name }
56
+ context = {
57
+ mechanism: "social",
58
+ provider: provider_name,
59
+ redirect_uri: caller_redirect_uri
60
+ }
56
61
  redirect_override = invoke_after_sign_in(account, context)
57
62
 
58
- destination = redirect_override || state_data["redirect_uri"]
63
+ # When the hook defers (returns nil), the originator-supplied URL becomes the
64
+ # destination. Validate it before redirect_to — without this, an attacker who
65
+ # tricks a victim into clicking /login?connection=google&redirect_uri=<evil>
66
+ # can steer the post-auth landing page. redirect_override is host-internal so
67
+ # we trust it; only the fallthrough needs validation.
68
+ destination = redirect_override || (safe_destination?(caller_redirect_uri) ? caller_redirect_uri : safe_post_signin_default)
59
69
  redirect_options = { notice: "Successfully signed in with #{provider_name.humanize}" }
60
70
  redirect_options[:allow_other_host] = true if allow_other_host_redirect?(destination)
61
71
  redirect_to destination, redirect_options
@@ -124,7 +134,17 @@ module StandardId
124
134
  "Authentication failed"
125
135
  end
126
136
 
127
- redirect_to StandardId::WebEngine.routes.url_helpers.login_path, alert: error_message
137
+ # Preserve redirect_uri across the bounce-back-to-/login so the user can retry
138
+ # the OAuth handshake and complete it back to the originator. Symmetric with
139
+ # the SocialLinkError / OAuthError rescue paths above.
140
+ redirect_uri = begin
141
+ extract_state_and_nonce => { state_data: }
142
+ state_data&.dig("redirect_uri").presence
143
+ rescue StandardId::InvalidRequestError
144
+ nil
145
+ end
146
+
147
+ redirect_to StandardId::WebEngine.routes.url_helpers.login_path(redirect_uri: redirect_uri), alert: error_message
128
148
  end
129
149
 
130
150
  def mobile_relay_params
@@ -14,6 +14,51 @@ module StandardId
14
14
 
15
15
  before_action -> { Current.scope = :web if defined?(::Current) }
16
16
  before_action :require_browser_session!
17
+
18
+ private
19
+
20
+ # Read a top-level query/form param expected to be a scalar String, returning
21
+ # nil for absent/blank values OR if Rails parsed it as an Array/Hash (e.g. from
22
+ # `?redirect_uri[]=a&redirect_uri[]=b`). Without this guard, `redirect_to` is
23
+ # called with a non-String and raises ArgumentError → 500 for any caller that
24
+ # sends a malformed redirect_uri.
25
+ def string_param(key)
26
+ value = params[key]
27
+ value.is_a?(String) ? value.presence : nil
28
+ end
29
+
30
+ # Whether `destination` is safe to redirect a signed-in user to.
31
+ # - Same-origin paths ("/foo") pass; protocol-relative ("//evil") does not.
32
+ # - Same-origin absolute URLs ("https://this-host/...") pass — `store_location_for_redirect`
33
+ # stashes `request.url` in session, so callers wrapping `after_authentication_url`
34
+ # need same-origin URLs accepted.
35
+ # - Cross-host URLs pass only when the host has explicitly allow-listed the prefix
36
+ # via `StandardId.config.allowed_redirect_url_prefixes`.
37
+ # - Anything else (blank, absolute URL not in the allow-list, protocol-relative,
38
+ # opaque scheme) is rejected; callers should fall back to `safe_post_signin_default`.
39
+ def safe_destination?(destination)
40
+ return false if destination.blank?
41
+ return true if destination.start_with?("/") && !destination.start_with?("//")
42
+ return true if same_origin_url?(destination)
43
+
44
+ Array(StandardId.config.allowed_redirect_url_prefixes).any? do |entry|
45
+ case entry
46
+ when Regexp then entry.match?(destination)
47
+ else destination.start_with?(entry.to_s)
48
+ end
49
+ end
50
+ end
51
+
52
+ def same_origin_url?(destination)
53
+ return false unless destination.start_with?("http://", "https://")
54
+ URI.parse(destination).origin == URI.parse(request.base_url).origin
55
+ rescue URI::Error, ArgumentError
56
+ false
57
+ end
58
+
59
+ def safe_post_signin_default
60
+ "/"
61
+ end
17
62
  end
18
63
  end
19
64
  end
@@ -31,7 +31,7 @@ module StandardId
31
31
  before_action :redirect_if_social_login, only: [:create]
32
32
 
33
33
  def show
34
- @redirect_uri = params[:redirect_uri] || after_authentication_url
34
+ @redirect_uri = string_param(:redirect_uri) || after_authentication_url
35
35
  @connection = params[:connection]
36
36
 
37
37
  render_with_inertia props: auth_page_props(passwordless_enabled: passwordless_enabled?)
@@ -59,9 +59,12 @@ module StandardId
59
59
  }
60
60
 
61
61
  if result
62
- context = { mechanism: "password", provider: nil }
62
+ redirect_uri = string_param(:redirect_uri)
63
+ context = { mechanism: "password", provider: nil, redirect_uri: redirect_uri }
63
64
  redirect_override = invoke_after_sign_in(current_account, context)
64
- destination = redirect_override || params[:redirect_uri] || after_authentication_url
65
+ fallback = after_authentication_url
66
+ fallback = safe_post_signin_default unless safe_destination?(fallback)
67
+ destination = redirect_override || (safe_destination?(redirect_uri) ? redirect_uri : nil) || fallback
65
68
  redirect_to destination, status: :see_other, notice: "Successfully signed in"
66
69
  else
67
70
  flash.now[:alert] = "Invalid email or password"
@@ -95,7 +98,11 @@ module StandardId
95
98
  expires_in: code_ttl.seconds
96
99
  )
97
100
  session[:standard_id_otp_payload] = signed_payload
98
- session[:return_to_after_authenticating] = params[:redirect_uri] if params[:redirect_uri].present?
101
+ # Use string_param to reject Array/Hash-shaped values — login_verify_controller
102
+ # consumes this via after_authentication_url and passes it to redirect_to;
103
+ # storing a non-String here would crash that flow with ArgumentError.
104
+ redirect_uri = string_param(:redirect_uri)
105
+ session[:return_to_after_authenticating] = redirect_uri if redirect_uri
99
106
 
100
107
  redirect_to login_verify_path, status: :see_other
101
108
  end
@@ -60,12 +60,24 @@ module StandardId
60
60
  invoke_after_account_created(account, { mechanism: "passwordless", provider: nil })
61
61
  end
62
62
 
63
- context = { mechanism: "passwordless", provider: nil }
63
+ # Peek (don't pop) session[:return_to_after_authenticating] after_authentication_url
64
+ # consumes it below when redirect_override is nil, so deleting it here would lose the
65
+ # destination for hosts whose after_sign_in hook defers to the originator's redirect_uri.
66
+ context = {
67
+ mechanism: "passwordless",
68
+ provider: nil,
69
+ redirect_uri: session[:return_to_after_authenticating]
70
+ }
64
71
  redirect_override = invoke_after_sign_in(account, context)
65
72
 
66
73
  session.delete(:standard_id_otp_payload)
67
74
 
68
- destination = redirect_override || after_authentication_url
75
+ # after_authentication_url returns whatever was stashed in
76
+ # session[:return_to_after_authenticating] — which could be an attacker-controlled
77
+ # URL set by handle_passwordless_login from params[:redirect_uri]. string_param
78
+ # blocks Array/Hash but not "https://evil.com/phish". Validate before redirect.
79
+ fallback = after_authentication_url
80
+ destination = redirect_override || (safe_destination?(fallback) ? fallback : safe_post_signin_default)
69
81
  redirect_to destination, status: :see_other, notice: "Successfully signed in"
70
82
  rescue StandardId::AuthenticationDenied => e
71
83
  session.delete(:standard_id_otp_payload)
@@ -14,13 +14,18 @@ module StandardId
14
14
 
15
15
  def create
16
16
  revoke_current_session!
17
- redirect_to params[:redirect_uri] || root_path, notice: "Successfully signed out"
17
+ redirect_to logout_destination, notice: "Successfully signed out"
18
18
  end
19
19
 
20
20
  private
21
21
 
22
22
  def redirect_if_not_authenticated
23
- redirect_to params[:redirect_uri] || root_path unless authenticated?
23
+ redirect_to logout_destination unless authenticated?
24
+ end
25
+
26
+ def logout_destination
27
+ redirect_uri = string_param(:redirect_uri)
28
+ safe_destination?(redirect_uri) ? redirect_uri : root_path
24
29
  end
25
30
  end
26
31
  end
@@ -15,7 +15,7 @@ module StandardId
15
15
  before_action :redirect_if_social_login, only: [:create]
16
16
 
17
17
  def show
18
- @redirect_uri = params[:redirect_uri] || after_authentication_url
18
+ @redirect_uri = string_param(:redirect_uri) || after_authentication_url
19
19
  @connection = params[:connection] # For social login detection
20
20
 
21
21
  render_with_inertia props: auth_page_props
@@ -43,12 +43,15 @@ module StandardId
43
43
  session_manager.sign_in_account(form.account, scope_name: request.path_parameters[:scope])
44
44
  invoke_after_account_created(form.account, { mechanism: "signup", provider: nil })
45
45
 
46
- context = { mechanism: "password", provider: nil }
46
+ redirect_uri = string_param(:redirect_uri)
47
+ context = { mechanism: "password", provider: nil, redirect_uri: redirect_uri }
47
48
  redirect_override = invoke_after_sign_in(form.account, context)
48
- destination = redirect_override || params[:redirect_uri] || after_authentication_url
49
+ fallback = after_authentication_url
50
+ fallback = safe_post_signin_default unless safe_destination?(fallback)
51
+ destination = redirect_override || (safe_destination?(redirect_uri) ? redirect_uri : nil) || fallback
49
52
  redirect_to destination, notice: "Account created successfully"
50
53
  else
51
- @redirect_uri = params[:redirect_uri] || after_authentication_url
54
+ @redirect_uri = string_param(:redirect_uri) || after_authentication_url
52
55
  @connection = params[:connection]
53
56
  flash.now[:alert] = form.errors.full_messages.join(", ")
54
57
  render_with_inertia action: :show, props: auth_page_props(errors: form.errors.to_hash), status: :unprocessable_content
@@ -79,7 +82,7 @@ module StandardId
79
82
 
80
83
  def encode_state
81
84
  Base64.urlsafe_encode64({
82
- redirect_uri: params[:redirect_uri] || after_authentication_url,
85
+ redirect_uri: string_param(:redirect_uri) || after_authentication_url,
83
86
  timestamp: Time.current.to_i
84
87
  }.to_json)
85
88
  end
@@ -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.1"
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.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jaryl Sim