standard_id 0.44.0 → 0.46.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 (42) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +55 -0
  3. data/README.md +167 -3
  4. data/app/controllers/concerns/standard_id/lifecycle_hooks.rb +12 -19
  5. data/app/controllers/concerns/standard_id/social_authentication.rb +267 -13
  6. data/app/controllers/concerns/standard_id/web/social_login_params.rb +6 -2
  7. data/app/controllers/concerns/standard_id/web_authentication.rb +6 -1
  8. data/app/controllers/standard_id/api/authorization_controller.rb +11 -4
  9. data/app/controllers/standard_id/api/base_controller.rb +16 -0
  10. data/app/controllers/standard_id/api/oauth/callback/providers_controller.rb +72 -16
  11. data/app/controllers/standard_id/web/auth/callback/providers_controller.rb +69 -6
  12. data/app/controllers/standard_id/web/consent_controller.rb +5 -1
  13. data/app/controllers/standard_id/web/login_controller.rb +8 -0
  14. data/app/controllers/standard_id/web/login_verify_controller.rb +6 -1
  15. data/app/controllers/standard_id/web/signup_controller.rb +6 -1
  16. data/db/migrate/20261004000000_add_auth_method_to_standard_id_refresh_tokens.rb +17 -0
  17. data/lib/generators/standard_id/install/templates/standard_id.rb +13 -0
  18. data/lib/standard_id/account_cleanup.rb +147 -0
  19. data/lib/standard_id/api/token_manager.rb +18 -2
  20. data/lib/standard_id/auth_lineage.rb +75 -0
  21. data/lib/standard_id/config/callable_validator.rb +5 -0
  22. data/lib/standard_id/config/schema.rb +14 -0
  23. data/lib/standard_id/errors.rb +55 -0
  24. data/lib/standard_id/events/definitions.rb +6 -1
  25. data/lib/standard_id/events/subscribers/logging_subscriber.rb +1 -0
  26. data/lib/standard_id/login_method_policy.rb +108 -0
  27. data/lib/standard_id/oauth/authorization_code_authorization_flow.rb +2 -1
  28. data/lib/standard_id/oauth/authorization_code_flow.rb +6 -0
  29. data/lib/standard_id/oauth/authorization_flow.rb +6 -2
  30. data/lib/standard_id/oauth/password_flow.rb +13 -2
  31. data/lib/standard_id/oauth/passwordless_otp_flow.rb +4 -0
  32. data/lib/standard_id/oauth/refresh_token_flow.rb +32 -0
  33. data/lib/standard_id/oauth/social_flow.rb +4 -0
  34. data/lib/standard_id/oauth/subflows/social_login_grant.rb +6 -0
  35. data/lib/standard_id/oauth/subflows/traditional_code_grant.rb +6 -1
  36. data/lib/standard_id/oauth/token_grant_flow.rb +54 -2
  37. data/lib/standard_id/providers/base.rb +87 -2
  38. data/lib/standard_id/version.rb +1 -1
  39. data/lib/standard_id/web/session_manager.rb +50 -3
  40. data/lib/standard_id/web/token_manager.rb +8 -3
  41. data/lib/standard_id.rb +3 -0
  42. metadata +5 -1
@@ -25,10 +25,11 @@ module StandardId
25
25
  state_data = nil
26
26
 
27
27
  begin
28
- extract_state_and_nonce => { state_data:, nonce: }
28
+ extract_state_and_nonce => { state_data:, nonce:, code_verifier: }
29
+ code_verifier = pkce_verifier_for_provider!(code_verifier)
29
30
  caller_redirect_uri = state_data&.dig("redirect_uri").presence
30
31
  redirect_uri = callback_url_for
31
- provider_response = get_user_info_from_provider(redirect_uri:, nonce:)
32
+ provider_response = get_user_info_from_provider(redirect_uri:, nonce:, code_verifier:)
32
33
  social_info = provider_response[:user_info]
33
34
  provider_tokens = provider_response[:tokens]
34
35
  begin
@@ -40,7 +41,13 @@ module StandardId
40
41
  newly_created = account.previously_new_record?
41
42
 
42
43
  invoke_before_sign_in(account, { mechanism: "social", provider: provider.provider_name })
