standard_id 0.43.2 → 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 +72 -0
- data/README.md +148 -3
- data/app/controllers/concerns/standard_id/lifecycle_hooks.rb +3 -18
- data/app/controllers/concerns/standard_id/social_authentication.rb +316 -8
- 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/app/models/standard_id/social_identity.rb +30 -0
- data/db/migrate/20261002000000_create_standard_id_social_identities.rb +44 -0
- 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 +21 -4
- 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 +18 -0
- data/lib/standard_id/errors.rb +68 -2
- 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 +7 -1
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
module StandardId
|
|
2
|
+
# A social provider's stable subject id (`sub`) linked to an account.
|
|
3
|
+
#
|
|
4
|
+
# Written by StandardId::SocialAuthentication when a social login creates an
|
|
5
|
+
# account, or links to an existing email identifier on a provider-verified
|
|
6
|
+
# email. Later logins with the same (provider, subject) resolve to the same
|
|
7
|
+
# account, whatever email the provider then reports.
|
|
8
|
+
#
|
|
9
|
+
# `identifier` is the email identifier the link was made through. A different
|
|
10
|
+
# subject from the same provider for that identifier is refused.
|
|
11
|
+
class SocialIdentity < ApplicationRecord
|
|
12
|
+
self.table_name = "standard_id_social_identities"
|
|
13
|
+
|
|
14
|
+
belongs_to :account, class_name: StandardId.config.account_class_name
|
|
15
|
+
belongs_to :identifier, class_name: "StandardId::Identifier"
|
|
16
|
+
|
|
17
|
+
validates :provider, presence: true
|
|
18
|
+
validates :subject, presence: true, uniqueness: { scope: :provider }
|
|
19
|
+
validates :identifier_id, uniqueness: { scope: :provider }
|
|
20
|
+
|
|
21
|
+
# Hosts that upgraded the gem but have not run
|
|
22
|
+
# 20261002000000_create_standard_id_social_identities yet. Cached by the
|
|
23
|
+
# schema cache, so this is one lookup per process.
|
|
24
|
+
def self.available?
|
|
25
|
+
table_exists?
|
|
26
|
+
rescue ActiveRecord::ActiveRecordError
|
|
27
|
+
false
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# Stores the social provider's stable subject id (`sub`) per account, so a
|
|
2
|
+
# returning social login is matched on (provider, sub) instead of on the email
|
|
3
|
+
# address alone.
|
|
4
|
+
#
|
|
5
|
+
# Before this table, `find_or_create_account_from_social` linked a provider
|
|
6
|
+
# login to whichever account held an email identifier with the same address,
|
|
7
|
+
# without looking at `sub` or `email_verified`. Under `link_strategy:
|
|
8
|
+
# :trust_provider`, or for an identifier with a NULL provider, any token that
|
|
9
|
+
# claimed an address could sign in as that address's account.
|
|
10
|
+
#
|
|
11
|
+
# New, empty table: nothing existing is rewritten, and no row is backfilled.
|
|
12
|
+
# Each existing social user gets a row on their next successful login (when the
|
|
13
|
+
# provider reports the email as verified). Until the migration has run, the
|
|
14
|
+
# gem skips subject matching and logs a warning, but still refuses to link an
|
|
15
|
+
# existing account to an unverified provider email.
|
|
16
|
+
#
|
|
17
|
+
# Both foreign keys cascade so the host's account- and identifier-deletion
|
|
18
|
+
# paths keep working unchanged: deleting the email identifier a link was made
|
|
19
|
+
# through removes the link, and the next login has to re-link on a verified
|
|
20
|
+
# email.
|
|
21
|
+
class CreateStandardIdSocialIdentities < ActiveRecord::Migration[8.0]
|
|
22
|
+
include StandardId::MigrationHelpers
|
|
23
|
+
|
|
24
|
+
def change
|
|
25
|
+
create_table :standard_id_social_identities, id: primary_key_type do |t|
|
|
26
|
+
t.references :account, type: foreign_key_type, null: false, index: true,
|
|
27
|
+
foreign_key: { to_table: StandardId.account_class.table_name, on_delete: :cascade }
|
|
28
|
+
t.references :identifier, type: foreign_key_type, null: false, index: false,
|
|
29
|
+
foreign_key: { to_table: :standard_id_identifiers, on_delete: :cascade }
|
|
30
|
+
|
|
31
|
+
t.string :provider, null: false
|
|
32
|
+
t.string :subject, null: false
|
|
33
|
+
|
|
34
|
+
t.timestamps
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# One account per provider subject.
|
|
38
|
+
add_index :standard_id_social_identities, [:provider, :subject], unique: true
|
|
39
|
+
# One subject per provider for a given email identifier: a second subject
|
|
40
|
+
# claiming the same address is refused as a possible takeover.
|
|
41
|
+
add_index :standard_id_social_identities, [:identifier_id, :provider], unique: true,
|
|
42
|
+
name: "index_standard_id_social_identities_on_identifier_and_provider"
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -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: }
|
|
@@ -478,10 +491,14 @@ StandardId.configure do |c|
|
|
|
478
491
|
# Default: []
|
|
479
492
|
# c.social.available_scopes = %w[profile email offline_access]
|
|
480
493
|
|
|
481
|
-
# Account linking strategy
|
|
482
|
-
#
|
|
483
|
-
#
|
|
484
|
-
#
|
|
494
|
+
# Account linking strategy, for a social login whose (provider, sub) is not
|
|
495
|
+
# linked yet but whose email belongs to an existing account:
|
|
496
|
+
# :strict — link only if that email identifier came from the same
|
|
497
|
+
# provider, predates provider tracking, or the account
|
|
498
|
+
# already has an identifier from this provider (default)
|
|
499
|
+
# :trust_provider — link to the account that holds the email
|
|
500
|
+
# Under both, the provider must report the email as verified, and an email
|
|
501
|
+
# already linked to a different sub from the same provider is refused.
|
|
485
502
|
# Default: :strict
|
|
486
503
|
# c.social.link_strategy = :trust_provider
|
|
487
504
|
|
|
@@ -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
|
|
@@ -565,6 +579,10 @@ StandardId::ConfigSchema.define do
|
|
|
565
579
|
field :social_account_attributes, type: :any, default: nil
|
|
566
580
|
field :allowed_redirect_url_prefixes, type: :array, default: []
|
|
567
581
|
field :available_scopes, type: :array, default: -> { [] }
|
|
582
|
+
# How a social login whose (provider, sub) is not linked yet may link to
|
|
583
|
+
# an existing account that holds its email: :strict or :trust_provider.
|
|
584
|
+
# Either way the provider must report the email as verified. See
|
|
585
|
+
# StandardId::SocialAuthentication#find_or_create_account_from_social.
|
|
568
586
|
field :link_strategy, type: :symbol, default: :strict
|
|
569
587
|
# What to do at boot when an enabled social provider is missing required
|
|
570
588
|
# config (e.g. apple_client_id set without apple_private_key). :warn logs
|
data/lib/standard_id/errors.rb
CHANGED
|
@@ -150,16 +150,64 @@ 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
|
|
156
191
|
# error tracker (Sentry, etc.), be aware these attributes contain PII.
|
|
192
|
+
#
|
|
193
|
+
# `reason` says why the link was refused:
|
|
194
|
+
# :link_required — the email belongs to an account the strict
|
|
195
|
+
# link_strategy will not link this provider to
|
|
196
|
+
# :email_unverified — the provider did not report the email as verified,
|
|
197
|
+
# so it cannot prove ownership of the existing account
|
|
198
|
+
# :subject_mismatch — the account's email identifier is already linked to a
|
|
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.)
|
|
157
202
|
class SocialLinkError < OAuthError
|
|
158
|
-
|
|
203
|
+
REASONS = %i[link_required email_unverified subject_mismatch].freeze
|
|
204
|
+
|
|
205
|
+
attr_reader :email, :provider_name, :reason
|
|
159
206
|
|
|
160
|
-
def initialize(email:, provider_name:)
|
|
207
|
+
def initialize(email:, provider_name:, reason: :link_required)
|
|
161
208
|
@email = email
|
|
162
209
|
@provider_name = provider_name
|
|
210
|
+
@reason = reason
|
|
163
211
|
super("This email is already associated with an account. Please sign in first to link this provider.")
|
|
164
212
|
end
|
|
165
213
|
|
|
@@ -168,6 +216,24 @@ module StandardId
|
|
|
168
216
|
def http_status = :forbidden
|
|
169
217
|
end
|
|
170
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
|
+
|
|
171
237
|
# Audience verification errors
|
|
172
238
|
class InvalidAudienceError < StandardError
|
|
173
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,
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
module StandardId
|
|
2
|
+
# Enforces `config.login_method_policy`: one decision point, consulted by
|
|
3
|
+
# every engine flow that establishes a NEW authentication, after the
|
|
4
|
+
# credential has been proven and before any session or token exists.
|
|
5
|
+
#
|
|
6
|
+
# ## Flows (the `flow:` the policy receives)
|
|
7
|
+
#
|
|
8
|
+
# | flow | auth_method | where |
|
|
9
|
+
# |---------------------------------|----------------|----------------------------------------------------------|
|
|
10
|
+
# | :web_password | :password | Web LoginController (WebAuthentication#sign_in_account) |
|
|
11
|
+
# | :web_signup | :password | Web SignupController |
|
|
12
|
+
# | :web_passwordless | :passwordless | Web LoginVerifyController (email/SMS OTP) |
|
|
13
|
+
# | :web_social | :social | Web Auth::Callback::ProvidersController |
|
|
14
|
+
# | :web_remember_me | :remember_me | Web::SessionManager (remember-me cookie re-auth) |
|
|
15
|
+
# | :web_session | caller's, else :unspecified | Web::SessionManager#sign_in_account called by host code |
|
|
16
|
+
# | :oauth_password_grant | :password | POST /oauth/token grant_type=password |
|
|
17
|
+
# | :oauth_passwordless_otp_grant | :passwordless | POST /oauth/token grant_type=passwordless_otp |
|
|
18
|
+
# | :oauth_social_callback | :social | /oauth/callback/:provider (Oauth::SocialFlow) |
|
|
19
|
+
# | :oauth_refresh_token | the ORIGINAL sign-in's, else :unspecified | POST /oauth/token grant_type=refresh_token |
|
|
20
|
+
# | :api_device_session | caller's, else :unspecified | Api::TokenManager#create_device_session (host code) |
|
|
21
|
+
# | :api_service_session | caller's, else :unspecified | Api::TokenManager#create_service_session (host code) |
|
|
22
|
+
#
|
|
23
|
+
# `refresh_token` is re-checked with the method of the authentication it
|
|
24
|
+
# descends from (see StandardId::AuthLineage): a policy tightened after a
|
|
25
|
+
# client signed in takes effect at that client's next refresh, and a refused
|
|
26
|
+
# refresh revokes the token family (answered as `invalid_grant`). Tokens with
|
|
27
|
+
# no recorded method (minted before 0.45) are checked as `:unspecified`.
|
|
28
|
+
#
|
|
29
|
+
# Not gated: `authorization_code` and the implicit flow (minted within
|
|
30
|
+
# minutes from a browser session that passed the policy when it was created;
|
|
31
|
+
# the code records that session's method for the refresh tokens it yields)
|
|
32
|
+
# and `client_credentials` (no account). Live browser sessions that predate a
|
|
33
|
+
# policy change are not re-checked; revoke them if they must end.
|
|
34
|
+
#
|
|
35
|
+
# ## Contract
|
|
36
|
+
#
|
|
37
|
+
# The policy is any object responding to `call`. It receives keywords and
|
|
38
|
+
# may declare any subset of them (see Utils::CallableParameterFilter):
|
|
39
|
+
# `account:`, `auth_method:`, `provider:`, `request:`, `flow:`.
|
|
40
|
+
#
|
|
41
|
+
# - truthy → allowed;
|
|
42
|
+
# - `false` / `nil` → refused with LoginMethodDenied::DEFAULT_MESSAGE;
|
|
43
|
+
# - `raise StandardId::LoginMethodDenied, "message"` → refused with that
|
|
44
|
+
# message.
|
|
45
|
+
#
|
|
46
|
+
# Any other exception propagates: a broken policy fails closed (the request
|
|
47
|
+
# errors) rather than open.
|
|
48
|
+
#
|
|
49
|
+
# Every refusal publishes AUTHENTICATION_METHOD_DENIED before raising.
|
|
50
|
+
module LoginMethodPolicy
|
|
51
|
+
AUTH_METHODS = %i[password passwordless social remember_me unspecified].freeze
|
|
52
|
+
|
|
53
|
+
FLOWS = %i[
|
|
54
|
+
web_password web_signup web_passwordless web_social web_remember_me web_session
|
|
55
|
+
oauth_password_grant oauth_passwordless_otp_grant oauth_social_callback oauth_refresh_token
|
|
56
|
+
api_device_session api_service_session
|
|
57
|
+
].freeze
|
|
58
|
+
|
|
59
|
+
module_function
|
|
60
|
+
|
|
61
|
+
# Whether a policy is configured at all.
|
|
62
|
+
def configured?
|
|
63
|
+
!StandardId.config.login_method_policy.nil?
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# @return [true] when allowed
|
|
67
|
+
# @raise [StandardId::LoginMethodDenied] when refused
|
|
68
|
+
# @raise [StandardId::ConfigurationError] when the policy is not callable
|
|
69
|
+
def enforce!(account:, auth_method:, flow:, provider: nil, request: nil)
|
|
70
|
+
policy = StandardId.config.login_method_policy
|
|
71
|
+
return true if policy.nil?
|
|
72
|
+
|
|
73
|
+
unless policy.respond_to?(:call)
|
|
74
|
+
raise StandardId::ConfigurationError,
|
|
75
|
+
"StandardId config: `login_method_policy` must respond to #call (got #{policy.class})"
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
auth_method = (auth_method || :unspecified).to_sym
|
|
79
|
+
provider = provider&.to_s
|
|
80
|
+
context = { account:, auth_method:, provider:, request:, flow: }
|
|
81
|
+
|
|
82
|
+
message = nil
|
|
83
|
+
allowed =
|
|
84
|
+
begin
|
|
85
|
+
policy.call(**StandardId::Utils::CallableParameterFilter.filter(policy, context))
|
|
86
|
+
rescue StandardId::LoginMethodDenied => e
|
|
87
|
+
message = e.message unless e.message == e.class.name
|
|
88
|
+
false
|
|
89
|
+
end
|
|
90
|
+
return true if allowed
|
|
91
|
+
|
|
92
|
+
denial = StandardId::LoginMethodDenied.new(message, auth_method:, provider:, flow:)
|
|
93
|
+
publish_denied(account:, auth_method:, provider:, flow:, message: denial.message)
|
|
94
|
+
raise denial
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def publish_denied(account:, auth_method:, provider:, flow:, message:)
|
|
98
|
+
StandardId::Events.publish(
|
|
99
|
+
StandardId::Events::AUTHENTICATION_METHOD_DENIED,
|
|
100
|
+
account: account,
|
|
101
|
+
auth_method: auth_method.to_s,
|
|
102
|
+
provider: provider,
|
|
103
|
+
flow: flow.to_s,
|
|
104
|
+
error_message: message
|
|
105
|
+
)
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
@@ -107,6 +107,12 @@ module StandardId
|
|
|
107
107
|
true
|
|
108
108
|
end
|
|
109
109
|
|
|
110
|
+
# The browser session the code was minted from recorded its sign-in
|
|
111
|
+
# method; carry it onto the refresh token.
|
|
112
|
+
def refresh_token_auth_lineage
|
|
113
|
+
StandardId::AuthLineage.from_metadata(@authorization_code&.metadata)
|
|
114
|
+
end
|
|
115
|
+
|
|
110
116
|
def find_authorization_code(code)
|
|
111
117
|
StandardId::AuthorizationCode.lookup(code)
|
|
112
118
|
end
|
|
@@ -1,12 +1,16 @@
|
|
|
1
1
|
module StandardId
|
|
2
2
|
module Oauth
|
|
3
3
|
class AuthorizationFlow < BaseRequestFlow
|
|
4
|
-
attr_reader :params, :request, :current_account
|
|
4
|
+
attr_reader :params, :request, :current_account, :auth_lineage
|
|
5
5
|
|
|
6
|
-
|
|
6
|
+
# `auth_lineage` (StandardId::AuthLineage) is how the browser session
|
|
7
|
+
# behind `current_account` was established; an authorization code carries
|
|
8
|
+
# it on to the tokens it is exchanged for.
|
|
9
|
+
def initialize(params, request, current_account: nil, auth_lineage: nil)
|
|
7
10
|
@params = params
|
|
8
11
|
@request = request
|
|
9
12
|
@current_account = current_account
|
|
13
|
+
@auth_lineage = auth_lineage
|
|
10
14
|
end
|
|
11
15
|
|
|
12
16
|
class << self
|