standard_id 0.44.0 → 0.45.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +42 -0
  3. data/README.md +123 -2
  4. data/app/controllers/concerns/standard_id/lifecycle_hooks.rb +3 -18
  5. data/app/controllers/concerns/standard_id/social_authentication.rb +208 -11
  6. data/app/controllers/concerns/standard_id/web_authentication.rb +6 -1
  7. data/app/controllers/standard_id/api/authorization_controller.rb +11 -4
  8. data/app/controllers/standard_id/api/base_controller.rb +16 -0
  9. data/app/controllers/standard_id/api/oauth/callback/providers_controller.rb +61 -15
  10. data/app/controllers/standard_id/web/auth/callback/providers_controller.rb +51 -3
  11. data/app/controllers/standard_id/web/consent_controller.rb +5 -1
  12. data/app/controllers/standard_id/web/login_verify_controller.rb +6 -1
  13. data/app/controllers/standard_id/web/signup_controller.rb +6 -1
  14. data/db/migrate/20261004000000_add_auth_method_to_standard_id_refresh_tokens.rb +17 -0
  15. data/lib/generators/standard_id/install/templates/standard_id.rb +13 -0
  16. data/lib/standard_id/account_cleanup.rb +62 -0
  17. data/lib/standard_id/api/token_manager.rb +18 -2
  18. data/lib/standard_id/auth_lineage.rb +75 -0
  19. data/lib/standard_id/config/callable_validator.rb +5 -0
  20. data/lib/standard_id/config/schema.rb +14 -0
  21. data/lib/standard_id/errors.rb +55 -0
  22. data/lib/standard_id/events/definitions.rb +6 -1
  23. data/lib/standard_id/events/subscribers/logging_subscriber.rb +1 -0
  24. data/lib/standard_id/login_method_policy.rb +108 -0
  25. data/lib/standard_id/oauth/authorization_code_authorization_flow.rb +2 -1
  26. data/lib/standard_id/oauth/authorization_code_flow.rb +6 -0
  27. data/lib/standard_id/oauth/authorization_flow.rb +6 -2
  28. data/lib/standard_id/oauth/password_flow.rb +13 -2
  29. data/lib/standard_id/oauth/passwordless_otp_flow.rb +4 -0
  30. data/lib/standard_id/oauth/refresh_token_flow.rb +32 -0
  31. data/lib/standard_id/oauth/social_flow.rb +4 -0
  32. data/lib/standard_id/oauth/subflows/traditional_code_grant.rb +6 -1
  33. data/lib/standard_id/oauth/token_grant_flow.rb +54 -2
  34. data/lib/standard_id/providers/base.rb +34 -0
  35. data/lib/standard_id/version.rb +1 -1
  36. data/lib/standard_id/web/session_manager.rb +50 -3
  37. data/lib/standard_id/web/token_manager.rb +8 -3
  38. data/lib/standard_id.rb +3 -0
  39. metadata +5 -1
@@ -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
@@ -55,6 +55,10 @@ module StandardId
55
55
  @account.id
56
56
  end
57
57
 
58
+ def login_method_policy_context
59
+ { auth_method: :password, flow: :oauth_password_grant }
60
+ end
61
+
58
62
  def client_id
59
63
  params[:client_id]
60
64
  end
@@ -76,15 +80,22 @@ module StandardId
76
80
  end
77
81
 
78
82
  def authenticate_account(username, password)
83
+ # Credential reaches the account through its identifier (`delegate
84
+ # :account, to: :identifier`); there is no `account` association to
85
+ # preload, and asking for one raised AssociationNotFoundError for every
86
+ # login that found a credential.
79
87
  @credential = StandardId::PasswordCredential
80
- .includes(credential: :account)
88
+ .includes(credential: { identifier: :account })
81
89
  .find_by(login: username)
82
90
 
83
91
  # Perform a dummy bcrypt comparison when the credential doesn't exist
84
92
  # to prevent user enumeration via response timing differences.
85
93
  BCrypt::Password.new(dummy_password_digest).is_password?(password) unless @credential
86
94
 
87
- @credential&.authenticate(password)&.account
95
+ # has_secure_password's authenticate returns false (not nil) on a wrong
96
+ # password, so `&.account` would call false.account → NoMethodError (500).
97
+ authenticated = @credential&.authenticate(password)
98
+ authenticated ? authenticated.account : nil
88
99
  end
89
100
 
90
101
  def validate_requested_scope!
@@ -26,6 +26,10 @@ module StandardId
26
26
  account.id
27
27
  end
28
28
 
