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.
Files changed (53) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +114 -1
  3. data/app/controllers/concerns/standard_id/audience_verification.rb +121 -12
  4. data/app/controllers/concerns/standard_id/lifecycle_hooks.rb +117 -36
  5. data/app/controllers/concerns/standard_id/social_authentication.rb +19 -0
  6. data/app/controllers/standard_id/api/oauth/revocations_controller.rb +55 -6
  7. data/app/controllers/standard_id/web/auth/callback/providers_controller.rb +6 -0
  8. data/app/controllers/standard_id/web/reset_password/start_controller.rb +27 -1
  9. data/app/controllers/standard_id/web/verify_email/start_controller.rb +1 -7
  10. data/app/controllers/standard_id/web/verify_phone/start_controller.rb +1 -7
  11. data/app/forms/standard_id/web/reset_password_start_form.rb +20 -19
  12. data/app/jobs/standard_id/cleanup_expired_authorization_codes_job.rb +51 -0
  13. data/app/jobs/standard_id/cleanup_expired_code_challenges_job.rb +45 -0
  14. data/app/jobs/standard_id/password_reset_delivery_job.rb +42 -0
  15. data/app/mailers/standard_id/password_reset_mailer.rb +16 -0
  16. data/app/models/concerns/standard_id/account_associations.rb +43 -0
  17. data/app/models/standard_id/authorization_code.rb +15 -2
  18. data/app/models/standard_id/client_application.rb +78 -3
  19. data/app/models/standard_id/code_challenge.rb +6 -1
  20. data/app/views/standard_id/password_reset_mailer/reset_email.html.erb +27 -0
  21. data/app/views/standard_id/password_reset_mailer/reset_email.text.erb +12 -0
  22. data/db/migrate/20260416180511_add_partial_indexes_for_active_session_and_challenge_lookups.rb +117 -0
  23. data/lib/generators/standard_id/install/install_generator.rb +174 -1
  24. data/lib/generators/standard_id/install/templates/standard_id.rb +322 -76
  25. data/lib/standard_id/account_locking.rb +23 -10
  26. data/lib/standard_id/account_status.rb +22 -8
  27. data/lib/standard_id/api/token_manager.rb +78 -11
  28. data/lib/standard_id/api_engine.rb +2 -0
  29. data/lib/standard_id/config/callable_validator.rb +163 -0
  30. data/lib/standard_id/config/schema.rb +124 -5
  31. data/lib/standard_id/config/scope_claims_validator.rb +47 -0
  32. data/lib/standard_id/engine.rb +60 -1
  33. data/lib/standard_id/errors.rb +41 -0
  34. data/lib/standard_id/events/definitions.rb +9 -3
  35. data/lib/standard_id/events/subscribers/password_reset_delivery_subscriber.rb +36 -0
  36. data/lib/standard_id/jwt_service.rb +141 -3
  37. data/lib/standard_id/oauth/audience_profile_resolver.rb +93 -0
  38. data/lib/standard_id/oauth/authorization_code_authorization_flow.rb +10 -0
  39. data/lib/standard_id/oauth/authorization_flow.rb +8 -0
  40. data/lib/standard_id/oauth/oauth_session_persistence.rb +81 -0
  41. data/lib/standard_id/oauth/token_grant_flow.rb +57 -1
  42. data/lib/standard_id/otp.rb +250 -0
  43. data/lib/standard_id/passwordless/base_strategy.rb +42 -15
  44. data/lib/standard_id/passwordless/verification_service.rb +145 -72
  45. data/lib/standard_id/passwordless.rb +34 -2
  46. data/lib/standard_id/scope_config.rb +79 -4
  47. data/lib/standard_id/session_type_resolver.rb +102 -0
  48. data/lib/standard_id/version.rb +1 -1
  49. data/lib/standard_id/web/token_manager.rb +42 -4
  50. data/lib/standard_id/web_engine.rb +2 -0
  51. data/lib/standard_id.rb +6 -1
  52. data/lib/tasks/standard_id_tasks.rake +56 -4
  53. metadata +15 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 47ce66e4e4b242fd96a90cda7b20a9316e31642551ebe6c94ae660336f8f0b2c
4
- data.tar.gz: a1a6d6f746f2ef0590aec0b5d6c4cc51472e04030af53e30d8546495d2556f03
3
+ metadata.gz: 9e4d6c462fcdc585b2eefb2c5224e92c7b2a9fe276c2a41ccbfd6bf7b1fee162
4
+ data.tar.gz: 91dde301a0e98b0ec60f704ff5beb03706f5f1dbf36ebdfddfc3e21cf322f5ea
5
5
  SHA512:
6
- metadata.gz: f323ce02474f8d4fc98a8aa4466616f15e58b9a5f6174336c91c0f9bc02c22e95862909963d0470271b49dc588fb0abcfcd2434c17a7ece79952d7547ed81ddf
7
- data.tar.gz: 03a24d819ad4f699be3d97ce26e8dcf04873b373ff55c03e31f0de475a014198053a1927bf6539de40c0398855490be01ea4bc77dbe093d1f381f67eddfb8608
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 support for public clients
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
- # While StandardId validates that the JWT `aud` claim is in the global
7
- # `allowed_audiences` list, this concern provides additional defense-in-depth
8
- # by restricting which audiences are accepted by each controller.
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
- return if (token_audiences & _required_audiences).any?
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
- raise StandardId::InvalidAudienceError.new(
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
- # 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.
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
- def handle_invalid_audience(error)
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
- 'Bearer error="insufficient_scope", error_description="The access token audience is not permitted for this resource"'
198
+ %(Bearer error="insufficient_scope", error_description="#{GENERIC_INSUFFICIENT_SCOPE_MESSAGE}")
93
199
  )
94
- render json: { error: "insufficient_scope", error_description: error.message }, status: :forbidden
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] 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)
@@ -152,12 +144,27 @@ module StandardId
152
144
  end
153
145
 
154
146
  # Determine if this is the account's first browser session.
155
- # When called before session creation (before_sign_in), count == 0 means first.
156
- # When called after session creation (after_sign_in), count <= 1 means first
157
- # (the just-created session is the only one).
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
- active_count = account.sessions.where(type: "StandardId::BrowserSession").active.count
160
- session_created ? active_count <= 1 : active_count == 0
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(request.path_parameters[:scope])
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
- revoked_sessions.each { |session| session.revoke!(reason: "token_revocation") }
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
- StandardId::Events.publish(
37
- StandardId::Events::OAUTH_TOKEN_REVOKED,
38
- account_id: account_id,
39
- sessions_revoked: revoked_sessions.size
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