standard_id 0.44.0 → 0.46.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 +55 -0
- data/README.md +167 -3
- data/app/controllers/concerns/standard_id/lifecycle_hooks.rb +12 -19
- data/app/controllers/concerns/standard_id/social_authentication.rb +267 -13
- data/app/controllers/concerns/standard_id/web/social_login_params.rb +6 -2
- 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 +72 -16
- data/app/controllers/standard_id/web/auth/callback/providers_controller.rb +69 -6
- data/app/controllers/standard_id/web/consent_controller.rb +5 -1
- data/app/controllers/standard_id/web/login_controller.rb +8 -0
- 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 +147 -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/social_login_grant.rb +6 -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 +87 -2
- 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
|
|
@@ -12,6 +12,12 @@ module StandardId
|
|
|
12
12
|
@social_provider_url ||= begin
|
|
13
13
|
connection = params[:connection]
|
|
14
14
|
provider = StandardId::ProviderRegistry.get(connection)
|
|
15
|
+
# This flow's state is a stateless encoding, so there is nowhere
|
|
16
|
+
# server-held to keep a PKCE verifier. Refuse a provider that
|
|
17
|
+
# requires core-managed PKCE rather than start it without one.
|
|
18
|
+
if provider.supports_pkce?
|
|
19
|
+
raise StandardId::InvalidRequestError, "#{connection} sign-in requires the web login flow (PKCE)"
|
|
20
|
+
end
|
|
15
21
|
|
|
16
22
|
provider.authorization_url(
|
|
17
23
|
state: encode_state_with_original_params,
|
|
@@ -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
|
#
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
require "uri"
|
|
2
|
+
require "base64"
|
|
3
|
+
require "digest"
|
|
2
4
|
require "active_support/security_utils"
|
|
3
5
|
|
|
4
6
|
module StandardId
|
|
@@ -81,7 +83,14 @@ module StandardId
|
|
|
81
83
|
# @param id_token [String, nil] JWT ID token (mobile/implicit flow)
|
|
82
84
|
# @param access_token [String, nil] Access token (implicit flow)
|
|
83
85
|
# @param redirect_uri [String, nil] Original redirect_uri for code exchange validation
|
|
84
|
-
# @param options [Hash] Provider-specific options (client_id for Apple mobile, etc.)
|
|
86
|
+
# @param options [Hash] Provider-specific options (client_id for Apple mobile, etc.).
|
|
87
|
+
# Core may also pass, and a provider that does not need them ignores:
|
|
88
|
+
# - `nonce:` the nonce the server issued for this flow (only for
|
|
89
|
+
# providers listing :nonce in {supported_authorization_params});
|
|
90
|
+
# - `callback_iss:` the callback's RFC 9207 `iss` parameter, when the
|
|
91
|
+
# redirect carried one as a String (absent otherwise);
|
|
92
|
+
# - `code_verifier:` the PKCE verifier core generated and stored with
|
|
93
|
+
# the flow's state (web callback only, and only when {supports_pkce?}).
|
|
85
94
|
# @return [HashWithIndifferentAccess] Standardized response with user_info and tokens
|
|
86
95
|
# @raise [NotImplementedError] if not overridden by subclass
|
|
87
96
|
# @raise [StandardId::InvalidRequestError] if credentials are missing or invalid
|
|
@@ -212,6 +221,76 @@ module StandardId
|
|
|
212
221
|
false
|
|
213
222
|
end
|
|
214
223
|
|
|
224
|
+
# Whether the `:strict` link_strategy may link this provider's login to
|
|
225
|
+
# an existing account that was created through ANOTHER provider (for
|
|
226
|
+
# example, a Google-created account) when the email addresses match.
|
|
227
|
+
#
|
|
228
|
+
# Default: false. Every provider, including Google and Apple, keeps
|
|
229
|
+
# the 0.44 behaviour: `:strict` refuses that link with
|
|
230
|
+
# SocialLinkError (reason `:link_required`).
|
|
231
|
+
#
|
|
232
|
+
# Return true ONLY for the organisation's own identity provider whose
|
|
233
|
+
# email claims the organisation itself verifies — e.g. a broker-local
|
|
234
|
+
# directory such as moneta (standard_id-void_which_binds), where an
|
|
235
|
+
# address is provisioned by the org and cannot be self-asserted. A
|
|
236
|
+
# public IdP that lets anyone register an address (Google, Apple,
|
|
237
|
+
# GitHub, ...) must never return true: trusting it would let whoever
|
|
238
|
+
# controls an address *at that IdP* take over the account holding the
|
|
239
|
+
# same address here.
|
|
240
|
+
#
|
|
241
|
+
# Returning true relaxes ONLY the `:strict` cross-provider check. All
|
|
242
|
+
# of these still apply, unchanged:
|
|
243
|
+
# - the provider must report `email_verified` as true (or "true"),
|
|
244
|
+
# otherwise the link is refused with reason `:email_unverified`;
|
|
245
|
+
# - the existing email identifier must itself be verified (an
|
|
246
|
+
# unverified address on the existing account proves nothing about
|
|
247
|
+
# who created it — see the pre-account-hijacking note in the
|
|
248
|
+
# README);
|
|
249
|
+
# - the identifier must not already be linked to a different `sub`
|
|
250
|
+
# from this provider (`:subject_mismatch`);
|
|
251
|
+
# - a stored (provider, sub) match still wins over any email match.
|
|
252
|
+
#
|
|
253
|
+
# @return [Boolean]
|
|
254
|
+
def trusted_for_linking?
|
|
255
|
+
false
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
# Whether core manages PKCE (RFC 7636, S256) for this provider's web
|
|
259
|
+
# sign-in.
|
|
260
|
+
#
|
|
261
|
+
# Default: false — core neither sends a challenge nor passes a verifier.
|
|
262
|
+
#
|
|
263
|
+
# When true, the web login flow generates a fresh `code_verifier` for
|
|
264
|
+
# every sign-in, stores it server-held alongside the state and nonce
|
|
265
|
+
# (the encrypted pending-requests cookie; it is never sent to the
|
|
266
|
+
# provider or put in a URL), and:
|
|
267
|
+
# - passes `code_challenge:` (the S256 challenge) and
|
|
268
|
+
# `code_challenge_method: "S256"` to {authorization_url}
|
|
269
|
+
# ({build_authorization_url} emits both when present);
|
|
270
|
+
# - passes the stored `code_verifier:` to {get_user_info} at the web
|
|
271
|
+
# callback, which the provider sends in its token request. A web
|
|
272
|
+
# callback whose stored request has no verifier is refused before
|
|
273
|
+
# the provider is called.
|
|
274
|
+
#
|
|
275
|
+
# The API callback (`/api/oauth/callback/:provider`) has no server-held
|
|
276
|
+
# flow state, so it never passes a `code_verifier:`; the API social
|
|
277
|
+
# login grant refuses to start a sign-in for a provider that returns
|
|
278
|
+
# true here rather than send it without a challenge.
|
|
279
|
+
#
|
|
280
|
+
# @return [Boolean]
|
|
281
|
+
def supports_pkce?
|
|
282
|
+
false
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
# The RFC 7636 S256 code challenge for a verifier:
|
|
286
|
+
# BASE64URL(SHA256(verifier)) without padding.
|
|
287
|
+
#
|
|
288
|
+
# @param code_verifier [String]
|
|
289
|
+
# @return [String]
|
|
290
|
+
def pkce_s256_challenge(code_verifier)
|
|
291
|
+
Base64.urlsafe_encode64(Digest::SHA256.digest(code_verifier.to_s), padding: false)
|
|
292
|
+
end
|
|
293
|
+
|
|
215
294
|
# Returns list of supported authorization parameters for this provider.
|
|
216
295
|
#
|
|
217
296
|
# Include :nonce in this list for OIDC providers to enable nonce validation.
|
|
@@ -419,7 +498,10 @@ module StandardId
|
|
|
419
498
|
#
|
|
420
499
|
# Emits `client_id`, `redirect_uri`, `response_type`, `state`, then one
|
|
421
500
|
# entry per {supported_authorization_params}, taking the caller's value
|
|
422
|
-
# from `options` or falling back to `defaults
|
|
501
|
+
# from `options` or falling back to `defaults`, then `code_challenge` and
|
|
502
|
+
# `code_challenge_method` when `options` carries them (core passes them
|
|
503
|
+
# only to providers that opt in with {supports_pkce?}). Nil values are
|
|
504
|
+
# dropped.
|
|
423
505
|
#
|
|
424
506
|
# @param endpoint [String] Provider authorization endpoint
|
|
425
507
|
# @param client_id [String]
|
|
@@ -452,6 +534,9 @@ module StandardId
|
|
|
452
534
|
query[param] = options[param] || defaults[param]
|
|
453
535
|
end
|
|
454
536
|
|
|
537
|
+
query[:code_challenge] = options[:code_challenge]
|
|
538
|
+
query[:code_challenge_method] = options[:code_challenge_method]
|
|
539
|
+
|
|
455
540
|
"#{endpoint}?#{URI.encode_www_form(query.compact)}"
|
|
456
541
|
end
|
|
457
542
|
|
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"
|