standard_id 0.46.0 → 0.47.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 2a825be521399d2fbb91a820cbc0da601d24865a12fc549d1a8697ea1d6c97cb
4
- data.tar.gz: da555da47e6a9ae99f1c888e5ef93c2fc4a02d21b11993300c67d02df6ef5d01
3
+ metadata.gz: 8c487f3a2265110f88e093a27b30e78742ecc04cbf460e94cbe35df02dbd2621
4
+ data.tar.gz: 6ad0ecc9edb3a9c2e5cbc51096ac08f6164b7bcb4671098f85806d3a1f605f6e
5
5
  SHA512:
6
- metadata.gz: f54bf303a4cc32095b3117df9636b9f3e04ebe4483de5b79f513a0089b44d45a36a3ff7dbaf54d0858a4c29c657e11452f9e7b4ffe89e7418fbf55598ecf75a7
7
- data.tar.gz: 157b2b4ffdca2f4a22eaf78207ba28579ef71c59693d6f8587159be19bd36d9540c008e9ede3bbbcc9f313f10d5ef762b9553e898428ffe4f37f7f513c83c2ef
6
+ metadata.gz: 818624622fef93866c46945cbe85c5a5e5898aea0d9770dab65596ab2524f03470f3f51b26286cea93a3cca639823be9566a7ef270cdbeec34794c6ebd4772e0
7
+ data.tar.gz: cea418b10ae16fa7d90caa16dfc4c394aa9445d4b6085380d38950c15c1d381a17b7bace3e786327fd21ac82b165525a95ad73b72a087b6cbb0b3c78ef13bf4a
data/CHANGELOG.md CHANGED
@@ -7,6 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.47.0] - 2026-10-19
11
+
12
+ ### Added
13
+
14
+ - **`c.oauth.disabled_grant_types`** (array, default `[]`): grant types the token endpoint refuses even though the engine implements them. A disabled grant answers exactly like an unknown one — 400 `unsupported_grant_type`, "Unsupported grant_type: <name>" — before the flow's parameters are read, so a caller cannot distinguish "disabled" from "never implemented", and both discovery documents drop it from `grant_types_supported`. Hosts that routed email+password sign-in through their own API and closed the resource-owner password grant with a `prepend` on `PasswordFlow` / `TokensController` (luminality-web `config/initializers/standard_id_password_grant.rb`) can replace the patch with `c.oauth.disabled_grant_types = %w[password]`.
15
+ - **`c.oauth.password_grant_requires_verified_identifier`** (boolean, default `false`): when on, `grant_type=password` refuses a credential whose identifier is unverified with the same 400 `invalid_grant` / "Invalid username or password" it gives a wrong password, and publishes `AUTHENTICATION_FAILED` with `error_code: "identifier_unverified"`. Until now the grant minted tokens for a sign-up that never confirmed its email, bypassing verification the host enforces on its other login paths. Off by default so an upgrade changes nothing; turn it on wherever the grant stays enabled.
16
+
17
+ ### Changed
18
+
19
+ - `PasswordFlow#emit_authentication_failed` takes optional `error_code:` / `error_message:` (defaults unchanged) so the verified-identifier refusal can be told apart in audit subscribers.
20
+ - `StandardId::Oauth::GrantTypes` (`.disabled`, `.disabled?(name)`) is the single reader of `oauth.disabled_grant_types`, used by both the token endpoint and the discovery documents so they cannot normalise the list differently (#375).
21
+
10
22
  ## [0.46.0] - 2026-10-05
11
23
 
12
24
  ### Added
data/README.md CHANGED
@@ -189,6 +189,24 @@ StandardId.configure do |config|
189
189
  end
190
190
  ```
191
191
 
192
+ Two further settings close the resource-owner password grant, or harden it, for
193
+ hosts whose email+password sign-in lives in their own API:
194
+
195
+ ```ruby
196
+ StandardId.configure do |config|
197
+ # Refuse grant types you do not want on the token endpoint. A disabled grant
198
+ # answers exactly like an unknown one (400 unsupported_grant_type, before the
199
+ # flow's params are read) and is dropped from `grant_types_supported` in both
200
+ # discovery documents. Values are the grant_type strings.
201
+ config.oauth.disabled_grant_types = %w[password]
202
+
203
+ # If the password grant stays enabled: refuse credentials whose identifier is
204
+ # not verified, with the same invalid_grant answer as a wrong password, and
205
+ # publish AUTHENTICATION_FAILED with error_code "identifier_unverified".
206
+ config.oauth.password_grant_requires_verified_identifier = true
207
+ end
208
+ ```
209
+
192
210
  Under `:grant`, revoking a **stateless access token is a no-op** — this engine
193
211
  has no per-access-token store, so it cannot invalidate one before its `exp`
194
212
  (the response is still `200`, per RFC 7009 §2.2). Clients that need revocation
@@ -86,7 +86,9 @@ module StandardId
86
86
  end
87
87
 
88
88
  klass = FLOW_STRATEGIES[grant_type]
89
- unless klass
89
+ # A disabled grant is indistinguishable from an unknown one: same
90
+ # error, same message, raised before the flow's params are read.
91
+ if klass.nil? || StandardId::Oauth::GrantTypes.disabled?(grant_type)
90
92
  raise StandardId::UnsupportedGrantTypeError, "Unsupported grant_type: #{grant_type}"
91
93
  end
92
94
  klass
@@ -457,6 +457,33 @@ StandardId::ConfigSchema.define do
457
457
  # immediately.
458
458
  field :introspection_enabled, type: :boolean, default: false
459
459
 
460
+ # Grant types the token endpoint refuses even though the engine implements
461
+ # them. Values are the `grant_type` strings: "authorization_code",
462
+ # "refresh_token", "client_credentials", "password", "passwordless_otp".
463
+ #
464
+ # A disabled grant answers exactly like an unknown one — 400
465
+ # `unsupported_grant_type`, "Unsupported grant_type: <name>" — before the
466
+ # flow's parameters are read, so a caller cannot tell a disabled grant from
467
+ # one that was never implemented, and the discovery documents drop it from
468
+ # `grant_types_supported`. Typical use: a host whose email+password sign-in
469
+ # runs through its own API (verification, per-email limits, generic
470
+ # answers) and must not leave the resource-owner password grant open as a
471
+ # second, weaker door to the same credentials.
472
+ #
473
+ # Empty (default) = every implemented grant is available, as before.
474
+ field :disabled_grant_types, type: :array, default: -> { [] }
475
+
476
+ # When true, the resource-owner password grant refuses a credential whose
477
+ # identifier has not been verified (`StandardId::Identifier#verified_at`
478
+ # nil) with the same 400 `invalid_grant` / "Invalid username or password"
479
+ # it gives a wrong password, and publishes AUTHENTICATION_FAILED with
480
+ # `error_code: "identifier_unverified"`. Without it a sign-up that never
481
+ # confirmed its email can mint tokens through `grant_type=password`,
482
+ # bypassing whatever verification the host enforces on its web and API
483
+ # login paths. Default false so an upgrade changes nothing; turn it on
484
+ # wherever the grant stays enabled.
485
+ field :password_grant_requires_verified_identifier, type: :boolean, default: false
486
+
460
487
  # Where the discovery documents say the ENDPOINTS live.
461
488
  #
462
489
  # The issuer and the endpoint base are different things. The issuer is a
@@ -64,7 +64,9 @@ module StandardId
64
64
  revocation_endpoint: "#{base}/oauth/revoke",
65
65
  userinfo_endpoint: "#{base}/userinfo",
66
66
  response_types_supported: %w[code],
67
- grant_types_supported: %w[authorization_code refresh_token client_credentials],
67
+ # Minus anything the host turned off with `oauth.disabled_grant_types`,
68
+ # so a client is never pointed at a grant the token endpoint refuses.
69
+ grant_types_supported: %w[authorization_code refresh_token client_credentials] - StandardId::Oauth::GrantTypes.disabled,
68
70
  subject_types_supported: %w[public],
69
71
  id_token_signing_alg_values_supported: [StandardId.config.oauth.signing_algorithm.to_s.upcase],
70
72
  # "none" advertises public-client support (PKCE-only token exchange,
@@ -0,0 +1,18 @@
1
+ module StandardId
2
+ module Oauth
3
+ # The one reader for `config.oauth.disabled_grant_types`, so the token
4
+ # endpoint (which refuses a disabled grant) and the discovery documents
5
+ # (which stop advertising it) can never normalise the list differently.
6
+ module GrantTypes
7
+ # @return [Array<String>] grant_type strings the host has turned off
8
+ def self.disabled
9
+ Array(StandardId.config.oauth.disabled_grant_types).map(&:to_s)
10
+ end
11
+
12
+ # @param name [String, Symbol, nil] a `grant_type` value
13
+ def self.disabled?(name)
14
+ disabled.include?(name.to_s)
15
+ end
16
+ end
17
+ end
18
+ end
@@ -19,6 +19,13 @@ module StandardId
19
19
  raise StandardId::InvalidGrantError, "Invalid username or password"
20
20
  end
21
21
 
22
+ # Same answer as a wrong password on purpose: the grant must not reveal
23
+ # which addresses exist but are unverified.
24
+ if unverified_identifier_refused?
25
+ emit_authentication_failed(error_code: "identifier_unverified", error_message: "Identifier not verified")
26
+ raise StandardId::InvalidGrantError, "Invalid username or password"
27
+ end
28
+
22
29
  emit_password_validated
23
30
  validate_requested_scope!
24
31
  end
@@ -33,16 +40,23 @@ module StandardId
33
40
  )