43
- session_manager.sign_in_account(account, scope_name: state_data&.dig("scope"))
44
+ session_manager.sign_in_account(
45
+ account,
46
+ scope_name: state_data&.dig("scope"),
47
+ auth_method: :social,
48
+ provider: provider.provider_name,
49
+ flow: :web_social
50
+ )
44
51
 
45
52
  provider_name = provider.provider_name
46
53
  invoke_after_account_created(account, { mechanism: "social", provider: provider_name }) if newly_created
@@ -68,8 +75,14 @@ module StandardId
68
75
  destination = redirect_override || (safe_destination?(caller_redirect_uri) ? caller_redirect_uri : safe_post_signin_default)
69
76
  redirect_options = { notice: "Successfully signed in with #{provider_name.humanize}" }
70
77
  redirect_options[:allow_other_host] = true if allow_other_host_redirect?(destination)
71
- redirect_to destination, redirect_options
78
+
79
+ # Accepted: only now write the (provider, sub) link, in the same
80
+ # transaction as the redirect, which can still raise (e.g. an
81
+ # after_sign_in URL on a host that is not allowed). If it does,
82
+ # the link is rolled back with it.
83
+ commit_social_link! { redirect_to destination, redirect_options }
72
84
  rescue StandardId::AuthenticationDenied => e
85
+ rollback_social_link!
73
86
  handle_authentication_denied(e, account: account, newly_created: newly_created)
74
87
  rescue StandardId::SocialLinkError => e
75
88
  # Policy/link error — SOCIAL_LINK_BLOCKED has already been emitted
@@ -77,8 +90,23 @@ module StandardId
77
90
  # (which is reserved for infrastructure-level failures).
78
91
  redirect_to StandardId::WebEngine.routes.url_helpers.login_path(redirect_uri: state_data&.dig("redirect_uri")), alert: "Authentication failed: #{e.message}"
79
92
  rescue StandardId::OAuthError => e
80
- emit_social_auth_failed(e, account: account)
93
+ discard_rejected_social_sign_in!(account, newly_created:)
94
+ # A (provider, sub) conflict with a concurrent login is a link
95
+ # refusal (SOCIAL_LINK_BLOCKED, already published), not an
96
+ # infrastructure failure.
97
+ emit_social_auth_failed(e, account: account) unless e.is_a?(StandardId::SocialLinkConflictError)
81
98
  redirect_to StandardId::WebEngine.routes.url_helpers.login_path(redirect_uri: state_data&.dig("redirect_uri")), alert: "Authentication failed: #{e.message}"
99
+ rescue StandardError => e
100
+ # Unexpected failure after the link/account may have been
101
+ # written: undo them, then let the error surface as before —
102
+ # unless the matched account was removed under this login by a
103
+ # concurrent, refused request, which is retryable.
104
+ removed = social_account_removed_concurrently?(account, newly_created:)
105
+ discard_rejected_social_sign_in!(account, newly_created:)
106
+ raise unless removed
107
+
108
+ emit_social_auth_failed(e, account: account)
109
+ redirect_to StandardId::WebEngine.routes.url_helpers.login_path(redirect_uri: state_data&.dig("redirect_uri")), alert: "Authentication failed: #{SOCIAL_RETRY_MESSAGE}"
82
110
  end
83
111
  end
84
112
 
@@ -103,6 +131,27 @@ module StandardId
103
131
 
104
132
  private
105
133
 
134
+ # A rejection after find_or_create_account_from_social leaves no
135
+ # link, no new account and no session — including one that
136
+ # sign_in_account created before raising (a failing SESSION_CREATED
137
+ # subscriber), which is why this reads session_manager.created_session
138
+ # rather than relying on sign_in_account having returned.
139
+ # AuthenticationDenied has its own path (handle_authentication_denied);
140
+ # SocialLinkError is raised before anything is written.
141
+ def discard_rejected_social_sign_in!(account, newly_created:)
142
+ created = session_manager.created_session
143
+ if created
144
+ created.revoke!(reason: "social_sign_in_rejected") unless created.revoked?
145
+ session_manager.clear_session!
146
+ end
147
+ discard_social_attempt!(account, newly_created: newly_created, sessions: [created])
148
+ end
149
+
150
+ # Write the (provider, sub) link only once the login is accepted.
151
+ def defer_social_link?
152
+ true
153
+ end
154
+
106
155
  def callback_url_for
107
156
  "#{request.base_url}#{provider.callback_path}"
