standard_id 0.29.0 → 0.30.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 +1038 -0
- data/README.md +50 -0
- data/app/controllers/standard_id/api/oauth/revocations_controller.rb +146 -60
- data/app/controllers/standard_id/web/base_controller.rb +41 -0
- data/app/controllers/standard_id/web/login_controller.rb +1 -1
- data/app/controllers/standard_id/web/login_verify_controller.rb +1 -1
- data/app/controllers/standard_id/web/signup_controller.rb +3 -1
- data/app/models/standard_id/session.rb +42 -0
- data/lib/standard_id/config/schema.rb +39 -0
- data/lib/standard_id/oauth/authorization_code_flow.rb +37 -3
- data/lib/standard_id/version.rb +1 -1
- metadata +2 -1
data/README.md
CHANGED
|
@@ -164,6 +164,56 @@ end
|
|
|
164
164
|
|
|
165
165
|
`default_token_lifetime` is applied to every OAuth grant unless you override it in `oauth.token_lifetimes`. Keys map to OAuth grant types (for example `:password`, `:client_credentials`, `:refresh_token`) and should return durations in seconds. Non-token endpoint flows such as the implicit flow can be customized with their symbol key (e.g. `:implicit`). Refresh tokens can be tuned separately through `oauth.refresh_token_lifetime`.
|
|
166
166
|
|
|
167
|
+
### OAuth Hardening Options
|
|
168
|
+
|
|
169
|
+
Two spec-conformance settings default to the historical (looser) behaviour so
|
|
170
|
+
that upgrading the gem never changes what a live client sees. **New
|
|
171
|
+
deployments should turn both on.**
|
|
172
|
+
|
|
173
|
+
```ruby
|
|
174
|
+
StandardId.configure do |config|
|
|
175
|
+
# RFC 6749 §4.1.3 — reject a token exchange that omits redirect_uri when the
|
|
176
|
+
# authorization request included one. Default: false (accepted, with a
|
|
177
|
+
# warning logged naming the client_id). Watch your logs for that warning;
|
|
178
|
+
# once it stops appearing, flip this to true.
|
|
179
|
+
config.oauth.strict_redirect_uri_matching = true
|
|
180
|
+
|
|
181
|
+
# RFC 7009 — how much POST /oauth/revoke revokes.
|
|
182
|
+
# :account (default) revokes EVERY active DeviceSession for the token's
|
|
183
|
+
# subject, whichever token was presented — one client's logout signs the
|
|
184
|
+
# account out everywhere.
|
|
185
|
+
# :grant revokes only the authorization grant behind the presented token:
|
|
186
|
+
# the refresh-token family its `jti` resolves to, plus that grant's
|
|
187
|
+
# Session when one is linked.
|
|
188
|
+
config.oauth.revocation_scope = :grant
|
|
189
|
+
end
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
Under `:grant`, revoking a **stateless access token is a no-op** — this engine
|
|
193
|
+
has no per-access-token store, so it cannot invalidate one before its `exp`
|
|
194
|
+
(the response is still `200`, per RFC 7009 §2.2). Clients that need revocation
|
|
195
|
+
to bite must present the **refresh token**. `token_type_hint` is accepted and
|
|
196
|
+
unused under both settings: the lookup is by `jti`, which is type-agnostic, and
|
|
197
|
+
a wrong hint must not change the outcome.
|
|
198
|
+
|
|
199
|
+
`ServiceSession`s are never touched by `/oauth/revoke` under either setting —
|
|
200
|
+
machine credentials have their own lifecycle.
|
|
201
|
+
|
|
202
|
+
### Authenticating Opaque Session Tokens
|
|
203
|
+
|
|
204
|
+
`Session.by_token` is a **lookup**, not authentication: it matches the SHA256
|
|
205
|
+
`lookup_hash` (an index key). The credential is the BCrypt `token_digest`.
|
|
206
|
+
Use `authenticate_by_token`, which does both and compares in constant time:
|
|
207
|
+
|
|
208
|
+
```ruby
|
|
209
|
+
session = StandardId::Session.api_compatible.active.authenticate_by_token(token)
|
|
210
|
+
raise Unauthorized if session.nil?
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
It honours the current scope, returns `nil` (never raises) for a blank,
|
|
214
|
+
unknown, mismatched, or malformed-digest token, and has an instance-level
|
|
215
|
+
counterpart, `session.authenticate_token(token)`.
|
|
216
|
+
|
|
167
217
|
### Custom Token Claims
|
|
168
218
|
|
|
169
219
|
You can add additional JWT claims for any token issued through the OAuth token endpoint by mapping scopes to claim names and providing callbacks to resolve each claim. Scopes listed in `oauth.scope_claims` are evaluated against the requested token scopes; when a scope matches, every claim listed for that scope is resolved via the callable defined in `oauth.claim_resolvers`.
|
|
@@ -2,6 +2,8 @@ module StandardId
|
|
|
2
2
|
module Api
|
|
3
3
|
module Oauth
|
|
4
4
|
class RevocationsController < BaseController
|
|
5
|
+
VALID_REVOCATION_SCOPES = %i[account grant].freeze
|
|
6
|
+
|
|
5
7
|
public_controller
|
|
6
8
|
|
|
7
9
|
skip_before_action :validate_content_type!
|
|
@@ -12,6 +14,12 @@ module StandardId
|
|
|
12
14
|
# Accepts a token and optional token_type_hint parameter.
|
|
13
15
|
# Always responds with 200 OK regardless of whether the token
|
|
14
16
|
# was valid or revocation was successful (per RFC 7009 Section 2.1).
|
|
17
|
+
#
|
|
18
|
+
# How much gets revoked is controlled by
|
|
19
|
+
# `config.oauth.revocation_scope` (:account — historical, revoke every
|
|
20
|
+
# active DeviceSession for the subject; :grant — revoke only the
|
|
21
|
+
# authorization grant the presented token belongs to). See the schema
|
|
22
|
+
# comment on that field.
|
|
15
23
|
def create
|
|
16
24
|
token = params[:token]
|
|
17
25
|
head :ok and return if token.blank?
|
|
@@ -19,77 +27,155 @@ module StandardId
|
|
|
19
27
|
payload = StandardId::JwtService.decode(token)
|
|
20
28
|
head :ok and return unless payload&.dig(:sub)
|
|
21
29
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
# token_type_hint is accepted but ignored — we always attempt
|
|
29
|
-
# revocation via sub claim regardless of token type (RFC 7009 §2.1)
|
|
30
|
-
revoked_sessions = sessions.to_a
|
|
31
|
-
if revoked_sessions.any?
|
|
32
|
-
now = Time.current
|
|
33
|
-
session_ids = revoked_sessions.map(&:id)
|
|
34
|
-
|
|
35
|
-
# Bulk-revoke in two queries (one UPDATE per table) instead of
|
|
36
|
-
# issuing session.revoke! per row, which would be O(N) UPDATEs plus
|
|
37
|
-
# another O(N) cascades to refresh_tokens.
|
|
38
|
-
#
|
|
39
|
-
# Tradeoff: update_all skips ActiveRecord callbacks, so the per-row
|
|
40
|
-
# SESSION_REVOKED event emitted by Session#revoke! is not fired
|
|
41
|
-
# automatically. We re-emit it explicitly below so audit-trail
|
|
42
|
-
# subscribers (account status/locking, etc.) still see one event
|
|
43
|
-
# per revoked session — the semantics are preserved, only the SQL
|
|
44
|
-
# shape has changed.
|
|
45
|
-
ActiveRecord::Base.transaction do
|
|
46
|
-
StandardId::Session.where(id: session_ids).update_all(revoked_at: now)
|
|
47
|
-
StandardId::RefreshToken
|
|
48
|
-
.where(session_id: session_ids, revoked_at: nil)
|
|
49
|
-
.update_all(revoked_at: now)
|
|
50
|
-
end
|
|
30
|
+
if revocation_scope == :grant
|
|
31
|
+
revoke_presented_grant!(payload)
|
|
32
|
+
else
|
|
33
|
+
revoke_account_sessions!(payload[:sub])
|
|
34
|
+
end
|
|
51
35
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
36
|
+
head :ok
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
def revocation_scope
|
|
42
|
+
configured = StandardId.config.oauth.revocation_scope&.to_sym
|
|
43
|
+
return configured if VALID_REVOCATION_SCOPES.include?(configured)
|
|
44
|
+
|
|
45
|
+
StandardId.logger&.warn(
|
|
46
|
+
"[StandardId::Revocations] Unknown config.oauth.revocation_scope " \
|
|
47
|
+
"#{configured.inspect}; falling back to :account. Valid values: " \
|
|
48
|
+
"#{VALID_REVOCATION_SCOPES.inspect}"
|
|
49
|
+
)
|
|
50
|
+
:account
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# :account — the historical blast radius. Every active DeviceSession
|
|
54
|
+
# for the subject is revoked, whichever token was presented.
|
|
55
|
+
# ServiceSessions are deliberately excluded: they are machine
|
|
56
|
+
# credentials with their own lifecycle, not something an interactive
|
|
57
|
+
# client's logout should silently kill.
|
|
58
|
+
def revoke_account_sessions!(account_id)
|
|
59
|
+
sessions = StandardId::DeviceSession.where(account_id: account_id).active.to_a
|
|
60
|
+
return if sessions.empty?
|
|
61
|
+
|
|
62
|
+
refresh_tokens_revoked = revoke_sessions!(sessions)
|
|
63
|
+
emit_token_revoked(
|
|
64
|
+
account_id: account_id,
|
|
65
|
+
sessions_revoked: sessions.size,
|
|
66
|
+
refresh_tokens_revoked: refresh_tokens_revoked
|
|
67
|
+
)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# :grant — RFC 7009 §2.1: revoke the presented token and the tokens
|
|
71
|
+
# issued from the same authorization grant, nothing else.
|
|
72
|
+
#
|
|
73
|
+
# The lookup is by `jti`, which is type-agnostic: refresh tokens are
|
|
74
|
+
# persisted as SHA256(jti) in standard_id_refresh_tokens, access tokens
|
|
75
|
+
# are not persisted at all. That is why token_type_hint stays unused —
|
|
76
|
+
# it is an optimisation hint for servers keeping separate per-type
|
|
77
|
+
# stores (RFC 7009 §2.1), and a wrong hint must not change the outcome.
|
|
78
|
+
# A presented access token therefore resolves to nothing and revokes
|
|
79
|
+
# nothing: this engine cannot invalidate a stateless JWT before its
|
|
80
|
+
# exp. Clients that need revocation to bite present the refresh token.
|
|
81
|
+
def revoke_presented_grant!(payload)
|
|
82
|
+
jti = payload[:jti]
|
|
83
|
+
return if jti.blank?
|
|
84
|
+
|
|
85
|
+
# Eager-load both associations: host apps run with
|
|
86
|
+
# `strict_loading_by_default` (jumpdrive-web, luminality-web), where a
|
|
87
|
+
# lazy `record.session` / `record.account` raises in their test env.
|
|
88
|
+
record = StandardId::RefreshToken
|
|
89
|
+
.where(token_digest: StandardId::RefreshToken.digest_for(jti))
|
|
90
|
+
.includes(:session, :account)
|
|
91
|
+
.first
|
|
92
|
+
# A jti that resolves to another subject's grant must never revoke
|
|
93
|
+
# it — the token was signature-verified, but bind the two identities
|
|
94
|
+
# anyway rather than trusting a single claim.
|
|
95
|
+
return if record.nil? || record.account_id.to_s != payload[:sub].to_s
|
|
96
|
+
|
|
97
|
+
refresh_tokens_revoked = record.revoke_family!
|
|
98
|
+
|
|
99
|
+
# The grant's Session, when the host app materialised one
|
|
100
|
+
# (RefreshToken#session_id is nil unless the flow sets it).
|
|
101
|
+
session = record.session
|
|
102
|
+
sessions = (session && session.active?) ? [session] : []
|
|
103
|
+
refresh_tokens_revoked += revoke_sessions!(sessions, account: record.account)
|
|
77
104
|
|
|
105
|
+
emit_token_revoked(
|
|
106
|
+
account_id: payload[:sub],
|
|
107
|
+
sessions_revoked: sessions.size,
|
|
108
|
+
refresh_tokens_revoked: refresh_tokens_revoked
|
|
109
|
+
)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Bulk-revoke in two queries (one UPDATE per table) instead of
|
|
113
|
+
# issuing session.revoke! per row, which would be O(N) UPDATEs plus
|
|
114
|
+
# another O(N) cascades to refresh_tokens.
|
|
115
|
+
#
|
|
116
|
+
# Tradeoff: update_all skips ActiveRecord callbacks, so the per-row
|
|
117
|
+
# SESSION_REVOKED event emitted by Session#revoke! is not fired
|
|
118
|
+
# automatically. We re-emit it explicitly below so audit-trail
|
|
119
|
+
# subscribers (account status/locking, etc.) still see one event
|
|
120
|
+
# per revoked session — the semantics are preserved, only the SQL
|
|
121
|
+
# shape has changed.
|
|
122
|
+
#
|
|
123
|
+
# @return [Integer] number of refresh-token rows revoked by the cascade
|
|
124
|
+
def revoke_sessions!(sessions, account: nil)
|
|
125
|
+
return 0 if sessions.empty?
|
|
126
|
+
|
|
127
|
+
now = Time.current
|
|
128
|
+
session_ids = sessions.map(&:id)
|
|
129
|
+
refresh_tokens_revoked = 0
|
|
130
|
+
|
|
131
|
+
ActiveRecord::Base.transaction do
|
|
132
|
+
StandardId::Session.where(id: session_ids).update_all(revoked_at: now)
|
|
133
|
+
refresh_tokens_revoked = StandardId::RefreshToken
|
|
134
|
+
.where(session_id: session_ids, revoked_at: nil)
|
|
135
|
+
.update_all(revoked_at: now)
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# DB state is already committed above; event publishing is best-effort
|
|
139
|
+
# audit emission. A failing subscriber must not short-circuit the loop
|
|
140
|
+
# and leave later sessions without their SESSION_REVOKED event, which
|
|
141
|
+
# would permanently desync audit-trail consumers from the DB.
|
|
142
|
+
#
|
|
143
|
+
# All sessions here belong to the same account (both callers scope by
|
|
144
|
+
# account), so we load the account once rather than calling
|
|
145
|
+
# session.account per row, which would issue N extra SELECTs.
|
|
146
|
+
shared_account = account || sessions.first.account
|
|
147
|
+
sessions.each do |session|
|
|
148
|
+
session.revoked_at = now
|
|
78
149
|
begin
|
|
79
150
|
StandardId::Events.publish(
|
|
80
|
-
StandardId::Events::
|
|
81
|
-
|
|
82
|
-
|
|
151
|
+
StandardId::Events::SESSION_REVOKED,
|
|
152
|
+
session: session,
|
|
153
|
+
account: shared_account,
|
|
154
|
+
reason: "token_revocation"
|
|
83
155
|
)
|
|
84
156
|
rescue StandardError => e
|
|
85
157
|
StandardId.logger.error(
|
|
86
|
-
"[StandardId::Revocations] Failed to publish
|
|
87
|
-
"for
|
|
158
|
+
"[StandardId::Revocations] Failed to publish SESSION_REVOKED " \
|
|
159
|
+
"for session #{session.id}: #{e.class}: #{e.message}"
|
|
88
160
|
)
|
|
89
161
|
end
|
|
90
162
|
end
|
|
91
163
|
|
|
92
|
-
|
|
164
|
+
refresh_tokens_revoked
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def emit_token_revoked(account_id:, sessions_revoked:, refresh_tokens_revoked:)
|
|
168
|
+
StandardId::Events.publish(
|
|
169
|
+
StandardId::Events::OAUTH_TOKEN_REVOKED,
|
|
170
|
+
account_id: account_id,
|
|
171
|
+
sessions_revoked: sessions_revoked,
|
|
172
|
+
refresh_tokens_revoked: refresh_tokens_revoked
|
|
173
|
+
)
|
|
174
|
+
rescue StandardError => e
|
|
175
|
+
StandardId.logger.error(
|
|
176
|
+
"[StandardId::Revocations] Failed to publish OAUTH_TOKEN_REVOKED " \
|
|
177
|
+
"for account #{account_id}: #{e.class}: #{e.message}"
|
|
178
|
+
)
|
|
93
179
|
end
|
|
94
180
|
end
|
|
95
181
|
end
|
|
@@ -75,6 +75,19 @@ module StandardId
|
|
|
75
75
|
return true if destination.start_with?("/") && !destination.start_with?("//")
|
|
76
76
|
return true if same_origin_url?(destination)
|
|
77
77
|
|
|
78
|
+
allow_listed_redirect?(destination)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Whether the host has explicitly allow-listed `destination`'s prefix via
|
|
82
|
+
# `StandardId.config.allowed_redirect_url_prefixes`. This is the only way a
|
|
83
|
+
# cross-origin or custom-scheme destination (e.g. "myapp://done") clears
|
|
84
|
+
# `safe_destination?`, so it is also exactly the condition under which
|
|
85
|
+
# `redirect_to` needs `allow_other_host: true` — Rails otherwise raises
|
|
86
|
+
# UnsafeRedirectError. Mirrors the pattern already used by the social
|
|
87
|
+
# callback (Web::Auth::Callback::ProvidersController).
|
|
88
|
+
def allow_listed_redirect?(destination)
|
|
89
|
+
return false if destination.blank?
|
|
90
|
+
|
|
78
91
|
Array(StandardId.config.allowed_redirect_url_prefixes).any? do |entry|
|
|
79
92
|
case entry
|
|
80
93
|
when Regexp then entry.match?(destination)
|
|
@@ -83,6 +96,34 @@ module StandardId
|
|
|
83
96
|
end
|
|
84
97
|
end
|
|
85
98
|
|
|
99
|
+
# Redirect a just-authenticated account to an already-validated `destination`.
|
|
100
|
+
#
|
|
101
|
+
# Uses `redirect_with_inertia` rather than `redirect_to` because the sign-in
|
|
102
|
+
# submit itself may be an Inertia XHR (hosts with `use_inertia` render the
|
|
103
|
+
# WebEngine's auth pages as Inertia components) while `destination` may not be
|
|
104
|
+
# an Inertia endpoint at all — the canonical case being the ApiEngine's
|
|
105
|
+
# /api/authorize in an OAuth flow. An Inertia client follows a 303 with its
|
|
106
|
+
# X-Inertia header still attached; inertia_rails' middleware then calls
|
|
107
|
+
# #inertia_configuration on the target controller, which ActionController::API
|
|
108
|
+
# controllers never receive (inertia_rails only includes its Controller module
|
|
109
|
+
# via `on_load(:action_controller_base)`), raising NoMethodError → 500.
|
|
110
|
+
# Emitting 409 + X-Inertia-Location instead makes the client do a full page
|
|
111
|
+
# visit, which drops the header. The destination being same-origin is not
|
|
112
|
+
# enough to make a plain redirect safe, so this deliberately keys off the
|
|
113
|
+
# request being Inertia rather than off the destination.
|
|
114
|
+
#
|
|
115
|
+
# `notice` is written to `flash` instead of being passed as a redirect option
|
|
116
|
+
# because `inertia_location` ignores redirect options — writing it to the
|
|
117
|
+
# session is what lets the message survive on both branches.
|
|
118
|
+
def redirect_after_authentication(destination, notice: nil, status: :see_other)
|
|
119
|
+
flash[:notice] = notice if notice.present?
|
|
120
|
+
|
|
121
|
+
options = { status: status }
|
|
122
|
+
options[:allow_other_host] = true if allow_listed_redirect?(destination)
|
|
123
|
+
|
|
124
|
+
redirect_with_inertia destination, **options
|
|
125
|
+
end
|
|
126
|
+
|
|
86
127
|
def same_origin_url?(destination)
|
|
87
128
|
return false unless destination.start_with?("http://", "https://")
|
|
88
129
|
URI.parse(destination).origin == URI.parse(request.base_url).origin
|
|
@@ -81,7 +81,7 @@ module StandardId
|
|
|
81
81
|
fallback = after_authentication_url
|
|
82
82
|
fallback = safe_post_signin_default unless safe_destination?(fallback)
|
|
83
83
|
destination = redirect_override || (safe_destination?(redirect_uri) ? redirect_uri : nil) || fallback
|
|
84
|
-
|
|
84
|
+
redirect_after_authentication destination, notice: "Successfully signed in"
|
|
85
85
|
else
|
|
86
86
|
flash.now[:alert] = "Invalid email or password"
|
|
87
87
|
render_with_inertia action: :show, props: auth_page_props(passwordless_enabled: passwordless_enabled?), status: :unprocessable_content
|
|
@@ -78,7 +78,7 @@ module StandardId
|
|
|
78
78
|
# blocks Array/Hash but not "https://evil.com/phish". Validate before redirect.
|
|
79
79
|
fallback = after_authentication_url
|
|
80
80
|
destination = redirect_override || (safe_destination?(fallback) ? fallback : safe_post_signin_default)
|
|
81
|
-
|
|
81
|
+
redirect_after_authentication destination, notice: "Successfully signed in"
|
|
82
82
|
rescue StandardId::AuthenticationDenied => e
|
|
83
83
|
session.delete(:standard_id_otp_payload)
|
|
84
84
|
handle_authentication_denied(e, account: account, newly_created: newly_created)
|
|
@@ -56,7 +56,9 @@ module StandardId
|
|
|
56
56
|
fallback = after_authentication_url
|
|
57
57
|
fallback = safe_post_signin_default unless safe_destination?(fallback)
|
|
58
58
|
destination = redirect_override || (safe_destination?(redirect_uri) ? redirect_uri : nil) || fallback
|
|
59
|
-
|
|
59
|
+
# status: :found preserves this action's long-standing 302 (the other
|
|
60
|
+
# post-auth redirects use 303); only the Inertia branch changes here.
|
|
61
|
+
redirect_after_authentication destination, notice: "Account created successfully", status: :found
|
|
60
62
|
else
|
|
61
63
|
@redirect_uri = string_param(:redirect_uri) || after_authentication_url
|
|
62
64
|
@connection = params[:connection]
|
|
@@ -19,6 +19,48 @@ module StandardId
|
|
|
19
19
|
where(lookup_hash:)
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
+
# Authenticate an opaque session token.
|
|
23
|
+
#
|
|
24
|
+
# `by_token` is NOT authentication on its own: it matches the SHA256
|
|
25
|
+
# `lookup_hash`, which exists only to find the candidate row from an
|
|
26
|
+
# indexed column. The credential is the BCrypt `token_digest`, and every
|
|
27
|
+
# consumer previously had to remember to verify it by hand (and to rescue
|
|
28
|
+
# BCrypt::Errors::InvalidHash). This is that step, done once, here.
|
|
29
|
+
#
|
|
30
|
+
# Honours the current scope, so callers keep their own filters:
|
|
31
|
+
#
|
|
32
|
+
# StandardId::Session.api_compatible.active.authenticate_by_token(token)
|
|
33
|
+
#
|
|
34
|
+
# @param token [String, nil] the raw token presented by the client
|
|
35
|
+
# @return [StandardId::Session, nil] the session, or nil when the token is
|
|
36
|
+
# blank, matches no row, or fails the digest verification
|
|
37
|
+
def self.authenticate_by_token(token)
|
|
38
|
+
return nil if token.blank?
|
|
39
|
+
|
|
40
|
+
session = by_token(token).first
|
|
41
|
+
return nil if session.nil?
|
|
42
|
+
|
|
43
|
+
session.authenticate_token(token) ? session : nil
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Timing-safe verification of `token` against this session's stored
|
|
47
|
+
# BCrypt digest. Re-hashes the presented token with the stored salt and
|
|
48
|
+
# compares the two digests with a constant-time compare, so the response
|
|
49
|
+
# time carries no information about how much of the digest matched.
|
|
50
|
+
#
|
|
51
|
+
# @return [Boolean] false for a blank or malformed digest — never raises.
|
|
52
|
+
def authenticate_token(token)
|
|
53
|
+
return false if token.blank? || token_digest.blank?
|
|
54
|
+
|
|
55
|
+
stored = BCrypt::Password.new(token_digest)
|
|
56
|
+
ActiveSupport::SecurityUtils.secure_compare(
|
|
57
|
+
stored.to_s,
|
|
58
|
+
BCrypt::Engine.hash_secret(token, stored.salt)
|
|
59
|
+
)
|
|
60
|
+
rescue BCrypt::Errors::InvalidHash, BCrypt::Errors::InvalidSalt
|
|
61
|
+
false
|
|
62
|
+
end
|
|
63
|
+
|
|
22
64
|
attr_reader :token
|
|
23
65
|
|
|
24
66
|
before_validation :generate_token, :generate_token_digest, :generate_lookup_hash, on: :create
|
|
@@ -318,6 +318,45 @@ StandardId::ConfigSchema.define do
|
|
|
318
318
|
# "client_secret_basic" — confidential, secret via HTTP Basic
|
|
319
319
|
# "client_secret_post" — confidential, secret in the request body
|
|
320
320
|
field :dynamic_registration_default_auth_method, type: :string, default: "none"
|
|
321
|
+
|
|
322
|
+
# RFC 6749 §4.1.3 strictness at the token endpoint.
|
|
323
|
+
#
|
|
324
|
+
# The spec is unambiguous: if `redirect_uri` was included in the
|
|
325
|
+
# authorization request, the client MUST send it again at the token
|
|
326
|
+
# endpoint and the two values MUST be identical. Historically this engine
|
|
327
|
+
# only compared the values when the client bothered to send one, so a
|
|
328
|
+
# client could silently skip the check by omitting the parameter.
|
|
329
|
+
#
|
|
330
|
+
# When true, redeeming a code that was minted WITH a redirect_uri fails
|
|
331
|
+
# with invalid_grant unless the token request repeats the identical value.
|
|
332
|
+
# Codes minted WITHOUT a redirect_uri are unaffected either way.
|
|
333
|
+
#
|
|
334
|
+
# Default is false — the historical, lenient behaviour — because flipping
|
|
335
|
+
# it silently would break any live client that omits the parameter, and
|
|
336
|
+
# this gem is consumed by several production apps. In lenient mode the
|
|
337
|
+
# engine logs a warning naming the client_id every time a code with a
|
|
338
|
+
# stored redirect_uri is redeemed without one, so deployments can confirm
|
|
339
|
+
# no client relies on the leniency before opting in. Set it to true.
|
|
340
|
+
field :strict_redirect_uri_matching, type: :boolean, default: false
|
|
341
|
+
|
|
342
|
+
# Blast radius of POST /oauth/revoke (RFC 7009).
|
|
343
|
+
#
|
|
344
|
+
# :account (default) — revoke every active DeviceSession belonging to the
|
|
345
|
+
# token's `sub`, regardless of which token was presented. Historical
|
|
346
|
+
# behaviour. Fail-safe (it over-revokes) but it means a single client's
|
|
347
|
+
# logout signs the account out on every device.
|
|
348
|
+
#
|
|
349
|
+
# :grant — revoke only the authorization grant the presented token
|
|
350
|
+
# belongs to: the refresh-token family the token's `jti` resolves to,
|
|
351
|
+
# plus that refresh token's Session when one is linked. A presented
|
|
352
|
+
# ACCESS token resolves to no stored artifact (access tokens are
|
|
353
|
+
# stateless JWTs this engine cannot individually invalidate), so in
|
|
354
|
+
# :grant mode revoking an access token is a no-op returning 200 per
|
|
355
|
+
# RFC 7009 §2.2 — clients that want revocation to bite must present
|
|
356
|
+
# the refresh token.
|
|
357
|
+
#
|
|
358
|
+
# An unrecognised value logs a warning and falls back to :account.
|
|
359
|
+
field :revocation_scope, type: :symbol, default: :account
|
|
321
360
|
end
|
|
322
361
|
|
|
323
362
|
scope :social do
|
|
@@ -22,9 +22,7 @@ module StandardId
|
|
|
22
22
|
raise StandardId::InvalidGrantError, "Invalid or expired authorization code"
|
|
23
23
|
end
|
|
24
24
|
|
|
25
|
-
|
|
26
|
-
raise StandardId::InvalidGrantError, "Redirect URI mismatch"
|
|
27
|
-
end
|
|
25
|
+
validate_redirect_uri!
|
|
28
26
|
|
|
29
27
|
# Fail closed: a public client's only authentication factor is PKCE, so a
|
|
30
28
|
# code minted without a code_challenge offers no client authentication at
|
|
@@ -44,6 +42,42 @@ module StandardId
|
|
|
44
42
|
|
|
45
43
|
private
|
|
46
44
|
|
|
45
|
+
# RFC 6749 §4.1.3: when the authorization request carried a redirect_uri,
|
|
46
|
+
# the token request MUST repeat it and the values MUST be identical.
|
|
47
|
+
#
|
|
48
|
+
# A presented value is always compared (that has always been the case).
|
|
49
|
+
# An OMITTED value is the interesting one: strictly it must be rejected,
|
|
50
|
+
# but rejecting it unconditionally would break any live client that
|
|
51
|
+
# relies on the historical leniency, so it is gated on
|
|
52
|
+
# `config.oauth.strict_redirect_uri_matching` (default false) and logged
|
|
53
|
+
# loudly in the meantime. See the schema comment for the migration path.
|
|
54
|
+
def validate_redirect_uri!
|
|
55
|
+
stored = @authorization_code.redirect_uri
|
|
56
|
+
presented = params[:redirect_uri]
|
|
57
|
+
|
|
58
|
+
if presented.present?
|
|
59
|
+
raise StandardId::InvalidGrantError, "Redirect URI mismatch" if stored != presented
|
|
60
|
+
return
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
return if stored.blank?
|
|
64
|
+
|
|
65
|
+
unless StandardId.config.oauth.strict_redirect_uri_matching
|
|
66
|
+
# Rails.logger, not StandardId.logger: the rest of lib/standard_id
|
|
67
|
+
# logs through Rails directly, and config.logger is host-supplied
|
|
68
|
+
# (it need not be a Logger at all).
|
|
69
|
+
Rails.logger.warn(
|
|
70
|
+
"[StandardId::AuthorizationCodeFlow] client #{params[:client_id]} redeemed an " \
|
|
71
|
+
"authorization code minted with redirect_uri without sending one at the token " \
|
|
72
|
+
"endpoint. RFC 6749 §4.1.3 requires it; this is accepted only because " \
|
|
73
|
+
"config.oauth.strict_redirect_uri_matching is false."
|
|
74
|
+
)
|
|
75
|
+
return
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
raise StandardId::InvalidGrantError, "Redirect URI mismatch"
|
|
79
|
+
end
|
|
80
|
+
|
|
47
81
|
def emit_code_consumed
|
|
48
82
|
StandardId::Events.publish(
|
|
49
83
|
StandardId::Events::OAUTH_CODE_CONSUMED,
|
data/lib/standard_id/version.rb
CHANGED
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.30.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Jaryl Sim
|
|
@@ -108,6 +108,7 @@ executables: []
|
|
|
108
108
|
extensions: []
|
|
109
109
|
extra_rdoc_files: []
|
|
110
110
|
files:
|
|
111
|
+
- CHANGELOG.md
|
|
111
112
|
- LICENSE
|
|
112
113
|
- README.md
|
|
113
114
|
- Rakefile
|