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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +42 -0
- data/README.md +123 -2
- data/app/controllers/concerns/standard_id/lifecycle_hooks.rb +3 -18
- data/app/controllers/concerns/standard_id/social_authentication.rb +208 -11
- data/app/controllers/concerns/standard_id/web_authentication.rb +6 -1
- data/app/controllers/standard_id/api/authorization_controller.rb +11 -4
- data/app/controllers/standard_id/api/base_controller.rb +16 -0
- data/app/controllers/standard_id/api/oauth/callback/providers_controller.rb +61 -15
- data/app/controllers/standard_id/web/auth/callback/providers_controller.rb +51 -3
- data/app/controllers/standard_id/web/consent_controller.rb +5 -1
- data/app/controllers/standard_id/web/login_verify_controller.rb +6 -1
- data/app/controllers/standard_id/web/signup_controller.rb +6 -1
- data/db/migrate/20261004000000_add_auth_method_to_standard_id_refresh_tokens.rb +17 -0
- data/lib/generators/standard_id/install/templates/standard_id.rb +13 -0
- data/lib/standard_id/account_cleanup.rb +62 -0
- data/lib/standard_id/api/token_manager.rb +18 -2
- data/lib/standard_id/auth_lineage.rb +75 -0
- data/lib/standard_id/config/callable_validator.rb +5 -0
- data/lib/standard_id/config/schema.rb +14 -0
- data/lib/standard_id/errors.rb +55 -0
- data/lib/standard_id/events/definitions.rb +6 -1
- data/lib/standard_id/events/subscribers/logging_subscriber.rb +1 -0
- data/lib/standard_id/login_method_policy.rb +108 -0
- data/lib/standard_id/oauth/authorization_code_authorization_flow.rb +2 -1
- data/lib/standard_id/oauth/authorization_code_flow.rb +6 -0
- data/lib/standard_id/oauth/authorization_flow.rb +6 -2
- data/lib/standard_id/oauth/password_flow.rb +13 -2
- data/lib/standard_id/oauth/passwordless_otp_flow.rb +4 -0
- data/lib/standard_id/oauth/refresh_token_flow.rb +32 -0
- data/lib/standard_id/oauth/social_flow.rb +4 -0
- data/lib/standard_id/oauth/subflows/traditional_code_grant.rb +6 -1
- data/lib/standard_id/oauth/token_grant_flow.rb +54 -2
- data/lib/standard_id/providers/base.rb +34 -0
- data/lib/standard_id/version.rb +1 -1
- data/lib/standard_id/web/session_manager.rb +50 -3
- data/lib/standard_id/web/token_manager.rb +8 -3
- data/lib/standard_id.rb +3 -0
- 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
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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
|
data/lib/standard_id/errors.rb
CHANGED
|
@@ -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,
|