108
157
  end
@@ -120,10 +169,24 @@ module StandardId
120
169
 
121
170
  {
122
171
  state_data: oauth_state["params"],
123
- nonce: oauth_state["nonce"]
172
+ nonce: oauth_state["nonce"],
173
+ code_verifier: oauth_state["code_verifier"].presence
124
174
  }
125
175
  end
126
176
 
177
+ # Core-managed PKCE (Providers::Base.supports_pkce?): the provider
178
+ # receives the verifier stored with this flow's state, and only
179
+ # then. A PKCE provider's flow must have been started with one (by
180
+ # /login for this provider), so a missing verifier — a state issued
181
+ # for another provider, or stored before the provider opted in — is
182
+ # refused rather than the code exchanged without it.
183
+ def pkce_verifier_for_provider!(code_verifier)
184
+ return nil unless provider.supports_pkce?
185
+ raise StandardId::InvalidRequestError, "Missing PKCE verifier for this sign-in" if code_verifier.nil?
186
+
187
+ code_verifier
188
+ end
189
+
127
190
  def handle_callback_error
128
191
  error_message = case params[:error]
129
192
  when "access_denied"
@@ -81,7 +81,11 @@ module StandardId
81
81
  )
82
82
 
83
83
  result = StandardId::Oauth::AuthorizationCodeAuthorizationFlow
84
- .new(@consent_request, request, current_account: current_account)
84
+ .new(
85
+ @consent_request, request,
86
+ current_account: current_account,
87
+ auth_lineage: StandardId::AuthLineage.from_session(current_session)
88
+ )
85
89
  .execute
86
90
 
87
91
  redirect_out(result[:redirect_to], status: result[:status] || :found)
@@ -133,10 +133,12 @@ module StandardId
133
133
 
134
134
  state = generate_oauth_token
135
135
  nonce = provider_supports_nonce?(provider) ? generate_oauth_token : nil
136
+ code_verifier = provider.supports_pkce? ? generate_oauth_token : nil
136
137
 
137
138
  store_oauth_request(
138
139
  state:,
139
140
  nonce:,
141
+ code_verifier:,
140
142
  params: extract_social_login_params
141
143
  )
142
144
 
@@ -146,6 +148,12 @@ module StandardId
146
148
  # Add nonce to OAuth params if provider supports it
147
149
  extra_params[:nonce] = nonce if nonce.present?
148
150
 
