standard_id 0.14.4 → 0.16.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 +114 -1
- data/app/controllers/concerns/standard_id/audience_verification.rb +121 -12
- data/app/controllers/concerns/standard_id/lifecycle_hooks.rb +117 -36
- data/app/controllers/concerns/standard_id/social_authentication.rb +19 -0
- data/app/controllers/standard_id/api/oauth/revocations_controller.rb +55 -6
- data/app/controllers/standard_id/web/auth/callback/providers_controller.rb +6 -0
- data/app/controllers/standard_id/web/reset_password/start_controller.rb +27 -1
- data/app/controllers/standard_id/web/verify_email/start_controller.rb +1 -7
- data/app/controllers/standard_id/web/verify_phone/start_controller.rb +1 -7
- data/app/forms/standard_id/web/reset_password_start_form.rb +20 -19
- 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/jobs/standard_id/password_reset_delivery_job.rb +42 -0
- data/app/mailers/standard_id/password_reset_mailer.rb +16 -0
- data/app/models/concerns/standard_id/account_associations.rb +43 -0
- data/app/models/standard_id/authorization_code.rb +15 -2
- data/app/models/standard_id/client_application.rb +78 -3
- data/app/models/standard_id/code_challenge.rb +6 -1
- data/app/views/standard_id/password_reset_mailer/reset_email.html.erb +27 -0
- data/app/views/standard_id/password_reset_mailer/reset_email.text.erb +12 -0
- data/db/migrate/20260416180511_add_partial_indexes_for_active_session_and_challenge_lookups.rb +117 -0
- data/lib/generators/standard_id/install/install_generator.rb +174 -1
- data/lib/generators/standard_id/install/templates/standard_id.rb +322 -76
- data/lib/standard_id/account_locking.rb +23 -10
- data/lib/standard_id/account_status.rb +22 -8
- data/lib/standard_id/api/token_manager.rb +78 -11
- data/lib/standard_id/api_engine.rb +2 -0
- data/lib/standard_id/config/callable_validator.rb +163 -0
- data/lib/standard_id/config/schema.rb +124 -5
- data/lib/standard_id/config/scope_claims_validator.rb +47 -0
- data/lib/standard_id/engine.rb +60 -1
- data/lib/standard_id/errors.rb +41 -0
- data/lib/standard_id/events/definitions.rb +9 -3
- data/lib/standard_id/events/subscribers/password_reset_delivery_subscriber.rb +36 -0
- data/lib/standard_id/jwt_service.rb +141 -3
- data/lib/standard_id/oauth/audience_profile_resolver.rb +93 -0
- data/lib/standard_id/oauth/authorization_code_authorization_flow.rb +10 -0
- data/lib/standard_id/oauth/authorization_flow.rb +8 -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 +42 -15
- data/lib/standard_id/passwordless/verification_service.rb +145 -72
- data/lib/standard_id/passwordless.rb +34 -2
- 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/web_engine.rb +2 -0
- data/lib/standard_id.rb +6 -1
- data/lib/tasks/standard_id_tasks.rake +56 -4
- metadata +15 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 9e4d6c462fcdc585b2eefb2c5224e92c7b2a9fe276c2a41ccbfd6bf7b1fee162
|
|
4
|
+
data.tar.gz: 91dde301a0e98b0ec60f704ff5beb03706f5f1dbf36ebdfddfc3e21cf322f5ea
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: f01caba2626cf9e4c8f4950aeeece214277492d74701f6359268da79cdf4234853f86a5490d33ab250dfe2cdda04e73c9c86a2896dc7ff76e3ab36e529da3cce
|
|
7
|
+
data.tar.gz: 519d061ab666d5d7a197819545bc9dcd5c304946074b80eaf89dca57898e7c24d6ccbde1b94155095e2ca382340b9179a62c2d2791331345d572ab826b88c8c7
|
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.
|
|
@@ -479,6 +529,7 @@ Every StandardId event automatically carries tracing metadata (`event_id`, `time
|
|
|
479
529
|
| | `social.account.created` | `account`, `provider`, `social_info` | When a social login creates a new account |
|
|
480
530
|
| | `social.account.linked` | `account`, `provider`, `identifier` | When a social identity links to an existing account |
|
|
481
531
|
| | `social.auth.completed` | `account`, `provider`, `tokens` | After social login completes |
|
|
532
|
+
| | `social.auth.failed` | `provider`, `error`, `error_class`, `account` | When social login fails due to an infrastructure error (HTTP/DNS/SSL/timeout) |
|
|
482
533
|
| Credential | `credential.password.created` | `credential`, `account` | After a password credential is created |
|
|
483
534
|
| | `credential.password.reset_initiated` | `credential`, `account`, `reset_token_expires_at` | After a password reset is initiated |
|
|
484
535
|
| | `credential.password.reset_completed` | `credential`, `account` | After a password reset is confirmed |
|
|
@@ -900,6 +951,60 @@ class Api::UsersController < ApiController
|
|
|
900
951
|
end
|
|
901
952
|
```
|
|
902
953
|
|
|
954
|
+
## Primitives
|
|
955
|
+
|
|
956
|
+
StandardId also ships a couple of thin, config-free JWT primitives for use cases
|
|
957
|
+
that have nothing to do with OAuth sessions — for example, service-to-service
|
|
958
|
+
tokens between two of your own backends.
|
|
959
|
+
|
|
960
|
+
### `JwtService.sign` / `JwtService.verify`
|
|
961
|
+
|
|
962
|
+
These are low-level wrappers around `JWT.encode` / `JWT.decode`. They do **not**
|
|
963
|
+
read `StandardId.config` — you supply the algorithm and key directly, and you
|
|
964
|
+
control the full payload. No `iss`, `aud`, or `iat` is added for you.
|
|
965
|
+
|
|
966
|
+
```ruby
|
|
967
|
+
# Sign an HS256 service token
|
|
968
|
+
token = StandardId::JwtService.sign(
|
|
969
|
+
{ sub: "harness", aud: "sidekick", gid: "gid://..." },
|
|
970
|
+
algorithm: "HS256",
|
|
971
|
+
key: Rails.application.credentials.dig(:service_jwt, :secret),
|
|
972
|
+
expires_in: 5.minutes
|
|
973
|
+
)
|
|
974
|
+
|
|
975
|
+
# Verify it on the receiving side
|
|
976
|
+
payload = StandardId::JwtService.verify(
|
|
977
|
+
token,
|
|
978
|
+
algorithm: "HS256",
|
|
979
|
+
key: Rails.application.credentials.dig(:service_jwt, :secret),
|
|
980
|
+
allowed_audiences: %w[sidekick]
|
|
981
|
+
)
|
|
982
|
+
payload["sub"] # => "harness"
|
|
983
|
+
```
|
|
984
|
+
|
|
985
|
+
Supports:
|
|
986
|
+
|
|
987
|
+
- HS256 / HS384 / HS512, RS256 / RS384 / RS512, ES256 / ES384 / ES512
|
|
988
|
+
- `expires_in:` to auto-set `exp` (caller-supplied `exp` wins)
|
|
989
|
+
- Arbitrary JWT headers via `**extra_headers` (e.g. `kid:`)
|
|
990
|
+
- `allowed_audiences:` to enforce the `aud` claim
|
|
991
|
+
- `key:` as a single value or an `Array` — keys are tried in order, so rotation
|
|
992
|
+
is a one-liner
|
|
993
|
+
- Failure raises `StandardId::InvalidTokenError` (with subclasses
|
|
994
|
+
`ExpiredTokenError`, `InvalidSignatureError`, `InvalidAlgorithmError`,
|
|
995
|
+
`InvalidAudienceTokenError`) — no `nil` returns
|
|
996
|
+
|
|
997
|
+
### When to use which
|
|
998
|
+
|
|
999
|
+
| Use case | API |
|
|
1000
|
+
|---|---|
|
|
1001
|
+
| OAuth 2.0 / OIDC access and ID tokens, browser/device sessions | `JwtService.encode` / `.decode` / `.decode_session` |
|
|
1002
|
+
| Anything else (service-to-service, internal signed payloads, webhooks) | `JwtService.sign` / `.verify` |
|
|
1003
|
+
|
|
1004
|
+
The OAuth/session methods consult `StandardId.config` (issuer, signing key,
|
|
1005
|
+
rotation, claim resolvers, etc.) and add standard claims automatically. The
|
|
1006
|
+
primitives deliberately do none of that.
|
|
1007
|
+
|
|
903
1008
|
## Database Schema
|
|
904
1009
|
|
|
905
1010
|
StandardId creates the following tables:
|
|
@@ -1005,9 +1110,17 @@ bundle exec rspec spec/controllers/
|
|
|
1005
1110
|
- CSRF protection enabled for web requests
|
|
1006
1111
|
- Secure session management with proper expiry
|
|
1007
1112
|
- Client secrets are rotatable with audit trail
|
|
1008
|
-
- PKCE
|
|
1113
|
+
- PKCE is enforced whenever `ClientApplication#require_pkce?` is true (the
|
|
1114
|
+
default). Public clients cannot disable it — a model-level validation
|
|
1115
|
+
rejects any public client saved with `require_pkce: false`. Authorize
|
|
1116
|
+
requests that omit `code_challenge` for a PKCE-required client are
|
|
1117
|
+
rejected with `invalid_request`.
|
|
1009
1118
|
- Rate limiting on authentication endpoints
|
|
1010
1119
|
|
|
1120
|
+
## Scheduled Maintenance
|
|
1121
|
+
|
|
1122
|
+
StandardId ships cleanup jobs (`StandardId::CleanupExpiredSessionsJob`, `StandardId::CleanupExpiredRefreshTokensJob`) and rake wrappers (`standard_id:cleanup:all`, `:sessions`, `:refresh_tokens`) to prune expired rows. See [docs/OPERATIONS.md](docs/OPERATIONS.md) for SolidQueue, sidekiq-cron, whenever, and system-cron scheduling examples.
|
|
1123
|
+
|
|
1011
1124
|
## Contributing
|
|
1012
1125
|
|
|
1013
1126
|
1. Fork the repository
|
|
@@ -3,9 +3,34 @@
|
|
|
3
3
|
module StandardId
|
|
4
4
|
# Per-controller audience verification for API endpoints.
|
|
5
5
|
#
|
|
6
|
-
#
|
|
7
|
-
#
|
|
8
|
-
#
|
|
6
|
+
# StandardId enforces audience in three layers:
|
|
7
|
+
#
|
|
8
|
+
# 1. At encode time (`Oauth::TokenGrantFlow#validate_audience!`):
|
|
9
|
+
# rejects issuance of tokens with an audience outside the global
|
|
10
|
+
# `StandardId.config.oauth.allowed_audiences` list.
|
|
11
|
+
# 2. At decode time (`JwtService.decode(..., allowed_audiences:)`):
|
|
12
|
+
# rejects tokens whose `aud` claim does not match the caller-supplied
|
|
13
|
+
# list, raising `StandardId::InvalidAudienceError`. The engine's
|
|
14
|
+
# `Api::TokenManager#verify_jwt_token` wires this automatically when
|
|
15
|
+
# `StandardId.config.oauth.allowed_audiences` is non-empty — a
|
|
16
|
+
# mismatch there is normalised to the same 401 "invalid token"
|
|
17
|
+
# response as a bad signature. Call sites that pass no arguments
|
|
18
|
+
# to `decode` directly still skip aud checks by design.
|
|
19
|
+
# 3. At the controller, via this concern: layers on top as
|
|
20
|
+
# per-endpoint defense-in-depth. Required when a controller serves
|
|
21
|
+
# a strict subset of the global allowed audiences (e.g., the global
|
|
22
|
+
# list is `%w[web api admin]` but `AdminController` must only
|
|
23
|
+
# accept `admin`).
|
|
24
|
+
#
|
|
25
|
+
# With the global decode-time check now automatic, this concern is
|
|
26
|
+
# primarily useful for tightening the allowed audience per controller,
|
|
27
|
+
# not for plugging the "controller forgot to verify aud" gap (which
|
|
28
|
+
# is closed globally when `config.oauth.allowed_audiences` is set).
|
|
29
|
+
#
|
|
30
|
+
# In addition, when `StandardId.config.oauth.audience_profile_types` is set,
|
|
31
|
+
# this concern enforces the audience → profile-type binding: after the
|
|
32
|
+
# allowed-audience check, it resolves the current account's profile for the
|
|
33
|
+
# matched audience and rejects requests whose profile type does not match.
|
|
9
34
|
#
|
|
10
35
|
# Requires StandardId::ApiAuthentication to be included before this concern
|
|
11
36
|
# (provides `verify_access_token!` and `current_session`). An error is raised
|
|
@@ -39,6 +64,7 @@ module StandardId
|
|
|
39
64
|
before_action :verify_audience!
|
|
40
65
|
|
|
41
66
|
rescue_from StandardId::InvalidAudienceError, with: :handle_invalid_audience
|
|
67
|
+
rescue_from StandardId::InvalidAudienceProfileError, with: :handle_invalid_audience
|
|
42
68
|
|
|
43
69
|
# Underscore prefix follows Rails class_attribute convention to avoid
|
|
44
70
|
# collisions with application method names.
|
|
@@ -57,10 +83,13 @@ module StandardId
|
|
|
57
83
|
|
|
58
84
|
private
|
|
59
85
|
|
|
60
|
-
# Verifies the token's `aud` claim contains at least one of the required audiences
|
|
86
|
+
# Verifies the token's `aud` claim contains at least one of the required audiences,
|
|
87
|
+
# then enforces audience → profile-type binding when configured.
|
|
61
88
|
# Supports both string and array `aud` claims.
|
|
62
89
|
#
|
|
63
90
|
# @raise [StandardId::InvalidAudienceError] when no audience matches
|
|
91
|
+
# @raise [StandardId::InvalidAudienceProfileError] when the account's
|
|
92
|
+
# profile type does not match the configured binding for the matched audience
|
|
64
93
|
def verify_audience!
|
|
65
94
|
return if _required_audiences.empty?
|
|
66
95
|
|
|
@@ -69,29 +98,109 @@ module StandardId
|
|
|
69
98
|
return unless current_session
|
|
70
99
|
|
|
71
100
|
token_audiences = Array(current_session.aud)
|
|
72
|
-
|
|
101
|
+
matched = (token_audiences & _required_audiences).first
|
|
102
|
+
|
|
103
|
+
if matched.nil?
|
|
104
|
+
raise StandardId::InvalidAudienceError.new(
|
|
105
|
+
required: _required_audiences,
|
|
106
|
+
actual: token_audiences
|
|
107
|
+
)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
enforce_audience_profile_binding!(matched, token_audiences)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Enforce `audience_profile_types[matched]` against the current account.
|
|
114
|
+
# No-op when the audience has no binding configured (back-compat).
|
|
115
|
+
def enforce_audience_profile_binding!(matched_audience, token_audiences)
|
|
116
|
+
expected_types = StandardId::Oauth::AudienceProfileResolver.profile_types_for(matched_audience)
|
|
117
|
+
return if expected_types.empty?
|
|
118
|
+
|
|
119
|
+
profile = StandardId::Oauth::AudienceProfileResolver.call(
|
|
120
|
+
account: current_account,
|
|
121
|
+
audience: matched_audience
|
|
122
|
+
)
|
|
123
|
+
actual_type = profile_type_name(profile)
|
|
124
|
+
|
|
125
|
+
return if actual_type && expected_types.include?(actual_type)
|
|
126
|
+
|
|
127
|
+
# For audit purposes, when there is no matching profile, report the
|
|
128
|
+
# first profile type the account actually has (if any) so operators can
|
|
129
|
+
# see what the client was carrying instead.
|
|
130
|
+
actual_type ||= fallback_actual_profile_type
|
|
73
131
|
|
|
74
|
-
|
|
132
|
+
error = StandardId::InvalidAudienceProfileError.new(
|
|
133
|
+
audience: matched_audience,
|
|
134
|
+
expected_profile_types: expected_types,
|
|
135
|
+
actual_profile_type: actual_type,
|
|
75
136
|
required: _required_audiences,
|
|
76
137
|
actual: token_audiences
|
|
77
138
|
)
|
|
139
|
+
emit_audience_mismatch(error)
|
|
140
|
+
raise error
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def profile_type_name(profile)
|
|
144
|
+
return nil if profile.nil?
|
|
145
|
+
return profile.profileable_type.to_s if profile.respond_to?(:profileable_type)
|
|
146
|
+
return profile.type.to_s if profile.respond_to?(:type)
|
|
147
|
+
|
|
148
|
+
profile.class.name.to_s
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# When no matching profile exists for the audience, surface the first
|
|
152
|
+
# profile type the account carries (if any) to aid debugging/audit. This
|
|
153
|
+
# never changes the decision — the mismatch is already confirmed when
|
|
154
|
+
# this is called — it only enriches the error/event payload.
|
|
155
|
+
def fallback_actual_profile_type
|
|
156
|
+
account = current_account
|
|
157
|
+
return nil if account.nil? || !account.respond_to?(:profiles)
|
|
158
|
+
|
|
159
|
+
candidates = account.profiles
|
|
160
|
+
candidates = candidates.to_a unless candidates.is_a?(Array)
|
|
161
|
+
first = candidates.first
|
|
162
|
+
profile_type_name(first)
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def emit_audience_mismatch(error)
|
|
166
|
+
StandardId::Events.publish(
|
|
167
|
+
StandardId::Events::OAUTH_AUDIENCE_MISMATCH,
|
|
168
|
+
audience: error.audience,
|
|
169
|
+
token_audiences: error.actual,
|
|
170
|
+
required_audiences: error.required,
|
|
171
|
+
expected_profile_types: error.expected_profile_types,
|
|
172
|
+
actual_profile_type: error.actual_profile_type,
|
|
173
|
+
account: (current_account if respond_to?(:current_account, true))
|
|
174
|
+
)
|
|
175
|
+
rescue StandardError => e
|
|
176
|
+
StandardId.config.logger&.warn(
|
|
177
|
+
"[StandardId] failed to emit oauth.audience.mismatch event: #{e.class}: #{e.message}"
|
|
178
|
+
)
|
|
78
179
|
end
|
|
79
180
|
|
|
80
181
|
# Returns 403 Forbidden per RFC 6750 §3.1 (insufficient_scope).
|
|
81
182
|
# Includes WWW-Authenticate header per spec, consistent with the gem's
|
|
82
183
|
# 401 handling in Api::BaseController#render_bearer_unauthorized!.
|
|
83
184
|
#
|
|
84
|
-
#
|
|
85
|
-
# error.message
|
|
86
|
-
#
|
|
185
|
+
# Both the header AND the JSON body use a static description rather
|
|
186
|
+
# than interpolating error.message, which contains raw aud values
|
|
187
|
+
# from the JWT and internal profile type names. Exposing those lets
|
|
188
|
+
# an attacker probe valid tokens across audiences to enumerate the
|
|
189
|
+
# internal profile-type taxonomy. Details are published as an
|
|
190
|
+
# OAUTH_AUDIENCE_MISMATCH audit event for server-side inspection.
|
|
87
191
|
#
|
|
88
192
|
# Override in your controller for custom error formatting.
|
|
89
|
-
|
|
193
|
+
GENERIC_INSUFFICIENT_SCOPE_MESSAGE = "The access token audience is not permitted for this resource".freeze
|
|
194
|
+
|
|
195
|
+
def handle_invalid_audience(_error)
|
|
90
196
|
response.set_header(
|
|
91
197
|
"WWW-Authenticate",
|
|
92
|
-
|
|
198
|
+
%(Bearer error="insufficient_scope", error_description="#{GENERIC_INSUFFICIENT_SCOPE_MESSAGE}")
|
|
93
199
|
)
|
|
94
|
-
render json: {
|
|
200
|
+
render json: {
|
|
201
|
+
error: "insufficient_scope",
|
|
202
|
+
error_description: GENERIC_INSUFFICIENT_SCOPE_MESSAGE
|
|
203
|
+
}, status: :forbidden
|
|
95
204
|
end
|
|
96
205
|
end
|
|
97
206
|
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)
|
|
@@ -152,12 +144,27 @@ module StandardId
|
|
|
152
144
|
end
|
|
153
145
|
|
|
154
146
|
# Determine if this is the account's first browser session.
|
|
155
|
-
#
|
|
156
|
-
#
|
|
157
|
-
#
|
|
147
|
+
# Uses `exists?` (which compiles to `SELECT 1 ... LIMIT 1`) instead of
|
|
148
|
+
# `count` — we only care whether *any other* active browser session is
|
|
149
|
+
# present, not the exact number. This short-circuits as soon as a row is
|
|
150
|
+
# found, so it's dramatically cheaper on accounts with many sessions.
|
|
151
|
+
#
|
|
152
|
+
# When called before session creation (before_sign_in), "first" means no
|
|
153
|
+
# active browser session exists at all.
|
|
154
|
+
# When called after session creation (after_sign_in), the just-created
|
|
155
|
+
# session counts as one, so "first" means no OTHER active browser session
|
|
156
|
+
# exists — i.e. exclude the current session before checking existence.
|
|
157
|
+
#
|
|
158
|
+
# Invariant: when `session_created: true`, `session_manager.current_session`
|
|
159
|
+
# is always set — invoke_after_sign_in runs immediately after
|
|
160
|
+
# session_manager.sign_in_account, which populates current_session. The
|
|
161
|
+
# nil guard below is defensive only; it would behave differently from the
|
|
162
|
+
# old `count <= 1` path (new: false, old: true) if that invariant were
|
|
163
|
+
# ever violated, but today no call site can reach it.
|
|
158
164
|
def first_sign_in?(account, session_created: true)
|
|
159
|
-
|
|
160
|
-
|
|
165
|
+
scope = account.sessions.where(type: "StandardId::BrowserSession").active
|
|
166
|
+
scope = scope.where.not(id: session_manager.current_session.id) if session_created && session_manager.current_session
|
|
167
|
+
!scope.exists?
|
|
161
168
|
end
|
|
162
169
|
|
|
163
170
|
# Handle AuthenticationDenied by revoking the session and redirecting to login.
|
|
@@ -198,13 +205,87 @@ module StandardId
|
|
|
198
205
|
end
|
|
199
206
|
end
|
|
200
207
|
|
|
208
|
+
# Resolve the active scope name for the current request.
|
|
209
|
+
# Delegates to the app-configured :scope_resolver callable so apps can source
|
|
210
|
+
# the scope from subdomains, session state, or custom path params without
|
|
211
|
+
# overriding this concern.
|
|
212
|
+
# Memoized per request.
|
|
213
|
+
def current_scope_name
|
|
214
|
+
return @current_scope_name if defined?(@current_scope_name)
|
|
215
|
+
resolver = StandardId.config.scope_resolver
|
|
216
|
+
resolver = DEFAULT_SCOPE_RESOLVER unless resolver.respond_to?(:call)
|
|
217
|
+
session = session_manager.respond_to?(:current_session) ? session_manager.current_session : nil
|
|
218
|
+
@current_scope_name = resolver.call(request: request, session: session)
|
|
219
|
+
end
|
|
220
|
+
|
|
201
221
|
# Look up the scope config for the current request.
|
|
202
|
-
# Reads :scope from route defaults (set by scoped route constraints).
|
|
203
222
|
# Returns nil when no scope is active, preserving backward compatibility.
|
|
204
223
|
# Memoized per request to avoid redundant ScopeConfig allocations.
|
|
205
224
|
def current_scope_config
|
|
206
225
|
return @current_scope_config if defined?(@current_scope_config)
|
|
207
|
-
@current_scope_config = StandardId.scope_for(
|
|
226
|
+
@current_scope_config = StandardId.scope_for(current_scope_name)
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
# Validate that the account has a profile matching one of the scope's configured
|
|
230
|
+
# profile_types, then (when configured) run the scope's custom :authorizer callable.
|
|
231
|
+
#
|
|
232
|
+
# Raises StandardId::AuthenticationDenied using the scope's no_profile_message when:
|
|
233
|
+
# - no profile of any configured type exists, or
|
|
234
|
+
# - the :authorizer returns a falsey value.
|
|
235
|
+
def validate_scope_profile!(account, scope_config)
|
|
236
|
+
resolver = StandardId.config.profile_resolver || DEFAULT_PROFILE_RESOLVER
|
|
237
|
+
matched_type = nil
|
|
238
|
+
|
|
239
|
+
if scope_config.requires_profile?
|
|
240
|
+
matched_type = scope_config.profile_types.find { |type| resolver.call(account, type) }
|
|
241
|
+
|
|
242
|
+
unless matched_type
|
|
243
|
+
raise StandardId::AuthenticationDenied, scope_config.no_profile_message
|
|
244
|
+
end
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
return unless scope_config.authorizer?
|
|
248
|
+
|
|
249
|
+
profile = matched_type ? resolve_profile_for_authorizer(account, matched_type) : nil
|
|
250
|
+
result = scope_config.authorizer.call(
|
|
251
|
+
account: account,
|
|
252
|
+
profile: profile,
|
|
253
|
+
scope: scope_config
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
unless result
|
|
257
|
+
raise StandardId::AuthenticationDenied, scope_config.no_profile_message
|
|
258
|
+
end
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
# Best-effort lookup of the matched profile record to pass into the :authorizer.
|
|
262
|
+
# Returns nil when the account does not expose a :profiles association of the
|
|
263
|
+
# expected shape — authorizers that need richer context can re-query from the
|
|
264
|
+
# account keyword arg.
|
|
265
|
+
#
|
|
266
|
+
# Rescue is narrowed to the structural cases we actually want to tolerate
|
|
267
|
+
# (missing methods / wrong types on a shape-mismatched association). DB-level
|
|
268
|
+
# errors are intentionally allowed to propagate so a transient outage isn't
|
|
269
|
+
# silently converted into "no profile found" and a denied sign-in.
|
|
270
|
+
def resolve_profile_for_authorizer(account, profile_type)
|
|
271
|
+
return nil unless account.respond_to?(:profiles)
|
|
272
|
+
relation = account.profiles
|
|
273
|
+
return nil unless relation.respond_to?(:find_by)
|
|
274
|
+
relation.find_by(profileable_type: profile_type)
|
|
275
|
+
rescue NoMethodError, TypeError
|
|
276
|
+
nil
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
# Build the hash of scope fields merged into lifecycle hook context.
|
|
280
|
+
# Includes the legacy :profile_type (singular) alongside :profile_types (plural)
|
|
281
|
+
# so existing hooks keep reading the same key.
|
|
282
|
+
def scope_context(scope_config)
|
|
283
|
+
{
|
|
284
|
+
scope: scope_config.name,
|
|
285
|
+
profile_type: scope_config.profile_type,
|
|
286
|
+
profile_types: scope_config.profile_types,
|
|
287
|
+
after_sign_in_path: scope_config.after_sign_in_path
|
|
288
|
+
}
|
|
208
289
|
end
|
|
209
290
|
end
|
|
210
291
|
end
|
|
@@ -200,5 +200,24 @@ module StandardId
|
|
|
200
200
|
source: "social"
|
|
201
201
|
)
|
|
202
202
|
end
|
|
203
|
+
|
|
204
|
+
# Emit SOCIAL_AUTH_FAILED for infrastructure-level failures during the
|
|
205
|
+
# social authentication flow (HTTP errors, DNS/SSL/timeouts surfaced as
|
|
206
|
+
# OAuthError by provider implementations).
|
|
207
|
+
#
|
|
208
|
+
# Host apps can subscribe to this event to forward failures to Sentry or
|
|
209
|
+
# similar observability tools without monkey-patching the controller.
|
|
210
|
+
#
|
|
211
|
+
# @param error [StandardId::OAuthError] the captured failure
|
|
212
|
+
# @param account [Object, nil] the account if one was resolved before the failure
|
|
213
|
+
def emit_social_auth_failed(error, account: nil)
|
|
214
|
+
StandardId::Events.publish(
|
|
215
|
+
StandardId::Events::SOCIAL_AUTH_FAILED,
|
|
216
|
+
provider: provider&.provider_name,
|
|
217
|
+
error: error.message,
|
|
218
|
+
error_class: error.class.name,
|
|
219
|
+
account: account
|
|
220
|
+
)
|
|
221
|
+
end
|
|
203
222
|
end
|
|
204
223
|
end
|
|
@@ -29,15 +29,64 @@ module StandardId
|
|
|
29
29
|
# revocation via sub claim regardless of token type (RFC 7009 §2.1)
|
|
30
30
|
revoked_sessions = sessions.to_a
|
|
31
31
|
if revoked_sessions.any?
|
|
32
|
+
now = Time.current
|
|
33
|
+
session_ids = revoked_sessions.map(&:id)
|
|
34
|
+
|
|
35
|
+
# Bulk-revoke in two queries (one UPDATE per table) instead of
|
|
36
|
+
# issuing session.revoke! per row, which would be O(N) UPDATEs plus
|
|
37
|
+
# another O(N) cascades to refresh_tokens.
|
|
38
|
+
#
|
|
39
|
+
# Tradeoff: update_all skips ActiveRecord callbacks, so the per-row
|
|
40
|
+
# SESSION_REVOKED event emitted by Session#revoke! is not fired
|
|
41
|
+
# automatically. We re-emit it explicitly below so audit-trail
|
|
42
|
+
# subscribers (account status/locking, etc.) still see one event
|
|
43
|
+
# per revoked session — the semantics are preserved, only the SQL
|
|
44
|
+
# shape has changed.
|
|
32
45
|
ActiveRecord::Base.transaction do
|
|
33
|
-
|
|
46
|
+
StandardId::Session.where(id: session_ids).update_all(revoked_at: now)
|
|
47
|
+
StandardId::RefreshToken
|
|
48
|
+
.where(session_id: session_ids, revoked_at: nil)
|
|
49
|
+
.update_all(revoked_at: now)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# DB state is already committed above; event publishing is best-effort
|
|
53
|
+
# audit emission. A failing subscriber must not short-circuit the loop
|
|
54
|
+
# and leave later sessions without their SESSION_REVOKED event, which
|
|
55
|
+
# would permanently desync audit-trail consumers from the DB.
|
|
56
|
+
#
|
|
57
|
+
# All revoked_sessions share the same account_id (we filtered by it
|
|
58
|
+
# at line 25), so we load the account once rather than calling
|
|
59
|
+
# session.account per row, which would issue N extra SELECTs.
|
|
60
|
+
shared_account = revoked_sessions.first.account
|
|
61
|
+
revoked_sessions.each do |session|
|
|
62
|
+
session.revoked_at = now
|
|
63
|
+
begin
|
|
64
|
+
StandardId::Events.publish(
|
|
65
|
+
StandardId::Events::SESSION_REVOKED,
|
|
66
|
+
session: session,
|
|
67
|
+
account: shared_account,
|
|
68
|
+
reason: "token_revocation"
|
|
69
|
+
)
|
|
70
|
+
rescue StandardError => e
|
|
71
|
+
StandardId.logger.error(
|
|
72
|
+
"[StandardId::Revocations] Failed to publish SESSION_REVOKED " \
|
|
73
|
+
"for session #{session.id}: #{e.class}: #{e.message}"
|
|
74
|
+
)
|
|
75
|
+
end
|
|
34
76
|
end
|
|
35
77
|
|
|
36
|
-
|
|
37
|
-
StandardId::Events
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
78
|
+
begin
|
|
79
|
+
StandardId::Events.publish(
|
|
80
|
+
StandardId::Events::OAUTH_TOKEN_REVOKED,
|
|
81
|
+
account_id: account_id,
|
|
82
|
+
sessions_revoked: revoked_sessions.size
|
|
83
|
+
)
|
|
84
|
+
rescue StandardError => e
|
|
85
|
+
StandardId.logger.error(
|
|
86
|
+
"[StandardId::Revocations] Failed to publish OAUTH_TOKEN_REVOKED " \
|
|
87
|
+
"for account #{account_id}: #{e.class}: #{e.message}"
|
|
88
|
+
)
|
|
89
|
+
end
|
|
41
90
|
end
|
|
42
91
|
|
|
43
92
|
head :ok
|
|
@@ -61,7 +61,13 @@ module StandardId
|
|
|
61
61
|
redirect_to destination, redirect_options
|
|
62
62
|
rescue StandardId::AuthenticationDenied => e
|
|
63
63
|
handle_authentication_denied(e, account: account, newly_created: newly_created)
|
|
64
|
+
rescue StandardId::SocialLinkError => e
|
|
65
|
+
# Policy/link error — SOCIAL_LINK_BLOCKED has already been emitted
|
|
66
|
+
# by validate_social_link!, so do not also emit SOCIAL_AUTH_FAILED
|
|
67
|
+
# (which is reserved for infrastructure-level failures).
|
|
68
|
+
redirect_to StandardId::WebEngine.routes.url_helpers.login_path(redirect_uri: state_data&.dig("redirect_uri")), alert: "Authentication failed: #{e.message}"
|
|
64
69
|
rescue StandardId::OAuthError => e
|
|
70
|
+
emit_social_auth_failed(e, account: account)
|
|
65
71
|
redirect_to StandardId::WebEngine.routes.url_helpers.login_path(redirect_uri: state_data&.dig("redirect_uri")), alert: "Authentication failed: #{e.message}"
|
|
66
72
|
end
|
|
67
73
|
end
|