standard_id 0.29.1 → 0.31.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.
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
- account_id = payload[:sub]
23
-
24
- sessions = StandardId::DeviceSession
25
- .where(account_id: account_id)
26
- .active
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
- # DB state is already committed above; event publishing is best-effort
53
- # audit emission. A failing subscriber must not short-circuit the loop
54
- # and leave later sessions without their SESSION_REVOKED event, which
55
- # would permanently desync audit-trail consumers from the DB.
56
- #
57
- # All revoked_sessions share the same account_id (we filtered by it
58
- # at line 25), so we load the account once rather than calling
59
- # session.account per row, which would issue N extra SELECTs.
60
- shared_account = revoked_sessions.first.account
61
- revoked_sessions.each do |session|
62
- session.revoked_at = now
63
- begin
64
- StandardId::Events.publish(
65
- StandardId::Events::SESSION_REVOKED,
66
- session: session,
67
- account: shared_account,
68
- reason: "token_revocation"
69
- )
70
- rescue StandardError => e
71
- StandardId.logger.error(
72
- "[StandardId::Revocations] Failed to publish SESSION_REVOKED " \
73
- "for session #{session.id}: #{e.class}: #{e.message}"
74
- )
75
- end
76
- end
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::OAUTH_TOKEN_REVOKED,
81
- account_id: account_id,
82
- sessions_revoked: revoked_sessions.size
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 OAUTH_TOKEN_REVOKED " \
87
- "for account #{account_id}: #{e.class}: #{e.message}"
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
- head :ok
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
@@ -1,7 +1,28 @@
1
1
  module StandardId
2
2
  class EmailIdentifier < Identifier
3
+ # `URI::MailTo::EMAIL_REGEXP` models the local part as one flat character
4
+ # class that happens to include `.`, so it accepts dot placements RFC 5322
5
+ # forbids in an unquoted local part: a leading dot, a trailing dot, and
6
+ # consecutive dots. `a..b@example.com` passes it.
7
+ #
8
+ # ESPs do not. Postmark rejects such an address at send time with
9
+ # `InvalidEmailRequestError`, so a typo accepted at sign-up surfaces much
10
+ # later as a failed delivery job that no operator can act on — the address
11
+ # is already stored and the user is long gone.
12
+ #
13
+ # Enforce the dot-atom rule instead: the local part is dot-separated atoms,
14
+ # each at least one character. The domain pattern is `URI::MailTo`'s,
15
+ # unchanged.
16
+ LOCAL_PART = %r{[A-Za-z0-9!\#$%&'*+/=?^_`{|}~-]+(?:\.[A-Za-z0-9!\#$%&'*+/=?^_`{|}~-]+)*}
17
+ DOMAIN = /[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?(?:\.[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?)*/
18
+ EMAIL_FORMAT = /\A#{LOCAL_PART}@#{DOMAIN}\z/
19
+
3
20
  normalizes :value, with: ->(e) { e.strip.downcase }
4
21
 
5
- validates :value, format: { with: URI::MailTo::EMAIL_REGEXP }
22
+ # Scoped to a changed value so tightening the rule cannot strand an account
23
+ # created under the looser one: an existing row keeps working until someone
24
+ # actually edits the address. Without this, saving an unrelated attribute on
25
+ # a grandfathered identifier would start raising.
26
+ validates :value, format: { with: EMAIL_FORMAT }, if: :value_changed?
6
27
  end
7
28
  end
@@ -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
- if params[:redirect_uri].present? && @authorization_code.redirect_uri != params[:redirect_uri]
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,
@@ -1,3 +1,3 @@
1
1
  module StandardId
2
- VERSION = "0.29.1"
2
+ VERSION = "0.31.0"
3
3
  end
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.29.1
4
+ version: 0.31.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