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.
Files changed (41) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +72 -0
  3. data/README.md +148 -3
  4. data/app/controllers/concerns/standard_id/lifecycle_hooks.rb +3 -18
  5. data/app/controllers/concerns/standard_id/social_authentication.rb +316 -8
  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/app/models/standard_id/social_identity.rb +30 -0
  15. data/db/migrate/20261002000000_create_standard_id_social_identities.rb +44 -0
  16. data/db/migrate/20261004000000_add_auth_method_to_standard_id_refresh_tokens.rb +17 -0
  17. data/lib/generators/standard_id/install/templates/standard_id.rb +21 -4
  18. data/lib/standard_id/account_cleanup.rb +62 -0
  19. data/lib/standard_id/api/token_manager.rb +18 -2
  20. data/lib/standard_id/auth_lineage.rb +75 -0
  21. data/lib/standard_id/config/callable_validator.rb +5 -0
  22. data/lib/standard_id/config/schema.rb +18 -0
  23. data/lib/standard_id/errors.rb +68 -2
  24. data/lib/standard_id/events/definitions.rb +6 -1
  25. data/lib/standard_id/events/subscribers/logging_subscriber.rb +1 -0
  26. data/lib/standard_id/login_method_policy.rb +108 -0
  27. data/lib/standard_id/oauth/authorization_code_authorization_flow.rb +2 -1
  28. data/lib/standard_id/oauth/authorization_code_flow.rb +6 -0
  29. data/lib/standard_id/oauth/authorization_flow.rb +6 -2
  30. data/lib/standard_id/oauth/password_flow.rb +13 -2
  31. data/lib/standard_id/oauth/passwordless_otp_flow.rb +4 -0
  32. data/lib/standard_id/oauth/refresh_token_flow.rb +32 -0
  33. data/lib/standard_id/oauth/social_flow.rb +4 -0
  34. data/lib/standard_id/oauth/subflows/traditional_code_grant.rb +6 -1
  35. data/lib/standard_id/oauth/token_grant_flow.rb +54 -2
  36. data/lib/standard_id/providers/base.rb +34 -0
  37. data/lib/standard_id/version.rb +1 -1
  38. data/lib/standard_id/web/session_manager.rb +50 -3
  39. data/lib/standard_id/web/token_manager.rb +8 -3
  40. data/lib/standard_id.rb +3 -0
  41. 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
- # :strict — refuse to link a social identity unless the email
483
- # already matches a verified identifier (default)
484
- # :trust_provider — accept the social provider's claim and create or link
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
- 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
@@ -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
@@ -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
- attr_reader :email, :provider_name
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
@@ -37,7 +37,8 @@ module StandardId
37
37
  traditional: -> do
38
38
  Subflows::TraditionalCodeGrant.new(
39
39
  **common_subflow_params(flow_params),
40
- current_account: current_account
40
+ current_account: current_account,
41
+ auth_lineage: auth_lineage
41
42
  )
42
43
  end
43
44
  }
@@ -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
- def initialize(params, request, current_account: nil)
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