151
+ # Core-managed PKCE: only the S256 challenge leaves the server.
152
+ if code_verifier
153
+ extra_params[:code_challenge] = provider.pkce_s256_challenge(code_verifier)
154
+ extra_params[:code_challenge_method] = "S256"
155
+ end
156
+
149
157
  url = provider.authorization_url(
150
158
  state:,
151
159
  redirect_uri: callback_url,
@@ -52,7 +52,12 @@ module StandardId
52
52
 
53
53
  invoke_before_sign_in(account, { mechanism: "passwordless", provider: nil })
54
54
 
55
- session_manager.sign_in_account(account, scope_name: request.path_parameters[:scope])
55
+ session_manager.sign_in_account(
56
+ account,
57
+ scope_name: request.path_parameters[:scope],
58
+ auth_method: :passwordless,
59
+ flow: :web_passwordless
60
+ )
56
61
  emit_authentication_succeeded(account)
57
62
 
58
63
  if newly_created
@@ -47,7 +47,12 @@ module StandardId
47
47
 
48
48
  if form.submit
49
49
  invoke_before_sign_in(form.account, { mechanism: "password", provider: nil })
50
- session_manager.sign_in_account(form.account, scope_name: request.path_parameters[:scope])
50
+ session_manager.sign_in_account(
51
+ form.account,
52
+ scope_name: request.path_parameters[:scope],
53
+ auth_method: :password,
54
+ flow: :web_signup
55
+ )
51
56
  invoke_after_account_created(form.account, { mechanism: "signup", provider: nil })
52
57
 
53
58
  redirect_uri = string_param(:redirect_uri)
@@ -0,0 +1,17 @@
1
+ # Records how the authentication behind a refresh token was established
2
+ # (`auth_method`: "password", "passwordless", "social", ...; `auth_provider`:
3
+ # the social provider's name, or NULL), so `config.login_method_policy` can be
4
+ # consulted again on the `refresh_token` grant with the ORIGINAL method. Each
5
+ # rotated token copies the values from its predecessor. See
6
+ # StandardId::AuthLineage.
7
+ #
8
+ # Two nullable columns, no default, no index, no backfill: metadata-only on
9
+ # PostgreSQL, so safe on a large table and under StrongMigrations. Rows minted
10
+ # before this ran keep NULL and reach the policy as `:unspecified`. Until the
11
+ # migration has run the gem simply does not write the columns.
12
+ class AddAuthMethodToStandardIdRefreshTokens < ActiveRecord::Migration[8.0]
13
+ def change
14
+ add_column :standard_id_refresh_tokens, :auth_method, :string
15
+ add_column :standard_id_refresh_tokens, :auth_provider, :string
16
+ end
17
+ end
@@ -403,6 +403,19 @@ StandardId.configure do |c|
403
403
  # context[:first_sign_in] ? "/onboarding" : nil
404
404
  # }
405
405
 
406
+ # Decides which authentication methods may sign an account in. Consulted in
407
+ # EVERY web and API flow that establishes a new authentication, after the
408
+ # credential is proven and before any session or token is created.
409
+ # Receives any subset of: account:, auth_method: (:password, :passwordless,
410
+ # :social, :remember_me, :unspecified), provider:, request:, flow:
411
+ # Return truthy to allow, false/nil to refuse, or raise
412
+ # StandardId::LoginMethodDenied.new("message") to refuse with a message.
413
+ # Default: nil (every method allowed)
414
+ # c.login_method_policy = ->(account:, auth_method:, provider:) {
415
+ # next true unless account.staff?
416
+ # auth_method == :social && provider == "void_which_binds"
417
+ # }
418
+
406
419
  # Called after a new account is created via any mechanism.
407
420
  # Receives: (account, request, context)
408
421
  # context = { mechanism:, provider: }
@@ -0,0 +1,147 @@
1
+ module StandardId
2
+ # Removes an account that was created during the current request and then
3
+ # refused sign-in (by a lifecycle hook or the login-method policy), so the
4
+ # refusal does not leave an orphaned account behind.
5
+ #
6
+ # Every association read goes through `.strict_loading(false)`: the account
7
+ # was built in this request, so none of its associations are loaded, and a
8
+ # host running `strict_loading_by_default = true` (most consumers) would
9
+ # otherwise raise StrictLoadingViolationError on `account.sessions` — turning
10
+ # a clean rejection of a new signup into a 500 and leaving the orphaned
11
+ # account behind. Relation-level `strict_loading(false)` also covers the
12
+ # records it loads, so `identifier.credentials` below is safe too.
13
+ #
14
+ # The account is visible to other requests from the moment it is created,
15
+ # so a concurrent sign-in can find it (by email) and sign in to it before
16
+ # this request is refused. The removal therefore takes a row lock on the
17
+ # account and skips it when the account has been adopted — it has an
18
+ # active session or an unrevoked refresh token, which this request's own
19
+ # rejection paths revoke before calling here. The social callbacks take
20
+ # the same lock before they write a link (SocialAuthentication#commit_social_link!)
21
+ # and fail when the account is gone, so either the other login completes
22
+ # first and the account stays, or the removal goes first and the other
23
+ # login fails and can be retried (creating a fresh account). Nothing waits
24
+ # on a doomed account, and nothing signs in to one.
25
+ #
26
+ # An account kept that way is kept FOR the adopters it found: the session
27
+ # and refresh token ids that were active, remembered in
28
+ # StandardId.cache_store. When such an adopter's own request then fails
29
+ # too, it revokes what it issued and calls reclaim_for_failed_adopter!,
30
+ # which removes the account after all unless it is still adopted (by
31
+ # someone else, who is then remembered in turn). Only a failing request
32
+ # that issued one of the remembered credentials can trigger the removal,
33
+ # so an adopter that went on to succeed is never undone by a later,
34
+ # unrelated failure. With a cache store that does not share entries
35
+ # across processes (or the null store) nothing is remembered and the kept
36
+ # account stays, as before.
37
+ module AccountCleanup
38
+ module_function
39
+
40
+ # How long a kept account remembers its adopters: comfortably longer than
41
+ # the adopting request can take to finish.
42
+ KEPT_FOR_ADOPTERS_TTL = 1.hour
43
+
44
+ # @return [Boolean] true when the account was removed, false when it was
45
+ # already gone or another login has adopted it.
46
+ def destroy_newly_created!(account)
47
+ return false unless account&.persisted?
48
+
49
+ ActiveRecord::Base.transaction do
50
+ next false if account.class.lock.where(id: account.id).pick(:id).nil?
51
+
52
+ if adopted?(account)
53
+ remember_adopters!(account)
54
+ Rails.logger&.info("[StandardId] Kept refused new account #{account.id}: a concurrent sign-in is using it")
55
+ next false
56
+ end
57
+
58
+ remove!(account)
59
+ end
60
+ end
61
+
62
+ # Called by a sign-in that matched an EXISTING account and then failed,
63
+ # with the sessions / refresh tokens it issued (already revoked). When a
64
+ # refused request kept that account only because of this sign-in (see
65
+ # destroy_newly_created!), the account is removed after all, unless
66
+ # something else has adopted it since. Anything else is left alone.
67
+ #
68
+ # @return [Boolean] true when the account was removed.
69
+ def reclaim_for_failed_adopter!(account, sessions: [], refresh_tokens: [])
70
+ return false unless account&.persisted?
71
+
72
+ issued = credential_keys(Array(sessions).compact.map(&:id), Array(refresh_tokens).compact.map(&:id))
73
+ return false if issued.empty?
74
+
75
+ ActiveRecord::Base.transaction do
76
+ next false if account.class.lock.where(id: account.id).pick(:id).nil?
77
+
78
+ kept_for = Array(read_kept_for(account))
79
+ next false if (kept_for & issued).empty?
80
+
81
+ if adopted?(account)
82
+ remember_adopters!(account)
83
+ next false
84
+ end
85
+
86
+ Rails.logger&.info("[StandardId] Removing refused new account #{account.id}: the sign-in it was kept for failed")
87
+ remove!(account)
88
+ end
89
+ end
90
+
91
+ # Another request signed in to the account. The refusing request has
92
+ # already revoked whatever session / refresh token it issued itself.
93
+ def adopted?(account)
94
+ return true if account.sessions.strict_loading(false).active.exists?
95
+
96
+ StandardId::RefreshToken.active.where(account_id: account.id).exists?
97
+ end
98
+
99
+ def remove!(account)
100
+ # Tokens first: refresh_tokens.account_id has no ON DELETE action, and
101
+ # a refused API grant may already have issued one for this account.
102
+ StandardId::RefreshToken.where(account_id: account.id).delete_all
103
+ account.sessions.strict_loading(false).destroy_all
104
+ identifiers = account.identifiers.strict_loading(false).to_a
105
+ identifiers.each { |i| i.credentials.strict_loading(false).destroy_all }
106
+ # Deliberately destroy! (unlike the destroy_all calls above): a failed identifier destroy raises and rolls back the whole cleanup, failing loud instead of leaving a half-cleaned orphan.
107
+ identifiers.each(&:destroy!)
108
+ account.destroy
109
+ forget_kept_for(account)
110
+ true
111
+ end
112
+
113
+ # Remembers the adopters the account is being kept for. Best effort: the
114
+ # cache is not part of the transaction, and a cache failure only means
115
+ # the kept account is not reclaimed later.
116
+ def remember_adopters!(account)
117
+ session_ids = account.sessions.strict_loading(false).active.pluck(:id)
118
+ token_ids = StandardId::RefreshToken.active.where(account_id: account.id).pluck(:id)
119
+ StandardId.cache_store&.write(kept_for_key(account), credential_keys(session_ids, token_ids), expires_in: KEPT_FOR_ADOPTERS_TTL)
120
+ rescue StandardError => e
121
+ Rails.logger&.warn("[StandardId] Could not remember the adopters of kept account #{account.id}: #{e.class}: #{e.message}")
122
+ end
123
+
124
+ def read_kept_for(account)
125
+ StandardId.cache_store&.read(kept_for_key(account))
126
+ rescue StandardError => e
127
+ Rails.logger&.warn("[StandardId] Could not read the adopters of kept account #{account.id}: #{e.class}: #{e.message}")
128
+ nil
129
+ end
130
+
131
+ def forget_kept_for(account)
132
+ StandardId.cache_store&.delete(kept_for_key(account))
133
+ rescue StandardError
134
+ nil
135
+ end
136
+
137
+ def kept_for_key(account)
138
+ "standard_id:account_cleanup:kept_for:#{account.class.name}:#{account.id}"
139
+ end
140
+
141
+ def credential_keys(session_ids, refresh_token_ids)
142
+ session_ids.map { |id| "session:#{id}" } + refresh_token_ids.map { |id| "refresh_token:#{id}" }
143
+ end
144
+
145
+ private_class_method :remove!, :remember_adopters!, :read_kept_for, :forget_kept_for, :kept_for_key, :credential_keys
146
+ end
147
+ end
@@ -7,7 +7,18 @@ module StandardId
7
7
  @request = request
8
8
  end
9
9
 
10
- def create_device_session(account, device_id: nil, device_agent: nil)
10
+ # Host-facing primitives: the engine itself never calls these two, so
11
+ # whoever does has authenticated `account` on its own. They still go
12
+ # through `config.login_method_policy` (flows :api_device_session /
13
+ # :api_service_session) so no session-creating entry point bypasses it;
14
+ # pass `auth_method:` (and `provider:`) so a policy can tell the method
15
+ # apart — without it the policy sees `:unspecified`.
16
+ def create_device_session(account, device_id: nil, device_agent: nil, auth_method: nil, provider: nil)
17
+ StandardId::LoginMethodPolicy.enforce!(
18
+ account: account, auth_method: auth_method, provider: provider,
19
+ request: @request, flow: :api_device_session
20
+ )
21
+
11
22
  session_class = StandardId::SessionTypeResolver.resolve!(
12
23
  request: @request,
13
24
  account: account,
@@ -22,7 +33,12 @@ module StandardId
22
33
  )
23
34
  end
24
35
 
25
- def create_service_session(account, service_name:, service_version:, owner:, metadata: {})
36
+ def create_service_session(account, service_name:, service_version:, owner:, metadata: {}, auth_method: nil, provider: nil)
37
+ StandardId::LoginMethodPolicy.enforce!(
38
+ account: account, auth_method: auth_method, provider: provider,
39
+ request: @request, flow: :api_service_session
40
+ )
41
+
26
42
  session_class = StandardId::SessionTypeResolver.resolve!(
27
43
  request: @request,
28
44
  account: account,
@@ -0,0 +1,75 @@
1
+ module StandardId
2
+ # Carries the method an authentication was established with (password,
3
+ # passwordless, social + provider, ...) along everything derived from it, so
4
+ # `config.login_method_policy` can be consulted again on the `refresh_token`
5
+ # grant with the ORIGINAL method:
6
+ #
7
+ # web sign-in ──▶ BrowserSession#metadata ──▶ AuthorizationCode#metadata
8
+ # (/authorize, consent) │
9
+ # password / passwordless_otp / social grants ──┴─▶ RefreshToken#auth_method,
10
+ # #auth_provider
11
+ # refresh_token grant: copied from the presented token to its successor.
12
+ #
13
+ # Sessions and authorization codes already have a JSON `metadata` column, so
14
+ # the lineage lives there under "auth_method" / "auth_provider". Refresh tokens
15
+ # get two columns (migration 20261004000000). Anything minted before 0.45, or
16
+ # before that migration ran, has no recorded method and reaches the policy as
17
+ # `:unspecified` — a restrictive policy refuses it (fails closed).
18
+ module AuthLineage
19
+ METHOD_KEY = "auth_method".freeze
20
+ PROVIDER_KEY = "auth_provider".freeze
21
+
22
+ module_function
23
+
24
+ # @return [Hash] `{ auth_method: String|nil, auth_provider: String|nil }`
25
+ def build(auth_method, provider = nil)
26
+ { auth_method: auth_method&.to_s.presence, auth_provider: provider&.to_s.presence }
27
+ end
28
+
29
+ def empty
30
+ build(nil, nil)
31
+ end
32
+
33
+ # Lineage stored on a session or authorization code `metadata` hash.
34
+ def from_metadata(metadata)
35
+ metadata = metadata.is_a?(Hash) ? metadata.stringify_keys : {}
36
+ build(metadata[METHOD_KEY], metadata[PROVIDER_KEY])
37
+ end
38
+
39
+ def from_session(session)
40
+ return empty unless session.respond_to?(:metadata)
41
+
42
+ from_metadata(session.metadata)
43
+ end
44
+
45
+ def from_refresh_token(record)
46
+ return empty unless record && refresh_token_columns?
47
+
48
+ build(record.auth_method, record.auth_provider)
49
+ end
50
+
51
+ # Merge into a `metadata` hash. Nothing is added for an empty lineage.
52
+ def to_metadata(lineage)
53
+ { METHOD_KEY => lineage[:auth_method], PROVIDER_KEY => lineage[:auth_provider] }.compact
54
+ end
55
+
56
+ # Attributes for a new RefreshToken row; empty until the host has run the
57
+ # migration, so a rolling deploy never writes to a column that is not there.
58
+ def refresh_token_attributes(lineage)
59
+ return {} unless refresh_token_columns?
60
+
61
+ { auth_method: lineage[:auth_method], auth_provider: lineage[:auth_provider] }
62
+ end
63
+
64
+ # The policy's view: missing method → :unspecified.
65
+ def policy_arguments(lineage)
66
+ { auth_method: (lineage[:auth_method].presence || :unspecified).to_sym, provider: lineage[:auth_provider] }
67
+ end
68
+
69
+ def refresh_token_columns?
70
+ StandardId::RefreshToken.column_names.include?("auth_method")
71
+ rescue ActiveRecord::ActiveRecordError
72
+ false
73
+ end
74
+ end
75
+ end
@@ -36,6 +36,11 @@ module StandardId
36
36
  kind: :keyword,
37
37
  keywords: %i[identifier params request]
38
38
  },
39
+ "login_method_policy" => {
40
+ signature: "(account:, auth_method:, provider:, request:, flow:)",
41
+ kind: :keyword,
42
+ keywords: %i[account auth_method provider request flow]
43
+ },
39
44
  "oauth.custom_claims" => {
40
45
  signature: "(account:, client:, request:, audience:)",
41
46
  kind: :keyword,
@@ -118,6 +118,20 @@ StandardId::ConfigSchema.define do
118
118
  # Return: nil (default redirect) or a path string (override redirect)
119
119
  # Raise StandardId::AuthenticationDenied.new("message") to reject sign-in.
120
120
  field :after_sign_in, type: :any, default: nil
121
+
122
+ # login_method_policy: decides whether an account may authenticate with a
123
+ # given method. Consulted in EVERY flow that establishes a new
124
+ # authentication (web and API), after the credential is proven and BEFORE
125
+ # any session or token is created. See StandardId::LoginMethodPolicy for
126
+ # the list of flows.
127
+ # Receives (keywords, take any subset):
128
+ # account:, auth_method: (:password / :passwordless / :social /
129
+ # :remember_me / :unspecified), provider: ("google", ... or nil),
130
+ # request:, flow: (e.g. :web_password, :oauth_social_callback)
131
+ # Return: truthy to allow; false/nil to refuse with a generic message.
132
+ # Raise StandardId::LoginMethodDenied.new("message") to refuse with your own.
133
+ # nil (the default) allows every method, i.e. no behaviour change.
134
+ field :login_method_policy, type: :any, default: nil
121
135
  end
122
136
 
123
137
  scope :events do
@@ -150,6 +150,41 @@ module StandardId
150
150
  # Lifecycle hook errors
151
151
  class AuthenticationDenied < StandardError; end
152
152
 
153
+ # Raised when `config.login_method_policy` refuses an authentication method
154
+ # for an account (e.g. "staff must sign in with the org IdP"). See
155
+ # StandardId::LoginMethodPolicy.
156
+ #
157
+ # A subclass of AuthenticationDenied so every WebEngine flow that already
158
+ # rescues AuthenticationDenied (redirect to /login with the message as the
159
+ # flash alert, new-account cleanup) handles it unchanged. The API engine
160
+ # renders it as `403 access_denied` (see Api::BaseController), the same
161
+ # shape as SocialLinkError.
162
+ #
163
+ # The policy is only ever consulted AFTER the credential has been proven
164
+ # (password checked, OTP verified, provider token verified), so the message
165
+ # reaches only someone who already controls the credential. Policies may
166
+ # raise this themselves to supply their own message:
167
+ #
168
+ # raise StandardId::LoginMethodDenied, "Staff must sign in with Void Which Binds"
169
+ #
170
+ # `auth_method`, `provider` and `flow` are filled in by the engine for
171
+ # logging and audit; do not surface them to end users.
172
+ class LoginMethodDenied < AuthenticationDenied
173
+ DEFAULT_MESSAGE = "This sign-in method is not available for your account".freeze
174
+
175
+ attr_reader :auth_method, :provider, :flow
176
+
177
+ def initialize(message = nil, auth_method: nil, provider: nil, flow: nil)
178
+ @auth_method = auth_method
179
+ @provider = provider
180
+ @flow = flow
181
+ super(message.presence || DEFAULT_MESSAGE)
182
+ end
183
+
184
+ def oauth_error_code = :access_denied
185
+ def http_status = :forbidden
186
+ end
187
+
153
188
  # Social login errors
154
189
  # NOTE: email and provider_name are exposed as reader attributes for host
155
190
  # apps to build custom error responses. If you report exceptions to an
@@ -162,6 +197,8 @@ module StandardId
162
197
  # so it cannot prove ownership of the existing account
163
198
  # :subject_mismatch — the account's email identifier is already linked to a
164
199
  # different subject (`sub`) from this provider
200
+ # (SOCIAL_LINK_BLOCKED also uses :subject_conflict, for the race raised as
201
+ # SocialLinkConflictError rather than this error.)
165
202
  class SocialLinkError < OAuthError
166
203
  REASONS = %i[link_required email_unverified subject_mismatch].freeze
167
204
 
@@ -179,6 +216,24 @@ module StandardId
179
216
  def http_status = :forbidden
180
217
  end
181
218
 
219
+ # Raised while a social login writes its (provider, sub) link and loses a
220
+ # race to a concurrent login. The client sees a retryable `invalid_grant`;
221
+ # SOCIAL_LINK_BLOCKED is published with `reason` (not SOCIAL_AUTH_FAILED,
222
+ # which is for infrastructure failures):
223
+ # :subject_conflict — the same (provider, sub) was committed for a
224
+ # DIFFERENT account;
225
+ # :subject_mismatch — this identifier was linked to this provider under a
226
+ # DIFFERENT sub (the (identifier, provider) unique index won).
227
+ class SocialLinkConflictError < InvalidGrantError
228
+ attr_reader :identifier, :reason
229
+
230
+ def initialize(message = nil, identifier: nil, reason: :subject_conflict)
231
+ @identifier = identifier
232
+ @reason = reason
233
+ super(message)
234
+ end
235
+ end
236
+
182
237
  # Audience verification errors
183
238
  class InvalidAudienceError < StandardError
184
239
  attr_reader :required, :actual
@@ -8,6 +8,9 @@ module StandardId
8
8
  PASSWORD_VALIDATION_FAILED = "authentication.password.failed"
9
9
  OTP_VALIDATED = "authentication.otp.validated"
10
10
  OTP_VALIDATION_FAILED = "authentication.otp.failed"
11
+ # config.login_method_policy refused the method for the account, after
12
+ # the credential was proven and before any session was created.
13
+ AUTHENTICATION_METHOD_DENIED = "authentication.method.denied"
11
14
 
12
15
  SESSION_CREATING = "session.creating"
13
16
  SESSION_CREATED = "session.created"
@@ -75,7 +78,8 @@ module StandardId
75
78
  PASSWORD_VALIDATED,
76
79
  PASSWORD_VALIDATION_FAILED,
77
80
  OTP_VALIDATED,
78
- OTP_VALIDATION_FAILED
81
+ OTP_VALIDATION_FAILED,
82
+ AUTHENTICATION_METHOD_DENIED
79
83
  ].freeze
80
84
 
81
85
  SESSION_EVENTS = [
@@ -157,6 +161,7 @@ module StandardId
157
161
  AUTHENTICATION_FAILED,
158
162
  PASSWORD_VALIDATION_FAILED,
159
163
  OTP_VALIDATION_FAILED,
164
+ AUTHENTICATION_METHOD_DENIED,
160
165
  # Session
161
166
  SESSION_CREATED,
162
167
  SESSION_REVOKED,
@@ -12,6 +12,7 @@ module StandardId
12
12
  "authentication.password.failed" => :warn,
13
13
  "authentication.otp.validated" => :debug,
14
14
  "authentication.otp.failed" => :warn,
15
+ "authentication.method.denied" => :warn,
15
16
  "session.creating" => :debug,
16
17
  "session.created" => :info,
17
18
  "session.validating" => :debug,