standard_id 0.44.0 → 0.45.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 (39) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +42 -0
  3. data/README.md +123 -2
  4. data/app/controllers/concerns/standard_id/lifecycle_hooks.rb +3 -18
  5. data/app/controllers/concerns/standard_id/social_authentication.rb +208 -11
  6. data/app/controllers/concerns/standard_id/web_authentication.rb +6 -1
  7. data/app/controllers/standard_id/api/authorization_controller.rb +11 -4
  8. data/app/controllers/standard_id/api/base_controller.rb +16 -0
  9. data/app/controllers/standard_id/api/oauth/callback/providers_controller.rb +61 -15
  10. data/app/controllers/standard_id/web/auth/callback/providers_controller.rb +51 -3
  11. data/app/controllers/standard_id/web/consent_controller.rb +5 -1
  12. data/app/controllers/standard_id/web/login_verify_controller.rb +6 -1
  13. data/app/controllers/standard_id/web/signup_controller.rb +6 -1
  14. data/db/migrate/20261004000000_add_auth_method_to_standard_id_refresh_tokens.rb +17 -0
  15. data/lib/generators/standard_id/install/templates/standard_id.rb +13 -0
  16. data/lib/standard_id/account_cleanup.rb +62 -0
  17. data/lib/standard_id/api/token_manager.rb +18 -2
  18. data/lib/standard_id/auth_lineage.rb +75 -0
  19. data/lib/standard_id/config/callable_validator.rb +5 -0
  20. data/lib/standard_id/config/schema.rb +14 -0
  21. data/lib/standard_id/errors.rb +55 -0
  22. data/lib/standard_id/events/definitions.rb +6 -1
  23. data/lib/standard_id/events/subscribers/logging_subscriber.rb +1 -0
  24. data/lib/standard_id/login_method_policy.rb +108 -0
  25. data/lib/standard_id/oauth/authorization_code_authorization_flow.rb +2 -1
  26. data/lib/standard_id/oauth/authorization_code_flow.rb +6 -0
  27. data/lib/standard_id/oauth/authorization_flow.rb +6 -2
  28. data/lib/standard_id/oauth/password_flow.rb +13 -2
  29. data/lib/standard_id/oauth/passwordless_otp_flow.rb +4 -0
  30. data/lib/standard_id/oauth/refresh_token_flow.rb +32 -0
  31. data/lib/standard_id/oauth/social_flow.rb +4 -0
  32. data/lib/standard_id/oauth/subflows/traditional_code_grant.rb +6 -1
  33. data/lib/standard_id/oauth/token_grant_flow.rb +54 -2
  34. data/lib/standard_id/providers/base.rb +34 -0
  35. data/lib/standard_id/version.rb +1 -1
  36. data/lib/standard_id/web/session_manager.rb +50 -3
  37. data/lib/standard_id/web/token_manager.rb +8 -3
  38. data/lib/standard_id.rb +3 -0
  39. metadata +5 -1
@@ -23,28 +23,74 @@ module StandardId
23
23
  social_info = provider_response[:user_info]
24
24
  provider_tokens = provider_response[:tokens]
25
25
  account = find_or_create_account_from_social(social_info)
26
+ newly_created = account.previously_new_record?
26
27
 
27
- flow = StandardId::Oauth::SocialFlow.new(
28
- params,
29
- request,
30
- account:,
31
- connection: provider.provider_name,
32
- scopes: params[:scope]
33
- )
28
+ # Everything after find_or_create_account_from_social can still
29
+ # reject the login: SocialFlow.new (InvalidScopeError), the grant's
30
+ # audience/profile binding (InvalidGrantError), the login-method
31
+ # policy, a SOCIAL_AUTH_COMPLETED subscriber, or anything unexpected.
32
+ # Any of them leaves no link (it is only written on acceptance), no
33
+ # new account, and no usable token or session from this request.
34
+ token_response = nil
35
+ flow = nil
36
+ begin
37
+ flow = StandardId::Oauth::SocialFlow.new(
38
+ params,
39
+ request,
40
+ account:,
41
+ connection: provider.provider_name,
42
+ scopes: params[:scope]
43
+ )
44
+ token_response = flow.execute
45
+ run_social_callback(
46
+ provider: provider.provider_name,
47
+ social_info:,
48
+ provider_tokens:,
49
+ account:,
50
+ original_request_params: forwarded_request_params
51
+ )
52
+ commit_social_link!
53
+ rescue StandardError
54
+ removed = social_account_removed_concurrently?(account, newly_created:)
55
+ revoke_issued_tokens!(flow)
56
+ discard_social_attempt!(account, newly_created: newly_created)
57
+ raise unless removed
58
+
59
+ # The matched account was removed under this login by a
60
+ # concurrent, refused request: retryable, not a 500.
61
+ raise StandardId::InvalidGrantError, SOCIAL_RETRY_MESSAGE
62
+ end
34
63
 