29
+ def login_method_policy_context
30
+ { auth_method: :passwordless, flow: :oauth_passwordless_otp_grant }
31
+ end
32
+
29
33
  def client_id
30
34
  params[:client_id]
31
35
  end
@@ -15,6 +15,7 @@ module StandardId
15
15
  # in a transaction for atomicity.
16
16
  def execute
17
17
  instrumented_authenticate!
18
+ enforce_refresh_login_method_policy!
18
19
  response = nil
19
20
  StandardId::RefreshToken.transaction do
20
21
  rotate_current_refresh_token!
@@ -45,6 +46,37 @@ module StandardId
45
46
 
46
47
  private
47
48
 
49
+ # The refresh_token grant re-checks config.login_method_policy with the
50
+ # method the ORIGINAL authentication used (recorded on the token, copied
51
+ # down each rotation). A token minted before 0.45 / before migration
52
+ # 20261004000000 has none and is checked as :unspecified, so a
53
+ # restrictive policy refuses it and the client must sign in again.
54
+ def login_method_policy_context
55
+ StandardId::AuthLineage.policy_arguments(refresh_token_auth_lineage).merge(flow: :oauth_refresh_token)
56
+ end
57
+
58
+ # Successor tokens inherit the presented token's lineage.
59
+ def refresh_token_auth_lineage
60
+ StandardId::AuthLineage.from_refresh_token(@current_refresh_token_record)
61
+ end
62
+
63
+ # Runs after authenticate! proved the token, before rotation. Outside the
64
+ # rotation transaction so the family revocation persists. The answer is
65
+ # the same `invalid_grant` as any other dead refresh token: whoever
66
+ # presents a refresh token may not be its legitimate holder, so the
67
+ # response must not reveal why (RFC 6749 §5.2; see
68
+ # Api::Oauth::BaseController#handle_account_unusable). The reason is in
69
+ # the AUTHENTICATION_METHOD_DENIED event, server-side.
70
+ def enforce_refresh_login_method_policy!
71
+ account = token_account
72
+ return if account.nil?
73
+
74
+ StandardId::LoginMethodPolicy.enforce!(account: account, request: request, **login_method_policy_context)
75
+ rescue StandardId::LoginMethodDenied
76
+ @current_refresh_token_record&.revoke_family!
77
+ raise StandardId::InvalidGrantError, "Refresh token is no longer valid"
78
+ end
79
+
48
80
  def validate_refresh_token_record!
49
81
  jti = @refresh_payload[:jti]
50
82
  # Legacy tokens minted before jti tracking was added cannot be looked
@@ -20,6 +20,10 @@ module StandardId
20
20
  @account.id
21
21
  end
22
22
 
23
+ def login_method_policy_context
24
+ { auth_method: :social, provider: @connection, flow: :oauth_social_callback }
25
+ end
26
+
23
27
  def client_id
24
28
  @connection
25
29
  end
@@ -21,6 +21,11 @@ module StandardId
21
21
  @authorization_code ||= SecureRandom.urlsafe_base64(32)
22
22
  end
23
23
 
24
+ def lineage_metadata
25
+ lineage = params[:auth_lineage]
26
+ lineage ? StandardId::AuthLineage.to_metadata(lineage) : {}
27
+ end
28
+
24
29
  def store_authorization_code
25
30
  StandardId::AuthorizationCode.issue!(
26
31
  plaintext_code: authorization_code,
@@ -32,7 +37,7 @@ module StandardId
32
37
  code_challenge: params[:code_challenge],
33
38
  code_challenge_method: params[:code_challenge_method],
34
39
  nonce: params[:nonce],
35
- metadata: { state: params[:state] }.compact
40
+ metadata: { state: params[:state] }.compact.merge(lineage_metadata)
36
41
  )
37
42
  end
38
43
 
@@ -3,6 +3,13 @@ module StandardId
3
3
  class TokenGrantFlow < BaseRequestFlow
4
4
  attr_reader :params, :request
5
5
 
6
+ # The refresh token row this grant persisted, once it got that far (nil
7
+ # before). A caller that rejects the grant after it ran (the API social
8
+ # callback) revokes it, and its session, from here: when the grant
9
+ # itself raised after the write, no token response came back to read
10
+ # it from.
11
+ attr_reader :issued_refresh_token
12
+
6
13
  def initialize(params, request, current_account: nil)
7
14
  @params = params
8
15
  @request = request
@@ -17,11 +24,42 @@ module StandardId
17
24
 
18
25
  def execute
19
26
  instrumented_authenticate!
27
+ enforce_login_method_policy!
20
28
  generate_token_response
21
29
  end