34
41
  end
35
42
 
36
- def emit_authentication_failed
43
+ def emit_authentication_failed(error_code: "invalid_credentials", error_message: "Invalid username or password")
37
44
  StandardId::Events.publish(
38
45
  StandardId::Events::AUTHENTICATION_FAILED,
39
46
  account_lookup: params[:username],
40
47
  auth_method: "password",
41
- error_code: "invalid_credentials",
42
- error_message: "Invalid username or password"
48
+ error_code: error_code,
49
+ error_message: error_message
43
50
  )
44
51
  end
45
52
 
53
+ def unverified_identifier_refused?
54
+ return false unless StandardId.config.oauth.password_grant_requires_verified_identifier
55
+
56
+ identifier = @credential&.credential&.identifier
57
+ identifier.nil? || !identifier.verified?
58
+ end
59
+
46
60
  def emit_password_validated
47
61
  StandardId::Events.publish(
48
62
  StandardId::Events::PASSWORD_VALIDATED,
@@ -1,3 +1,3 @@
1
1
  module StandardId
2
- VERSION = "0.46.0"
2
+ VERSION = "0.47.0"
3
3
  end
data/lib/standard_id.rb CHANGED
@@ -55,6 +55,7 @@ require "standard_id/oauth/subflows/traditional_code_grant"
55
55
  require "standard_id/oauth/subflows/social_login_grant"
56
56
  require "standard_id/oauth/passwordless_otp_flow"
57
57
  require "standard_id/oauth/discovery_resolver"
58
+ require "standard_id/oauth/grant_types"
58
59
  require "standard_id/oauth/discovery_document"
59
60
  require "standard_id/routing"
60
61
  require "standard_id/oauth/consent_payload"
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.46.0
4
+ version: 0.47.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jaryl Sim
@@ -287,6 +287,7 @@ files:
287
287
  - lib/standard_id/oauth/consent_payload.rb
288
288
  - lib/standard_id/oauth/discovery_document.rb
289
289
  - lib/standard_id/oauth/discovery_resolver.rb
290
+ - lib/standard_id/oauth/grant_types.rb
290
291
  - lib/standard_id/oauth/implicit_authorization_flow.rb
291
292
  - lib/standard_id/oauth/oauth_session_persistence.rb
292
293
  - lib/standard_id/oauth/password_flow.rb