35
- token_response = flow.execute
36
- run_social_callback(
37
- provider: provider.provider_name,
38
- social_info:,
39
- provider_tokens:,
40
- account:,
41
- original_request_params: forwarded_request_params
42
- )
43
64
  render json: token_response, status: :ok
44
65
  end
45
66
 
46
67
  private
47
68
 
69
+ # Write the (provider, sub) link only once the login is accepted.
70
+ def defer_social_link?
71
+ true
72
+ end
73
+
74
+ # The response was never sent, but the grant may already have
75
+ # persisted a refresh token and a session — also when the grant itself
76
+ # raised after writing them (e.g. an OAUTH_TOKEN_ISSUED subscriber), so
77
+ # they are read from the flow, not from a token response. Revoke them
78
+ # so nothing from the rejected request stays usable, and so that
79
+ # AccountCleanup does not mistake them for a concurrent login using a
80
+ # new account. A token whose transaction rolled back is not persisted
81
+ # and is skipped (its session write was rolled back with it).
82
+ def revoke_issued_tokens!(flow)
83
+ record = flow&.issued_refresh_token
84
+ return unless record&.persisted?
85
+
86
+ # Loaded by id, not through the association: the record was built
87
+ # with only session_id, and a host with strict_loading_by_default
88
+ # would raise here and mask the original error.
89
+ session = record.session_id && StandardId::Session.find_by(id: record.session_id)
90
+ session.revoke!(reason: "social_sign_in_rejected") unless session.nil? || session.revoked?
91
+ record.revoke!
92
+ end
93
+
48
94
  # Mirror of the web callback's OAuthError handling: emit
49
95
  # SOCIAL_AUTH_FAILED for infrastructure-level provider failures
50
96
  # (HTTP/DNS/SSL/timeouts surfaced as OAuthError by provider
@@ -40,7 +40,13 @@ module StandardId
40
40
  newly_created = account.previously_new_record?
41
41
 
42
42
  invoke_before_sign_in(account, { mechanism: "social", provider: provider.provider_name })
43
- session_manager.sign_in_account(account, scope_name: state_data&.dig("scope"))
43
+ session_manager.sign_in_account(
44
+ account,
45
+ scope_name: state_data&.dig("scope"),
46
+ auth_method: :social,
47
+ provider: provider.provider_name,
48
+ flow: :web_social
49
+ )
44
50
 
45
51
  provider_name = provider.provider_name
46
52
  invoke_after_account_created(account, { mechanism: "social", provider: provider_name }) if newly_created
@@ -68,8 +74,14 @@ module StandardId
68
74
  destination = redirect_override || (safe_destination?(caller_redirect_uri) ? caller_redirect_uri : safe_post_signin_default)
69
75
  redirect_options = { notice: "Successfully signed in with #{provider_name.humanize}" }
70
76
  redirect_options[:allow_other_host] = true if allow_other_host_redirect?(destination)
71
- redirect_to destination, redirect_options
77
+
78
+ # Accepted: only now write the (provider, sub) link, in the same
79
+ # transaction as the redirect, which can still raise (e.g. an
80
+ # after_sign_in URL on a host that is not allowed). If it does,
81
+ # the link is rolled back with it.
82
+ commit_social_link! { redirect_to destination, redirect_options }
72
83
  rescue StandardId::AuthenticationDenied => e
84
+ rollback_social_link!
73
85
  handle_authentication_denied(e, account: account, newly_created: newly_created)
74
86
  rescue StandardId::SocialLinkError => e
75
87
  # Policy/link error — SOCIAL_LINK_BLOCKED has already been emitted
@@ -77,8 +89,23 @@ module StandardId
77
89
  # (which is reserved for infrastructure-level failures).