22
30
 
23
31
  private
24
32
 
33
+ # The login-method policy context for this grant, or nil when the grant
34
+ # does not establish a new authentication (authorization_code,
35
+ # refresh_token, client_credentials derive from one that already
36
+ # happened, or have no account). Grants that authenticate an account
37
+ # themselves override this. See StandardId::LoginMethodPolicy.
38
+ #
39
+ # @return [Hash, nil] `{ auth_method:, flow:, provider: }`
40
+ def login_method_policy_context
41
+ nil
42
+ end
43
+
44
+ # Runs after authenticate! has proven the credential and before any
45
+ # token, refresh token or session row is created. On refusal an account
46
+ # created by this very request (passwordless or social registration) is
47
+ # removed again, mirroring the WebEngine's handle_authentication_denied.
48
+ def enforce_login_method_policy!
49
+ policy_context = login_method_policy_context
50
+ return if policy_context.nil?
51
+
52
+ account = token_account
53
+ return if account.nil?
54
+
55
+ StandardId::LoginMethodPolicy.enforce!(account: account, request: request, **policy_context)
56
+ rescue StandardId::LoginMethodDenied
57
+ if account.respond_to?(:previously_new_record?) && account.previously_new_record?
58
+ StandardId::AccountCleanup.destroy_newly_created!(account)
59
+ end
60
+ raise
61
+ end
62
+
25
63
  # authenticate!, wrapped in the Instrumentation::AUTHENTICATE event.
26
64
  def instrumented_authenticate!
27
65
  StandardId::Instrumentation.instrument(StandardId::Instrumentation::AUTHENTICATE, instrumentation_payload) do
@@ -186,15 +224,29 @@ module StandardId
186
224
  end
187
225
 
188
226
  def persist_refresh_token!(jti:, expires_at:)
