standard_id 0.44.0 → 0.45.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +42 -0
- data/README.md +123 -2
- data/app/controllers/concerns/standard_id/lifecycle_hooks.rb +3 -18
- data/app/controllers/concerns/standard_id/social_authentication.rb +208 -11
- data/app/controllers/concerns/standard_id/web_authentication.rb +6 -1
- data/app/controllers/standard_id/api/authorization_controller.rb +11 -4
- data/app/controllers/standard_id/api/base_controller.rb +16 -0
- data/app/controllers/standard_id/api/oauth/callback/providers_controller.rb +61 -15
- data/app/controllers/standard_id/web/auth/callback/providers_controller.rb +51 -3
- data/app/controllers/standard_id/web/consent_controller.rb +5 -1
- data/app/controllers/standard_id/web/login_verify_controller.rb +6 -1
- data/app/controllers/standard_id/web/signup_controller.rb +6 -1
- data/db/migrate/20261004000000_add_auth_method_to_standard_id_refresh_tokens.rb +17 -0
- data/lib/generators/standard_id/install/templates/standard_id.rb +13 -0
- data/lib/standard_id/account_cleanup.rb +62 -0
- data/lib/standard_id/api/token_manager.rb +18 -2
- data/lib/standard_id/auth_lineage.rb +75 -0
- data/lib/standard_id/config/callable_validator.rb +5 -0
- data/lib/standard_id/config/schema.rb +14 -0
- data/lib/standard_id/errors.rb +55 -0
- data/lib/standard_id/events/definitions.rb +6 -1
- data/lib/standard_id/events/subscribers/logging_subscriber.rb +1 -0
- data/lib/standard_id/login_method_policy.rb +108 -0
- data/lib/standard_id/oauth/authorization_code_authorization_flow.rb +2 -1
- data/lib/standard_id/oauth/authorization_code_flow.rb +6 -0
- data/lib/standard_id/oauth/authorization_flow.rb +6 -2
- data/lib/standard_id/oauth/password_flow.rb +13 -2
- data/lib/standard_id/oauth/passwordless_otp_flow.rb +4 -0
- data/lib/standard_id/oauth/refresh_token_flow.rb +32 -0
- data/lib/standard_id/oauth/social_flow.rb +4 -0
- data/lib/standard_id/oauth/subflows/traditional_code_grant.rb +6 -1
- data/lib/standard_id/oauth/token_grant_flow.rb +54 -2
- data/lib/standard_id/providers/base.rb +34 -0
- data/lib/standard_id/version.rb +1 -1
- data/lib/standard_id/web/session_manager.rb +50 -3
- data/lib/standard_id/web/token_manager.rb +8 -3
- data/lib/standard_id.rb +3 -0
- metadata +5 -1
|
@@ -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
|
|
@@ -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
|
-
|
|
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!
|
|
@@ -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
|
|
@@ -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.
|
data/lib/standard_id/version.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|