78
90
  redirect_to StandardId::WebEngine.routes.url_helpers.login_path(redirect_uri: state_data&.dig("redirect_uri")), alert: "Authentication failed: #{e.message}"
79
91
  rescue StandardId::OAuthError => e
80
- emit_social_auth_failed(e, account: account)
92
+ discard_rejected_social_sign_in!(account, newly_created:)
93
+ # A (provider, sub) conflict with a concurrent login is a link
94
+ # refusal (SOCIAL_LINK_BLOCKED, already published), not an
95
+ # infrastructure failure.
96
+ emit_social_auth_failed(e, account: account) unless e.is_a?(StandardId::SocialLinkConflictError)
81
97
  redirect_to StandardId::WebEngine.routes.url_helpers.login_path(redirect_uri: state_data&.dig("redirect_uri")), alert: "Authentication failed: #{e.message}"
98
+ rescue StandardError => e
99
+ # Unexpected failure after the link/account may have been
100
+ # written: undo them, then let the error surface as before —
101
+ # unless the matched account was removed under this login by a
102
+ # concurrent, refused request, which is retryable.
103
+ removed = social_account_removed_concurrently?(account, newly_created:)
104
+ discard_rejected_social_sign_in!(account, newly_created:)
105
+ raise unless removed
106
+
107
+ emit_social_auth_failed(e, account: account)
108
+ redirect_to StandardId::WebEngine.routes.url_helpers.login_path(redirect_uri: state_data&.dig("redirect_uri")), alert: "Authentication failed: #{SOCIAL_RETRY_MESSAGE}"
82
109
  end
83
110
  end
84
111
 
@@ -103,6 +130,27 @@ module StandardId
103
130
 
104
131
  private
105
132
 
133
+ # A rejection after find_or_create_account_from_social leaves no
134
+ # link, no new account and no session — including one that
135
+ # sign_in_account created before raising (a failing SESSION_CREATED
136
+ # subscriber), which is why this reads session_manager.created_session
137
+ # rather than relying on sign_in_account having returned.
138
+ # AuthenticationDenied has its own path (handle_authentication_denied);
139
+ # SocialLinkError is raised before anything is written.
140
+ def discard_rejected_social_sign_in!(account, newly_created:)
141
+ created = session_manager.created_session
142
+ if created
143
+ created.revoke!(reason: "social_sign_in_rejected") unless created.revoked?
144
+ session_manager.clear_session!
145
+ end
146
+ discard_social_attempt!(account, newly_created: newly_created)
147
+ end
148
+
149
+ # Write the (provider, sub) link only once the login is accepted.
150
+ def defer_social_link?
151
+ true
152
+ end
153
+
106
154
  def callback_url_for
107
155
  "#{request.base_url}#{provider.callback_path}"
108
156
  end
@@ -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)
@@ -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,62 @@
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
+ module AccountCleanup
26
+ module_function
27
+
28
+ # @return [Boolean] true when the account was removed, false when it was
29
+ # already gone or another login has adopted it.
30
+ def destroy_newly_created!(account)
31
+ return false unless account&.persisted?
32
+
33
+ ActiveRecord::Base.transaction do
34
+ next false if account.class.lock.where(id: account.id).pick(:id).nil?
35
+
36
+ if adopted?(account)
37
+ Rails.logger&.info("[StandardId] Kept refused new account #{account.id}: a concurrent sign-in is using it")
38
+ next false
39
+ end
40
+
41
+ # Tokens first: refresh_tokens.account_id has no ON DELETE action, and
42
+ # a refused API grant may already have issued one for this account.
43
+ StandardId::RefreshToken.where(account_id: account.id).delete_all
44
+ account.sessions.strict_loading(false).destroy_all
45
+ identifiers = account.identifiers.strict_loading(false).to_a
46
+ identifiers.each { |i| i.credentials.strict_loading(false).destroy_all }
47
+ # 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.
48
+ identifiers.each(&:destroy!)
49
+ account.destroy
50
+ true
51
+ end
52
+ end
53
+
54
+ # Another request signed in to the account. The refusing request has
55
+ # already revoked whatever session / refresh token it issued itself.
56
+ def adopted?(account)
57
+ return true if account.sessions.strict_loading(false).active.exists?
58
+
59
+ StandardId::RefreshToken.active.where(account_id: account.id).exists?
60
+ end
61
+ end
62
+ 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,