standard_id 0.14.4 → 0.15.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: 47ce66e4e4b242fd96a90cda7b20a9316e31642551ebe6c94ae660336f8f0b2c
4
- data.tar.gz: a1a6d6f746f2ef0590aec0b5d6c4cc51472e04030af53e30d8546495d2556f03
3
+ metadata.gz: b7231e9470123ff19809e493c1ebea4f84b573b3a39ca1a1f79f1e186a378fa1
4
+ data.tar.gz: 9dcc82b759903b33a2352e3849ff9044ca576b400e75f728f6c4bf49317011cd
5
5
  SHA512:
6
- metadata.gz: f323ce02474f8d4fc98a8aa4466616f15e58b9a5f6174336c91c0f9bc02c22e95862909963d0470271b49dc588fb0abcfcd2434c17a7ece79952d7547ed81ddf
7
- data.tar.gz: 03a24d819ad4f699be3d97ce26e8dcf04873b373ff55c03e31f0de475a014198053a1927bf6539de40c0398855490be01ea4bc77dbe093d1f381f67eddfb8608
6
+ metadata.gz: 933f67b55905bd7b6ff421a77ff8b51ea3eab24ce84ed939e710b657b5e3838ead43ce0e0f2e9cdbfb58afb04f1d3f91e69e34ce335e13fdbccfd7161ec63600
7
+ data.tar.gz: 789d75aa20a6ff0305aa75140abc85d6dd4c7b7ffa42f85f06d3828429c1c763544359dc0519fc4c2ca8debdfa5e568411820b6a4846e86d2990a0b43c272101
data/README.md CHANGED
@@ -404,6 +404,56 @@ Event payload includes:
404
404
 
405
405
  > **Note**: If you're using the deprecated `passwordless_email_sender` or `passwordless_sms_sender` callbacks, see the [Migration Guide](docs/MIGRATION_GUIDE.md) for upgrade instructions.
406
406
 
407
+ ### Using OTP for non-authentication flows
408
+
409
+ The same hardened OTP machinery (enumeration defense, atomic attempt tracking, pessimistic locking, bypass_code hook) is exposed as a public primitive via `StandardId::Otp`. Use it when you need one-time codes for purposes *other* than authentication — contact verification widgets, step-up challenges, custom confirmation flows, etc.
410
+
411
+ ```ruby
412
+ # Issue a code — caller handles delivery
413
+ result = StandardId::Otp.issue(
414
+ realm: "widget_contact_verification",
415
+ target: "user@example.com",
416
+ channel: :email,
417
+ request: request,
418
+ delivery: :manual
419
+ )
420
+
421
+ MyMailer.widget_otp(result.challenge.target, result.code).deliver_later
422
+
423
+ # Verify the code
424
+ result = StandardId::Otp.verify(
425
+ realm: "widget_contact_verification",
426
+ target: params[:email],
427
+ channel: :email,
428
+ code: params[:otp],
429
+ request: request
430
+ )
431
+
432
+ if result.success?
433
+ # Code is valid — proceed with the action.
434
+ else
435
+ case result.error_code
436
+ when :not_found, :invalid_code then render_error("Invalid code")
437
+ when :expired then render_error("Code expired")
438
+ when :max_attempts then render_error("Too many attempts")
439
+ end
440
+ end
441
+ ```
442
+
443
+ **Delivery modes for `Otp.issue`:**
444
+
445
+ | Mode | Behavior |
446
+ |-------------|--------------------------------------------------------------------------|
447
+ | `:built_in` | Uses the engine's bundled `PasswordlessMailer` (email only). |
448
+ | `:custom` | Invokes `passwordless_email_sender` / `passwordless_sms_sender` callback.|
449
+ | `:manual` | Skips delivery; returns the raw `code` on the result for caller to deliver. |
450
+
451
+ **Realm isolation.** `realm:` is a free-form string that partitions challenges by purpose. A code issued for realm `"widget_contact_verification"` cannot be used to verify against realm `"authentication"` (or any other realm) — even for the same `target`. Choose a stable string per flow.
452
+
453
+ **Bypass code (E2E testing).** When `StandardId.config.passwordless.bypass_code` is set (and `Rails.env` is not `"production"`), `Otp.verify` accepts the bypass code for **any realm**. This replaces per-app bypass ENV checks and works consistently across `Otp.verify` and the built-in passwordless login flow. Never set `bypass_code` in production — it will raise if you try.
454
+
455
+ **Back-compat.** The existing passwordless authentication flow continues to work unchanged. `Otp.issue`/`Otp.verify` are a new addition — you can migrate direct `CodeChallenge.create!` calls at your own pace.
456
+
407
457
  ## Event System