189
- StandardId::RefreshToken.create!(
227
+ @issued_refresh_token = StandardId::RefreshToken.create!(
190
228
  account_id: subject_id,
191
229
  session_id: refresh_token_session_id,
192
230
  token_digest: StandardId::RefreshToken.digest_for(jti),
193
231
  expires_at: expires_at,
194
- previous_token: previous_refresh_token_record
232
+ previous_token: previous_refresh_token_record,
233
+ **StandardId::AuthLineage.refresh_token_attributes(refresh_token_auth_lineage)
195
234
  )
196
235
  end
197
236
 
237
+ # How the authentication behind this grant was established, recorded on
238
+ # the refresh token so the refresh_token grant can re-check the
239
+ # login-method policy against it. Grants that authenticate an account
240
+ # themselves derive it from their policy context; the authorization_code
241
+ # and refresh_token grants override it (from the code / the presented
242
+ # token). See StandardId::AuthLineage.
243
+ def refresh_token_auth_lineage
244
+ context = login_method_policy_context
245
+ return StandardId::AuthLineage.empty if context.nil?
246
+
247
+ StandardId::AuthLineage.build(context[:auth_method], context[:provider])
248
+ end
249
+
198
250
  # The parent session for the refresh token issued by THIS grant, when the
199
251
  # host materialised one via `config.session.session_type_resolver`.
200
252
  #
@@ -212,6 +212,40 @@ module StandardId
212
212
  false
213
213
  end
214
214
 
215
+ # Whether the `:strict` link_strategy may link this provider's login to
216
+ # an existing account that was created through ANOTHER provider (for
217
+ # example, a Google-created account) when the email addresses match.
218
+ #
219
+ # Default: false. Every provider, including Google and Apple, keeps
220
+ # the 0.44 behaviour: `:strict` refuses that link with
221
+ # SocialLinkError (reason `:link_required`).
222
+ #
223
+ # Return true ONLY for the organisation's own identity provider whose
224
+ # email claims the organisation itself verifies — e.g. a broker-local
225
+ # directory such as moneta (standard_id-void_which_binds), where an
226
+ # address is provisioned by the org and cannot be self-asserted. A
227
+ # public IdP that lets anyone register an address (Google, Apple,
228
+ # GitHub, ...) must never return true: trusting it would let whoever
229
+ # controls an address *at that IdP* take over the account holding the
230
+ # same address here.
231
+ #
232
+ # Returning true relaxes ONLY the `:strict` cross-provider check. All
233
+ # of these still apply, unchanged:
234
+ # - the provider must report `email_verified` as true (or "true"),
235
+ # otherwise the link is refused with reason `:email_unverified`;
236
+ # - the existing email identifier must itself be verified (an
237
+ # unverified address on the existing account proves nothing about
238
+ # who created it — see the pre-account-hijacking note in the
239
+ # README);
240
+ # - the identifier must not already be linked to a different `sub`
241
+ # from this provider (`:subject_mismatch`);
242
+ # - a stored (provider, sub) match still wins over any email match.
243
+ #
244
+ # @return [Boolean]
245
+ def trusted_for_linking?
246
+ false
247
+ end
248
+
215
249
  # Returns list of supported authorization parameters for this provider.
216
250
  #
217
251
  # Include :nonce in this list for OIDC providers to enable nonce validation.
@@ -1,3 +1,3 @@
1
1
  module StandardId
2
- VERSION = "0.44.0"
2
+ VERSION = "0.45.0"
3
3
  end
@@ -3,6 +3,11 @@ module StandardId
3
3
  class SessionManager
4
4
  attr_reader :token_manager, :request, :session, :cookies
5
5
 
6
+ # The session sign_in_account created in this request, set as soon as the
7
+ # row exists — so a caller can revoke it even when sign_in_account then
8
+ # raises (e.g. a SESSION_CREATED subscriber fails).
9
+ attr_reader :created_session
10
+
6
11
  def initialize(token_manager, request:, session:, cookies:, reset_session: nil)
7
12
  @token_manager = token_manager
8
13
  @request = request
@@ -32,7 +37,26 @@ module StandardId
32
37
  Current.account = load_current_account
33
38
  end
34
39
 
35
- def sign_in_account(account, scope_name: nil)
40
+ # Creates a browser session for an account whose credential the caller
41
+ # has already verified.
42
+ #
43
+ # `config.login_method_policy` is consulted first, before anything is
44
+ # written; a refusal raises StandardId::LoginMethodDenied (an
45
+ # AuthenticationDenied) and no session, cookie or Rails-session change
46
+ # happens. The engine's own controllers pass `auth_method:` /
47
+ # `provider:` / `flow:`. Host code that signs accounts in itself should
48
+ # pass at least `auth_method:` (e.g. `:password`, `:social`); without it
49
+ # the policy sees `:unspecified` under flow `:web_session`, which a
50
+ # restrictive policy will (correctly) refuse.
51
+ def sign_in_account(account, scope_name: nil, auth_method: nil, provider: nil, flow: :web_session)
52
+ StandardId::LoginMethodPolicy.enforce!(
53
+ account: account,
54
+ auth_method: auth_method,
55
+ provider: provider,
56
+ request: request,
57
+ flow: flow
58
+ )
59
+
36
60
  emit_session_creating(account, "browser")
37
61
 
38
62
  # Prevent session fixation by resetting the Rails session before
@@ -44,7 +68,9 @@ module StandardId
44
68
  session[:return_to_after_authenticating] = return_to if return_to
45
69
  session[:standard_id_scopes] = existing_scopes if existing_scopes
46
70
 
47
- token_manager.create_browser_session(account).tap do |browser_session|
71
+ lineage = StandardId::AuthLineage.build(auth_method || :unspecified, provider)
72
+ token_manager.create_browser_session(account, auth_lineage: lineage).tap do |browser_session|
73
+ @created_session = browser_session
48
74
  # Store in both session and encrypted cookie for backward compatibility
49
75
  # Action Cable will use the encrypted cookie
50
76
  session[:session_token] = browser_session.token
@@ -202,13 +228,34 @@ module StandardId
202
228
  password_credential = StandardId::PasswordCredential.find_by_token_for(:remember_me, cookies[:remember_token])
203
229
  return if password_credential.blank?
204
230
 
231
+ # Remember-me is a re-authentication (it mints a new session), so the
232
+ # login-method policy applies. A refusal must not raise here — this
233
+ # runs on any request that asks for current_session — so it signs the
234
+ # visitor out instead (clearing the remember cookie, so the refused
235
+ # cookie is not replayed on every request). The refusal itself is
236
+ # published as AUTHENTICATION_METHOD_DENIED by the policy.
237
+ begin
238
+ StandardId::LoginMethodPolicy.enforce!(
239
+ account: password_credential.account,
240
+ auth_method: :remember_me,
241
+ request: request,
242
+ flow: :web_remember_me
243
+ )
244
+ rescue StandardId::LoginMethodDenied
245
+ clear_session!
246
+ return
247
+ end
248
+
205
249
  # Prevent session fixation on returning-user remember-me flow.
206
250
  # Note: standard_id_scopes are intentionally NOT preserved here —
207
251
  # remember-me re-auth is a fresh session context where scopes
208
252
  # must be re-acquired through explicit scoped sign-in.
209
253
  @reset_session&.call
210
254
 
211
- token_manager.create_browser_session(password_credential.account, remember_me: true).tap do |browser_session|
255
+ # Recorded as :remember_me, the method the policy approved above, so
256
+ # tokens later derived from this session are re-checked as such.
257
+ lineage = StandardId::AuthLineage.build(:remember_me)
258
+ token_manager.create_browser_session(password_credential.account, auth_lineage: lineage).tap do |browser_session|
212
259
  # Store in both session and encrypted cookie for backward compatibility
213
260
  session[:session_token] = browser_session.token
214
261
  write_session_cookie(browser_session)
@@ -7,14 +7,17 @@ module StandardId
7
7
  @request = request
8
8
  end
9
9
 
10
- def create_browser_session(account)
10
+ # `auth_lineage` (StandardId::AuthLineage) records how the sign-in was
11
+ # made in the session's metadata, so codes and tokens derived from this
12
+ # session can be re-checked against config.login_method_policy.
13
+ def create_browser_session(account, auth_lineage: nil)
11
14
  session_class = StandardId::SessionTypeResolver.resolve!(
12
15
  request: request,
13
16
  account: account,
14
17
  flow: :web_sign_in
15
18
  )
16
19
 
17
- create_session_for(session_class, account: account)
20
+ create_session_for(session_class, account: account, auth_lineage: auth_lineage)
18
21
  end
19
22
 
20
23
  def create_remember_token(password_credential)
@@ -29,10 +32,12 @@ module StandardId
29
32
 
30
33
  private
31
34
 
32
- def create_session_for(session_class, account:)
35
+ def create_session_for(session_class, account:, auth_lineage: nil)
33
36
  attrs = base_attributes(account: account).merge(
34
37
  session_specific_attributes(session_class)
35
38
  )
39
+ lineage_metadata = auth_lineage ? StandardId::AuthLineage.to_metadata(auth_lineage) : {}
40
+ attrs[:metadata] = lineage_metadata if lineage_metadata.present?
36
41
  session_class.create!(**attrs)
37
42
  end
38
43
 
data/lib/standard_id.rb CHANGED
@@ -33,6 +33,9 @@ require "standard_id/api/session_manager"
33
33
  require "standard_id/api/token_manager"
34
34
  require "standard_id/api/authentication_guard"
35
35
  require "standard_id/utils/callable_parameter_filter"
36
+ require "standard_id/login_method_policy"
37
+ require "standard_id/auth_lineage"
38
+ require "standard_id/account_cleanup"
36
39
  require "standard_id/oauth/audience_profile_resolver"
37
40
  require "standard_id/oauth/audience_scope_resolver"
38
41
  require "standard_id/oauth/base_request_flow"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: standard_id
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.44.0
4
+ version: 0.45.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jaryl Sim
@@ -237,9 +237,11 @@ files:
237
237
  - db/migrate/20260915000000_remove_refresh_token_lifetime_from_standard_id_client_applications.rb
238
238
  - db/migrate/20260924000000_add_unique_active_device_index_to_standard_id_sessions.rb
239
239
  - db/migrate/20261002000000_create_standard_id_social_identities.rb
240
+ - db/migrate/20261004000000_add_auth_method_to_standard_id_refresh_tokens.rb
240
241
  - lib/generators/standard_id/install/install_generator.rb
241
242
  - lib/generators/standard_id/install/templates/standard_id.rb
242
243
  - lib/standard_id.rb
244
+ - lib/standard_id/account_cleanup.rb
243
245
  - lib/standard_id/account_locking.rb
244
246
  - lib/standard_id/account_status.rb
245
247
  - lib/standard_id/api/authentication_guard.rb
@@ -247,6 +249,7 @@ files:
247
249
  - lib/standard_id/api/token_manager.rb
248
250
  - lib/standard_id/api_engine.rb
249
251
  - lib/standard_id/association_strict_loading.rb
252
+ - lib/standard_id/auth_lineage.rb
250
253
  - lib/standard_id/authorization_bypass.rb
251
254
  - lib/standard_id/bearer_token_extraction.rb
252
255
  - lib/standard_id/checks/migrations.rb
@@ -270,6 +273,7 @@ files:
270
273
  - lib/standard_id/http_client.rb
271
274
  - lib/standard_id/instrumentation.rb
272
275
  - lib/standard_id/jwt_service.rb
276
+ - lib/standard_id/login_method_policy.rb
273
277
  - lib/standard_id/migration_check.rb
274
278
  - lib/standard_id/migration_helpers.rb
275
279
  - lib/standard_id/oauth/audience_profile_resolver.rb