standard_id 0.14.3 → 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 +4 -4
- data/README.md +104 -0
- data/app/controllers/concerns/standard_id/audience_verification.rb +98 -9
- data/app/controllers/concerns/standard_id/lifecycle_hooks.rb +97 -31
- data/app/jobs/standard_id/cleanup_expired_authorization_codes_job.rb +51 -0
- data/app/jobs/standard_id/cleanup_expired_code_challenges_job.rb +45 -0
- data/app/models/standard_id/code_challenge.rb +6 -1
- data/db/migrate/20260414200000_add_target_created_at_index_to_code_challenges.rb +7 -0
- data/lib/generators/standard_id/install/install_generator.rb +174 -1
- data/lib/generators/standard_id/install/templates/standard_id.rb +308 -76
- data/lib/standard_id/api/token_manager.rb +57 -10
- data/lib/standard_id/config/schema.rb +77 -4
- data/lib/standard_id/errors.rb +43 -0
- data/lib/standard_id/events/definitions.rb +4 -1
- data/lib/standard_id/jwt_service.rb +98 -0
- data/lib/standard_id/oauth/audience_profile_resolver.rb +93 -0
- data/lib/standard_id/oauth/oauth_session_persistence.rb +81 -0
- data/lib/standard_id/oauth/token_grant_flow.rb +57 -1
- data/lib/standard_id/otp.rb +250 -0
- data/lib/standard_id/passwordless/base_strategy.rb +54 -21
- data/lib/standard_id/passwordless/verification_service.rb +85 -36
- data/lib/standard_id/scope_config.rb +79 -4
- data/lib/standard_id/session_type_resolver.rb +102 -0
- data/lib/standard_id/version.rb +1 -1
- data/lib/standard_id/web/token_manager.rb +42 -4
- data/lib/standard_id.rb +5 -1
- data/lib/tasks/standard_id_tasks.rake +30 -4
- metadata +8 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: b7231e9470123ff19809e493c1ebea4f84b573b3a39ca1a1f79f1e186a378fa1
|
|
4
|
+
data.tar.gz: 9dcc82b759903b33a2352e3849ff9044ca576b400e75f728f6c4bf49317011cd
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
85
|
-
# error.message
|
|
86
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
178
|
+
%(Bearer error="insufficient_scope", error_description="#{GENERIC_INSUFFICIENT_SCOPE_MESSAGE}")
|
|
93
179
|
)
|
|
94
|
-
render json: {
|
|
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]
|
|
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
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
)
|
|
63
|
-
|
|
64
|
-
#
|
|
65
|
-
if scope_config.requires_profile?
|
|
66
|
-
|
|
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]
|
|
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]
|
|
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(
|
|
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
|
|
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
|