408
458
 
409
459
  StandardId emits events throughout the authentication lifecycle using `ActiveSupport::Notifications`. This enables decoupled handling of cross-cutting concerns like logging, analytics, audit trails, and webhooks.
@@ -900,6 +950,60 @@ class Api::UsersController < ApiController
900
950
  end
901
951
  ```
902
952
 
953
+ ## Primitives
954
+
955
+ StandardId also ships a couple of thin, config-free JWT primitives for use cases
956
+ that have nothing to do with OAuth sessions — for example, service-to-service
957
+ tokens between two of your own backends.
958
+
959
+ ### `JwtService.sign` / `JwtService.verify`
960
+
961
+ These are low-level wrappers around `JWT.encode` / `JWT.decode`. They do **not**
962
+ read `StandardId.config` — you supply the algorithm and key directly, and you
963
+ control the full payload. No `iss`, `aud`, or `iat` is added for you.
964
+
965
+ ```ruby
966
+ # Sign an HS256 service token
967
+ token = StandardId::JwtService.sign(
968
+ { sub: "harness", aud: "sidekick", gid: "gid://..." },
969
+ algorithm: "HS256",
970
+ key: Rails.application.credentials.dig(:service_jwt, :secret),
971
+ expires_in: 5.minutes
972
+ )
973
+
974
+ # Verify it on the receiving side
975
+ payload = StandardId::JwtService.verify(
976
+ token,
977
+ algorithm: "HS256",
978
+ key: Rails.application.credentials.dig(:service_jwt, :secret),
979
+ allowed_audiences: %w[sidekick]
980
+ )
981
+ payload["sub"] # => "harness"
982
+ ```
983
+
984
+ Supports:
985
+
986
+ - HS256 / HS384 / HS512, RS256 / RS384 / RS512, ES256 / ES384 / ES512
987
+ - `expires_in:` to auto-set `exp` (caller-supplied `exp` wins)
988
+ - Arbitrary JWT headers via `**extra_headers` (e.g. `kid:`)
989
+ - `allowed_audiences:` to enforce the `aud` claim
990
+ - `key:` as a single value or an `Array` — keys are tried in order, so rotation
991
+ is a one-liner
992
+ - Failure raises `StandardId::InvalidTokenError` (with subclasses
993
+ `ExpiredTokenError`, `InvalidSignatureError`, `InvalidAlgorithmError`,
994
+ `InvalidAudienceTokenError`) — no `nil` returns
995
+
996
+ ### When to use which
997
+
998
+ | Use case | API |
999
+ |---|---|
1000
+ | OAuth 2.0 / OIDC access and ID tokens, browser/device sessions | `JwtService.encode` / `.decode` / `.decode_session` |
1001
+ | Anything else (service-to-service, internal signed payloads, webhooks) | `JwtService.sign` / `.verify` |
1002
+
1003
+ The OAuth/session methods consult `StandardId.config` (issuer, signing key,
1004
+ rotation, claim resolvers, etc.) and add standard claims automatically. The
1005
+ primitives deliberately do none of that.
1006
+
903
1007
  ## Database Schema
904
1008
 
905
1009
  StandardId creates the following tables:
@@ -7,6 +7,11 @@ module StandardId
7
7
  # `allowed_audiences` list, this concern provides additional defense-in-depth
8
8
  # by restricting which audiences are accepted by each controller.
9
9
  #
10
+ # In addition, when `StandardId.config.oauth.audience_profile_types` is set,
11
+ # this concern enforces the audience → profile-type binding: after the
12
+ # allowed-audience check, it resolves the current account's profile for the
13
+ # matched audience and rejects requests whose profile type does not match.
14
+ #
10
15
  # Requires StandardId::ApiAuthentication to be included before this concern
11
16
  # (provides `verify_access_token!` and `current_session`). An error is raised
12
17
  # at include time if ApiAuthentication is missing.
@@ -39,6 +44,7 @@ module StandardId
39
44
  before_action :verify_audience!
40
45
 
41
46
  rescue_from StandardId::InvalidAudienceError, with: :handle_invalid_audience
47
+ rescue_from StandardId::InvalidAudienceProfileError, with: :handle_invalid_audience
42
48
 
43
49
  # Underscore prefix follows Rails class_attribute convention to avoid
44
50
  # collisions with application method names.
@@ -57,10 +63,13 @@ module StandardId
57
63
 
58
64
  private
59
65
 
60
- # Verifies the token's `aud` claim contains at least one of the required audiences.
66
+ # Verifies the token's `aud` claim contains at least one of the required audiences,
67
+ # then enforces audience → profile-type binding when configured.
61
68
  # Supports both string and array `aud` claims.
62
69
  #
63
70
  # @raise [StandardId::InvalidAudienceError] when no audience matches
71
+ # @raise [StandardId::InvalidAudienceProfileError] when the account's
72
+ # profile type does not match the configured binding for the matched audience
64
73
  def verify_audience!
65
74
  return if _required_audiences.empty?
66
75
 
@@ -69,29 +78,109 @@ module StandardId
69
78
  return unless current_session
70
79
 
71
80
  token_audiences = Array(current_session.aud)
72
- return if (token_audiences & _required_audiences).any?
81
+ matched = (token_audiences & _required_audiences).first
82
+
83
+ if matched.nil?
84
+ raise StandardId::InvalidAudienceError.new(
85
+ required: _required_audiences,
86
+ actual: token_audiences
87
+ )
88
+ end
89
+
90
+ enforce_audience_profile_binding!(matched, token_audiences)
91
+ end
92
+
93
+ # Enforce `audience_profile_types[matched]` against the current account.
94
+ # No-op when the audience has no binding configured (back-compat).
95
+ def enforce_audience_profile_binding!(matched_audience, token_audiences)
96
+ expected_types = StandardId::Oauth::AudienceProfileResolver.profile_types_for(matched_audience)
97
+ return if expected_types.empty?
73
98
 
74
- raise StandardId::InvalidAudienceError.new(
99
+ profile = StandardId::Oauth::AudienceProfileResolver.call(
100
+ account: current_account,
101
+ audience: matched_audience
102
+ )
103
+ actual_type = profile_type_name(profile)
104
+
105
+ return if actual_type && expected_types.include?(actual_type)
106
+
107
+ # For audit purposes, when there is no matching profile, report the
108
+ # first profile type the account actually has (if any) so operators can
109
+ # see what the client was carrying instead.
110
+ actual_type ||= fallback_actual_profile_type
111
+
112
+ error = StandardId::InvalidAudienceProfileError.new(
113
+ audience: matched_audience,
114
+ expected_profile_types: expected_types,
115
+ actual_profile_type: actual_type,
75
116
  required: _required_audiences,
76
117
  actual: token_audiences
77
118
  )
119
+ emit_audience_mismatch(error)
120
+ raise error
121
+ end
122
+
123
+ def profile_type_name(profile)
124
+ return nil if profile.nil?
125
+ return profile.profileable_type.to_s if profile.respond_to?(:profileable_type)
126
+ return profile.type.to_s if profile.respond_to?(:type)
127
+
128
+ profile.class.name.to_s
129
+ end
130
+
131
+ # When no matching profile exists for the audience, surface the first
132
+ # profile type the account carries (if any) to aid debugging/audit. This
133
+ # never changes the decision — the mismatch is already confirmed when
134
+ # this is called — it only enriches the error/event payload.
135
+ def fallback_actual_profile_type
136
+ account = current_account
137
+ return nil if account.nil? || !account.respond_to?(:profiles)
138
+
139
+ candidates = account.profiles
140
+ candidates = candidates.to_a unless candidates.is_a?(Array)
141
+ first = candidates.first
142
+ profile_type_name(first)
143
+ end
144
+
145
+ def emit_audience_mismatch(error)
146
+ StandardId::Events.publish(
147
+ StandardId::Events::OAUTH_AUDIENCE_MISMATCH,
148
+ audience: error.audience,
149
+ token_audiences: error.actual,
150
+ required_audiences: error.required,
151
+ expected_profile_types: error.expected_profile_types,
152
+ actual_profile_type: error.actual_profile_type,
153
+ account: (current_account if respond_to?(:current_account, true))
154
+ )
155
+ rescue StandardError => e
156
+ StandardId.config.logger&.warn(
157
+ "[StandardId] failed to emit oauth.audience.mismatch event: #{e.class}: #{e.message}"
158
+ )
78
159
  end
79
160
 
80
161
  # Returns 403 Forbidden per RFC 6750 §3.1 (insufficient_scope).
81
162
  # Includes WWW-Authenticate header per spec, consistent with the gem's
82
163
  # 401 handling in Api::BaseController#render_bearer_unauthorized!.
83
164
  #
84
- # The header uses a static description rather than interpolating
85
- # error.message (which contains raw aud values from the JWT) to
86
- # avoid header injection via crafted audience strings.
165
+ # Both the header AND the JSON body use a static description rather
166
+ # than interpolating error.message, which contains raw aud values
167
+ # from the JWT and internal profile type names. Exposing those lets
168
+ # an attacker probe valid tokens across audiences to enumerate the
169
+ # internal profile-type taxonomy. Details are published as an
170
+ # OAUTH_AUDIENCE_MISMATCH audit event for server-side inspection.
87
171
  #
88
172
  # Override in your controller for custom error formatting.
89
- def handle_invalid_audience(error)
173
+ GENERIC_INSUFFICIENT_SCOPE_MESSAGE = "The access token audience is not permitted for this resource".freeze
174
+
175
+ def handle_invalid_audience(_error)
90
176
  response.set_header(
91
177
  "WWW-Authenticate",
92
- 'Bearer error="insufficient_scope", error_description="The access token audience is not permitted for this resource"'
178
+ %(Bearer error="insufficient_scope", error_description="#{GENERIC_INSUFFICIENT_SCOPE_MESSAGE}")
93
179
  )
94
- render json: { error: "insufficient_scope", error_description: error.message }, status: :forbidden
180
+ render json: {
181
+ error: "insufficient_scope",
182
+ error_description: GENERIC_INSUFFICIENT_SCOPE_MESSAGE
183
+ }, status: :forbidden
95
184
  end
96
185
  end
97
186
  end
@@ -33,6 +33,10 @@ module StandardId
33
33
  # Default profile resolver when StandardId.config.profile_resolver is nil.
34
34
  DEFAULT_PROFILE_RESOLVER = ->(acct, pt) { acct.profiles.exists?(profileable_type: pt) }
35
35
 
36
+ # Default scope resolver when StandardId.config.scope_resolver is nil.
37
+ # Preserves the historical behaviour of reading :scope from route defaults.
38
+ DEFAULT_SCOPE_RESOLVER = ->(request:, session:) { request.path_parameters[:scope]&.to_sym }
39
+
36
40
  private
37
41
 
38
42
  # Invoke the before_sign_in hook if configured.
@@ -48,25 +52,23 @@ module StandardId
48
52
  # - :provider [String, nil] e.g. "google", "apple", or nil
49
53
  # - :first_sign_in [Boolean] whether this is the account's first browser session
50
54
  # - :scope [Symbol, nil] scope name when scoped authentication is active
51
- # - :profile_type [String, nil] required profile type for the scope
55
+ # - :profile_type [String, nil] first configured profile type for the scope (back-compat)
56
+ # - :profile_types [Array<String>, nil] all configured profile types for the scope
52
57
  # - :after_sign_in_path [String, nil] default redirect path for the scope
53
58
  # @return [void]
54
59
  # @raise [StandardId::AuthenticationDenied] when profile check fails or hook returns { error: "..." }
55
60
  def invoke_before_sign_in(account, context)
56
61
  scope_config = current_scope_config
57
62
  if scope_config
58
- context = context.merge(
59
- scope: scope_config.name,
60
- profile_type: scope_config.profile_type,
61
- after_sign_in_path: scope_config.after_sign_in_path
62
- )
63
-
64
- # Built-in profile check runs before the app's custom hook
65
- if scope_config.requires_profile?
66
- resolver = StandardId.config.profile_resolver || DEFAULT_PROFILE_RESOLVER
67
- unless resolver.call(account, scope_config.profile_type)
68
- raise StandardId::AuthenticationDenied, scope_config.no_profile_message
69
- end
63
+ context = context.merge(scope_context(scope_config))
64
+
65
+ # Built-in profile check and/or authorizer — runs before the app's custom hook.
66
+ # A scope may configure :authorizer without :profile_types (e.g. policy-only
67
+ # gates), so we must still run validate_scope_profile! in that case —
68
+ # otherwise the authorizer would be silently skipped and every sign-in
69
+ # granted regardless of its decision.
70
+ if scope_config.requires_profile? || scope_config.authorizer?
71
+ validate_scope_profile!(account, scope_config)
70
72
  end
71
73
  end
72
74
 
@@ -95,19 +97,14 @@ module StandardId
95
97
  # - :first_sign_in [Boolean] whether this is the account's first browser session
96
98
  # - :session [StandardId::Session] the session that was just created
97
99
  # - :scope [Symbol, nil] scope name when scoped authentication is active
98
- # - :profile_type [String, nil] required profile type for the scope
100
+ # - :profile_type [String, nil] first configured profile type for the scope (back-compat)
101
+ # - :profile_types [Array<String>, nil] all configured profile types for the scope
99
102
  # - :after_sign_in_path [String, nil] default redirect path for the scope
100
103
  # @return [String, nil] redirect path override, or nil for default
101
104
  # @raise [StandardId::AuthenticationDenied] to reject the sign-in
102
105
  def invoke_after_sign_in(account, context)
103
106
  scope_config = current_scope_config
104
- if scope_config
105
- context = context.merge(
106
- scope: scope_config.name,
107
- profile_type: scope_config.profile_type,
108
- after_sign_in_path: scope_config.after_sign_in_path
109
- )
110
- end
107
+ context = context.merge(scope_context(scope_config)) if scope_config
111
108
 
112
109
  hook = StandardId.config.after_sign_in
113
110
  context = context.merge(
@@ -132,18 +129,13 @@ module StandardId
132
129
  # - :mechanism [String] "passwordless", "social", or "signup"
133
130
  # - :provider [String, nil] e.g. "google", "apple", or nil
134
131
  # - :scope [Symbol, nil] scope name when scoped authentication is active
135
- # - :profile_type [String, nil] required profile type for the scope
132
+ # - :profile_type [String, nil] first configured profile type for the scope (back-compat)
133
+ # - :profile_types [Array<String>, nil] all configured profile types for the scope
136
134
  # - :after_sign_in_path [String, nil] default redirect path for the scope
137
135
  # @return [void]
138
136
  def invoke_after_account_created(account, context)
139
137
  scope_config = current_scope_config
140
- if scope_config
141
- context = context.merge(
142
- scope: scope_config.name,
143
- profile_type: scope_config.profile_type,
144
- after_sign_in_path: scope_config.after_sign_in_path
145
- )
146
- end
138
+ context = context.merge(scope_context(scope_config)) if scope_config
147
139
 
148
140
  hook = StandardId.config.after_account_created
149
141
  return unless hook.respond_to?(:call)
@@ -198,13 +190,87 @@ module StandardId
198
190
  end
199
191
  end
200
192
 
193
+ # Resolve the active scope name for the current request.
194
+ # Delegates to the app-configured :scope_resolver callable so apps can source
195
+ # the scope from subdomains, session state, or custom path params without
196
+ # overriding this concern.
197
+ # Memoized per request.
198
+ def current_scope_name
199
+ return @current_scope_name if defined?(@current_scope_name)
200
+ resolver = StandardId.config.scope_resolver
201
+ resolver = DEFAULT_SCOPE_RESOLVER unless resolver.respond_to?(:call)
202
+ session = session_manager.respond_to?(:current_session) ? session_manager.current_session : nil
203
+ @current_scope_name = resolver.call(request: request, session: session)
204
+ end
205
+
201
206
  # Look up the scope config for the current request.
202
- # Reads :scope from route defaults (set by scoped route constraints).
203
207
  # Returns nil when no scope is active, preserving backward compatibility.
204
208
  # Memoized per request to avoid redundant ScopeConfig allocations.
205
209
  def current_scope_config
206
210
  return @current_scope_config if defined?(@current_scope_config)
207
- @current_scope_config = StandardId.scope_for(request.path_parameters[:scope])
211
+ @current_scope_config = StandardId.scope_for(current_scope_name)
212
+ end
213
+
214
+ # Validate that the account has a profile matching one of the scope's configured
215
+ # profile_types, then (when configured) run the scope's custom :authorizer callable.
216
+ #
217
+ # Raises StandardId::AuthenticationDenied using the scope's no_profile_message when:
218
+ # - no profile of any configured type exists, or
219
+ # - the :authorizer returns a falsey value.
220
+ def validate_scope_profile!(account, scope_config)
221
+ resolver = StandardId.config.profile_resolver || DEFAULT_PROFILE_RESOLVER
222
+ matched_type = nil
223
+
224
+ if scope_config.requires_profile?
225
+ matched_type = scope_config.profile_types.find { |type| resolver.call(account, type) }
226
+
227
+ unless matched_type
228
+ raise StandardId::AuthenticationDenied, scope_config.no_profile_message
229
+ end
230
+ end
231
+
232
+ return unless scope_config.authorizer?
233
+
234
+ profile = matched_type ? resolve_profile_for_authorizer(account, matched_type) : nil
235
+ result = scope_config.authorizer.call(
236
+ account: account,
237
+ profile: profile,
238
+ scope: scope_config
239
+ )
240
+
241
+ unless result
242
+ raise StandardId::AuthenticationDenied, scope_config.no_profile_message
243
+ end
244
+ end
245
+
246
+ # Best-effort lookup of the matched profile record to pass into the :authorizer.
247
+ # Returns nil when the account does not expose a :profiles association of the
248
+ # expected shape — authorizers that need richer context can re-query from the
249
+ # account keyword arg.
250
+ #
251
+ # Rescue is narrowed to the structural cases we actually want to tolerate
252
+ # (missing methods / wrong types on a shape-mismatched association). DB-level
253
+ # errors are intentionally allowed to propagate so a transient outage isn't
254
+ # silently converted into "no profile found" and a denied sign-in.
255
+ def resolve_profile_for_authorizer(account, profile_type)
256
+ return nil unless account.respond_to?(:profiles)
257
+ relation = account.profiles
258
+ return nil unless relation.respond_to?(:find_by)
259
+ relation.find_by(profileable_type: profile_type)
260
+ rescue NoMethodError, TypeError
261
+ nil
262
+ end
263
+
264
+ # Build the hash of scope fields merged into lifecycle hook context.
265
+ # Includes the legacy :profile_type (singular) alongside :profile_types (plural)
266
+ # so existing hooks keep reading the same key.
267
+ def scope_context(scope_config)
268
+ {
269
+ scope: scope_config.name,
270
+ profile_type: scope_config.profile_type,
271
+ profile_types: scope_config.profile_types,
272
+ after_sign_in_path: scope_config.after_sign_in_path
273
+ }
208
274
  end
209
275
  end
210
276
  end
@@ -0,0 +1,51 @@
1
+ module StandardId
2
+ class CleanupExpiredAuthorizationCodesJob < ApplicationJob
3
+ queue_as :default
4
+
5
+ # Delete OAuth authorization codes that are either expired or consumed
6
+ # beyond the respective grace periods.
7
+ #
8
+ # Authorization codes are single-use and short-lived (OAuth 2.1 recommends
9
+ # a lifetime under 10 minutes). Two grace windows apply:
10
+ #
11
+ # - `grace_period_seconds` (default 7 days): how long expired-but-unused
12
+ # codes are retained after `expires_at`. Matches the sessions/refresh-
13
+ # token cleanup windows so operators only have to reason about one
14
+ # default.
15
+ # - `consumed_grace_period_seconds` (default 1 day): how long consumed
16
+ # codes are retained after `consumed_at`. Used codes are useless after
17
+ # redemption, so they're pruned faster — keeping them briefly only
18
+ # helps with replay-attack forensics in the immediate aftermath of a
19
+ # redemption.
20
+ #
21
+ # Accepts integer seconds for reliable ActiveJob serialization across all
22
+ # queue adapters.
23
+ def perform(grace_period_seconds: 7.days.to_i, consumed_grace_period_seconds: 1.day.to_i)
24
+ expired_cutoff = grace_period_seconds.seconds.ago
25
+ consumed_cutoff = consumed_grace_period_seconds.seconds.ago
26
+
27
+ # The two windows govern disjoint sets of rows:
28
+ # - Unconsumed codes: ruled by `expires_at < expired_cutoff`.
29
+ # - Consumed codes: ruled by `consumed_at < consumed_cutoff`.
30
+ #
31
+ # A naive `expires_at < :expired_cutoff OR consumed_at < :consumed_cutoff`
32
+ # would let the expired arm delete a consumed-12-hours-ago row whose
33
+ # `expires_at` sits 8 days in the past — defeating the consumed grace
34
+ # window's whole purpose. Scoping the expired clause to `consumed_at IS
35
+ # NULL` keeps consumed rows under the consumed window exclusively.
36
+ deleted = StandardId::AuthorizationCode
37
+ .where(
38
+ "(expires_at < :expired_cutoff AND consumed_at IS NULL) " \
39
+ "OR consumed_at < :consumed_cutoff",
40
+ expired_cutoff: expired_cutoff,
41
+ consumed_cutoff: consumed_cutoff
42
+ )
43
+ .delete_all
44
+
45
+ Rails.logger.info(
46
+ "[StandardId] Cleaned up #{deleted} authorization codes " \
47
+ "(expired before #{expired_cutoff}, consumed before #{consumed_cutoff})"
48
+ )
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,45 @@
1
+ module StandardId
2
+ class CleanupExpiredCodeChallengesJob < ApplicationJob
3
+ queue_as :default
4
+
5
+ # Delete code challenges (OTP records) that are either expired or used
6
+ # beyond the respective grace periods.
7
+ #
8
+ # Code challenges back passwordless/OTP flows: rows are short-lived and
9
+ # single-use. Two grace windows apply:
10
+ #
11
+ # - `grace_period_seconds` (default 7 days): how long expired-but-unused
12
+ # challenges are retained after `expires_at`. Matches the sessions/
13
+ # refresh-token cleanup windows for operational consistency.
14
+ # - `used_grace_period_seconds` (default 1 day): how long used challenges
15
+ # are retained after `used_at`. A used OTP is useless after redemption,
16
+ # so prune faster — the short tail only helps with replay-attack
17
+ # forensics immediately after use.
18
+ #
19
+ # Accepts integer seconds for reliable ActiveJob serialization across all
20
+ # queue adapters.
21
+ def perform(grace_period_seconds: 7.days.to_i, used_grace_period_seconds: 1.day.to_i)
22
+ expired_cutoff = grace_period_seconds.seconds.ago
23
+ used_cutoff = used_grace_period_seconds.seconds.ago
24
+
25
+ # See CleanupExpiredAuthorizationCodesJob for the rationale — the two
26
+ # windows govern disjoint sets of rows and a naive OR lets the expired
27
+ # arm prune recently-used challenges out from under the used grace
28
+ # window. Unused challenges follow `expires_at`; used challenges follow
29
+ # `used_at`.
30
+ deleted = StandardId::CodeChallenge
31
+ .where(
32
+ "(expires_at < :expired_cutoff AND used_at IS NULL) " \
33
+ "OR used_at < :used_cutoff",
34
+ expired_cutoff: expired_cutoff,
35
+ used_cutoff: used_cutoff
36
+ )
37
+ .delete_all
38
+
39
+ Rails.logger.info(
40
+ "[StandardId] Cleaned up #{deleted} code challenges " \
41
+ "(expired before #{expired_cutoff}, used before #{used_cutoff})"
42
+ )
43
+ end
44
+ end
45
+ end
@@ -2,10 +2,15 @@ module StandardId
2
2
  class CodeChallenge < ApplicationRecord
3
3
  self.table_name = "standard_id_code_challenges"
4
4
 
5
+ # Well-known realms used by the engine itself. Host apps may create
6
+ # challenges in any realm (see StandardId::Otp) — realm is a free-form
7
+ # string that partitions challenges by purpose (e.g. "authentication",
8
+ # "verification", "custom_widget"). Only presence is validated so
9
+ # consumers can define their own realms without the engine knowing.
5
10
  REALMS = %w[authentication verification].freeze
6
11
  CHANNELS = %w[email sms].freeze
7
12
 
8
- validates :realm, presence: true, inclusion: { in: REALMS }
13
+ validates :realm, presence: true
9
14
  validates :channel, presence: true, inclusion: { in: CHANNELS }
10
15
  validates :target, presence: true
11
16
  validates :code, presence: true