standard_id 0.22.0 → 0.24.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: 7caed55f6e5f6f70b01a7e79dabdc5ad9ddd646535f8c10bbde93158c65e32d9
4
- data.tar.gz: 7ca5e3b573b3718e722eb047f14eefffab20263ee374d23f706f278878bcc640
3
+ metadata.gz: 96bb41b62e41b340083b98a19c91913d8a1342a1895150d68595cba6a576d039
4
+ data.tar.gz: b85e690728fad61be9a1ab5fa047096cf3db2b97bea9b9403774cd53895216f6
5
5
  SHA512:
6
- metadata.gz: c68adc74ec092631a1af808cc61ea1d2d8adeb72dbaa70588cd4069edd71b94d57e6ed33f5a9fc5ca4104def616b779e9bbe44f3c9b61e9a861ebdee1eadc946
7
- data.tar.gz: 6f10a9a4916737e870798e38c96c53e7767fcf9c4e538bd5c0ca0ee43d21843ec3dff838ee384d1acb6d68336655165fc43cf970e3e8a4e65e5e75cba32fd51f
6
+ metadata.gz: 4bebc333835970bebc067517014364dce32cfa6f3d9165a64aa7b82f3464f5862439c9fd7e40d73ae6d79a478490abe749609804c168d9ae8669d29e7d6d861e
7
+ data.tar.gz: 23a59d0a655a3d933ed953923ce5f2c4f63240543b06f094faa182366c6d147e27b769c6c6230821d0a1c60501399ac7569296840255403b95953faf37a5ba8b
@@ -19,7 +19,7 @@ module StandardId
19
19
  ].freeze
20
20
 
21
21
  def callback
22
- provider_response = get_user_info_from_provider(flow: resolve_flow_for(provider.provider_name))
22
+ provider_response = fetch_provider_user_info
23
23
  social_info = provider_response[:user_info]
24
24
  provider_tokens = provider_response[:tokens]
25
25
  account = find_or_create_account_from_social(social_info)
@@ -45,6 +45,22 @@ module StandardId
45
45
 
46
46
  private
47
47
 
48
+ # Mirror of the web callback's OAuthError handling: emit
49
+ # SOCIAL_AUTH_FAILED for infrastructure-level provider failures
50
+ # (HTTP/DNS/SSL/timeouts surfaced as OAuthError by provider
51
+ # implementations) so host apps can observe provider outages on the
52
+ # API flow too. Scoped to the provider call — OAuthError subclasses
53
+ # raised later in the flow (SocialLinkError, InvalidRequestError,
54
+ # ...) are policy/client errors, not infrastructure failures, and
55
+ # must not emit. The error re-raises into the standard
56
+ # handle_oauth_error JSON response.
57
+ def fetch_provider_user_info
58
+ get_user_info_from_provider(flow: resolve_flow_for(provider.provider_name))
59
+ rescue StandardId::OAuthError => e
60
+ emit_social_auth_failed(e)
61
+ raise
62
+ end
63
+
48
64
  def resolve_flow_for(connection)
49
65
  return :mobile unless connection == "apple"
50
66
 
@@ -21,6 +21,7 @@ module StandardId
21
21
  }.freeze
22
22
 
23
23
  before_action :extract_client_credentials_from_basic_auth
24
+ before_action :enforce_per_audience_rate_limit, only: :create
24
25
 
25
26
  def create
26
27
  response_data = flow_strategy_class.new(flow_strategy_params, request).execute
@@ -29,6 +30,31 @@ module StandardId
29
30
 
30
31
  private
31
32
 
33
+ # Per-audience tightening on top of the global api_token_per_ip
34
+ # ceiling (rate_limits.api_token_per_audience_per_ip). Hand-rolled
35
+ # rather than the Rails rate_limit DSL on purpose: the DSL counts
36
+ # every request that reaches the action — a `by:` block returning nil
37
+ # does NOT exempt a request, it collapses into a shared bucket keyed
38
+ # without the discriminator (["rate-limit", scope, name, nil].compact),
39
+ # so one audience's rule would throttle every other audience's
40
+ # traffic. Here only requests that target a configured audience
41
+ # increment that audience's per-IP counter.
42
+ def enforce_per_audience_rate_limit
43
+ limits = StandardId.config.rate_limits.api_token_per_audience_per_ip
44
+ return if limits.blank?
45
+
46
+ Array(params[:audience]).each do |audience|
47
+ next unless audience.is_a?(String)
48
+
49
+ cap = limits[audience] || limits[audience.to_sym]
50
+ next if cap.blank?
51
+
52
+ cache_key = "rate-limit:#{self.class.controller_path}:api_token_per_audience:#{audience}:#{request.remote_ip}"
53
+ count = StandardId::RateLimitHandling::RATE_LIMIT_STORE.increment(cache_key, 1, expires_in: 15.minutes)
54
+ raise ActionController::TooManyRequests if count && count > cap.to_i
55
+ end
56
+ end
57
+
32
58
  # Support HTTP Basic authentication for client credentials (RFC 6749 Section 2.3.1)
33
59
  def extract_client_credentials_from_basic_auth
34
60
  auth_header = request.headers["Authorization"]
@@ -84,11 +84,26 @@ module StandardId
84
84
  .new(@consent_request, request, current_account: current_account)
85
85
  .execute
86
86
 
87
- redirect_to result[:redirect_to], status: result[:status] || :found, allow_other_host: true
87
+ redirect_out(result[:redirect_to], status: result[:status] || :found)
88
88
  end
89
89
 
90
90
  def deny!
91
- redirect_to denied_redirect_uri, status: :found, allow_other_host: true
91
+ redirect_out(denied_redirect_uri, status: :found)
92
+ end
93
+
94
+ # Redirect to the OAuth client's (external) redirect_uri in a way that
95
+ # works for BOTH plain-browser and Inertia consumers of the consent
96
+ # screen. An Inertia visit is an XHR — it cannot follow a 302 to a
97
+ # non-Inertia, cross-origin URL (the browser stays on the consent page),
98
+ # so for Inertia requests we emit an Inertia-Location (409 +
99
+ # X-Inertia-Location) which the client turns into a hard `window.location`
100
+ # navigation. Plain (ERB) form posts keep the ordinary redirect.
101
+ def redirect_out(url, status: :found)
102
+ if request.respond_to?(:inertia?) && request.inertia?
103
+ inertia_location(url)
104
+ else
105
+ redirect_to url, status: status, allow_other_host: true
106
+ end
92
107
  end
93
108
 
94
109
  def denied_redirect_uri
@@ -368,6 +368,11 @@ StandardId.configure do |c|
368
368
  # c.rate_limits.api_passwordless_start_per_target = 5 # per 15 minutes
369
369
  # c.rate_limits.api_token_per_ip = 30 # per 15 minutes
370
370
 
371
+ # Optional per-audience tightening on top of api_token_per_ip. Only token
372
+ # requests targeting a configured audience count toward its cap (per IP,
373
+ # per 15 minutes); unlisted audiences are governed by the global ceiling.
374
+ # c.rate_limits.api_token_per_audience_per_ip = { "mobile_app" => 10 }
375
+
371
376
  # ---------------------------------------------------------------------------
372
377
  # Observability
373
378
  # ---------------------------------------------------------------------------
@@ -289,6 +289,21 @@ StandardId::ConfigSchema.define do
289
289
  # silently failing the model's presence validation — so misconfiguration is
290
290
  # caught loudly at request time.
291
291
  field :dynamic_registration_owner, type: :any, default: nil
292
+
293
+ # Default `token_endpoint_auth_method` applied to clients created via RFC 7591
294
+ # Dynamic Client Registration when the request omits `token_endpoint_auth_method`.
295
+ #
296
+ # Controls whether self-registered clients default to PUBLIC (PKCE-only, no
297
+ # secret) or to a CONFIDENTIAL secret-bearing method. The default `"none"`
298
+ # preserves the historical behaviour (DCR clients are public unless they ask
299
+ # for a secret) and is the right default for interactive/native/MCP clients,
300
+ # which cannot keep a secret.
301
+ #
302
+ # Valid values (validated at use in StandardId::Oauth::ClientRegistration):
303
+ # "none" — public client, authenticates via PKCE alone
304
+ # "client_secret_basic" — confidential, secret via HTTP Basic
305
+ # "client_secret_post" — confidential, secret in the request body
306
+ field :dynamic_registration_default_auth_method, type: :string, default: "none"
292
307
  end
293
308
 
294
309
  scope :social do
@@ -328,6 +343,15 @@ StandardId::ConfigSchema.define do
328
343
  field :api_passwordless_start_per_target, type: :integer, default: 5 # per 15 minutes
329
344
  field :api_token_per_ip, type: :integer, default: 30 # per 15 minutes
330
345
 
346
+ # Optional per-audience tightening on top of the api_token_per_ip
347
+ # ceiling. A Hash of audience => max token requests per IP per 15
348
+ # minutes, e.g. `{ "mobile_app" => 10, "partner_api" => 30 }`. Only
349
+ # requests targeting a configured audience count toward that audience's
350
+ # limit; audiences without an entry are governed solely by the global
351
+ # api_token_per_ip ceiling. A request must pass both its audience cap
352
+ # and the global cap.
353
+ field :api_token_per_audience_per_ip, type: :hash, default: -> { {} }
354
+
331
355
  # Dynamic client registration (RFC 7591) — throttle the open registration
332
356
  # endpoint by IP so an enabled deployment can't be flooded with client rows.
333
357
  field :dynamic_registration_per_ip, type: :integer, default: 10 # per hour
@@ -1,11 +1,21 @@
1
1
  module StandardId
2
2
  module Oauth
3
3
  class AuthorizationCodeFlow < TokenGrantFlow
4
- expect_params :client_id, :client_secret, :code
5
- permit_params :redirect_uri, :code_verifier
4
+ expect_params :client_id, :code
5
+ permit_params :client_secret, :redirect_uri, :code_verifier
6
6
 
7
7
  def authenticate!
8
- @credential = validate_client_secret!(params[:client_id], params[:client_secret])
8
+ @client = StandardId::ClientApplication.find_by(client_id: params[:client_id])
9
+ raise StandardId::InvalidClientError, "Client authentication failed" if @client.nil?
10
+
11
+ # Confidential clients authenticate with a client secret. Public clients
12
+ # (e.g. native/SPA/MCP clients per RFC 8252 / OAuth 2.1) cannot keep a
13
+ # secret and authenticate via PKCE alone — they MUST NOT send one.
14
+ if @client.confidential?
15
+ @credential = validate_client_secret!(params[:client_id], params[:client_secret])
16
+ elsif params[:client_secret].present?
17
+ raise StandardId::InvalidClientError, "Public clients must not send a client_secret"
18
+ end
9
19
 
10
20
  @authorization_code = find_authorization_code(params[:code])
11
21
  unless @authorization_code&.valid_for_client?(params[:client_id])
@@ -16,6 +26,14 @@ module StandardId
16
26
  raise StandardId::InvalidGrantError, "Redirect URI mismatch"
17
27
  end
18
28
 
29
+ # Fail closed: a public client's only authentication factor is PKCE, so a
30
+ # code minted without a code_challenge offers no client authentication at
31
+ # all. (pkce_valid? returns true when code_challenge is blank, which is
32
+ # safe for confidential clients but would be a bypass for public ones.)
33
+ if @client.public? && @authorization_code.code_challenge.blank?
34
+ raise StandardId::InvalidGrantError, "PKCE is required for public clients"
35
+ end
36
+
19
37
  unless @authorization_code.pkce_valid?(params[:code_verifier])
20
38
  raise StandardId::InvalidGrantError, "Invalid PKCE code_verifier"
21
39
  end
@@ -30,7 +48,7 @@ module StandardId
30
48
  StandardId::Events.publish(
31
49
  StandardId::Events::OAUTH_CODE_CONSUMED,
32
50
  authorization_code: @authorization_code,
33
- client_id: @credential.client_id,
51
+ client_id: @client.client_id,
34
52
  account: @authorization_code.account
35
53
  )
36
54
  end
@@ -40,7 +58,7 @@ module StandardId
40
58
  end
41
59
 
42
60
  def client_id
43
- @credential.client_id
61
+ @client.client_id
44
62
  end
45
63
 
46
64
  def token_scope
@@ -60,7 +78,7 @@ module StandardId
60
78
  end
61
79
 
62
80
  def token_client
63
- @credential&.client_application
81
+ @client
64
82
  end
65
83
 
66
84
  def token_account
@@ -25,7 +25,7 @@ module StandardId
25
25
  # token_endpoint_auth_method -> client_type mapping.
26
26
  PUBLIC_AUTH_METHOD = "none".freeze
27
27
  CONFIDENTIAL_AUTH_METHODS = %w[client_secret_basic client_secret_post].freeze
28
- DEFAULT_AUTH_METHOD = PUBLIC_AUTH_METHOD
28
+ SUPPORTED_AUTH_METHODS = (CONFIDENTIAL_AUTH_METHODS + [PUBLIC_AUTH_METHOD]).freeze
29
29
  DEFAULT_SCOPE = "openid profile email".freeze
30
30
 
31
31
  # Minimal result object mirroring the gem's `result.success?` /
@@ -132,7 +132,22 @@ module StandardId
132
132
 
133
133
  def auth_method
134
134
  method = metadata[:token_endpoint_auth_method].to_s.strip
135
- method.presence || DEFAULT_AUTH_METHOD
135
+ method.presence || default_auth_method
136
+ end
137
+
138
+ # Fallback token_endpoint_auth_method when the registration request omits
139
+ # one. Reads the first-class config value (default "none"). A misconfigured
140
+ # value is a host-app bug, so it raises ConfigurationError loudly rather
141
+ # than silently registering a malformed client.
142
+ def default_auth_method
143
+ configured = StandardId.config.oauth.dynamic_registration_default_auth_method.to_s
144
+ unless SUPPORTED_AUTH_METHODS.include?(configured)
145
+ raise StandardId::ConfigurationError,
146
+ "oauth.dynamic_registration_default_auth_method must be one of " \
147
+ "#{SUPPORTED_AUTH_METHODS.join(', ')} (got #{configured.inspect})"
148
+ end
149
+
150
+ configured
136
151
  end
137
152
 
138
153
  def client_type
@@ -142,7 +157,7 @@ module StandardId
142
157
 
143
158
  raise StandardId::InvalidClientMetadataError,
144
159
  "Unsupported token_endpoint_auth_method: #{method.inspect}. " \
145
- "Allowed: #{(CONFIDENTIAL_AUTH_METHODS + [PUBLIC_AUTH_METHOD]).join(', ')}"
160
+ "Allowed: #{SUPPORTED_AUTH_METHODS.join(', ')}"
146
161
  end
147
162
 
148
163
  # Public clients are always forced onto PKCE/S256 (the model also validates
@@ -39,7 +39,9 @@ module StandardId
39
39
  grant_types_supported: %w[authorization_code refresh_token client_credentials],
40
40
  subject_types_supported: %w[public],
41
41
  id_token_signing_alg_values_supported: [StandardId.config.oauth.signing_algorithm.to_s.upcase],
42
- token_endpoint_auth_methods_supported: %w[client_secret_basic client_secret_post],
42
+ # "none" advertises public-client support (PKCE-only token exchange,
43
+ # no client_secret) per RFC 8414 — required by native/SPA/MCP clients.
44
+ token_endpoint_auth_methods_supported: %w[client_secret_basic client_secret_post none],
43
45
  # PKCE is always enforced (require_pkce defaults true and cannot be
44
46
  # disabled for public clients), so advertise the supported method.
45
47
  code_challenge_methods_supported: %w[S256]
@@ -1,3 +1,3 @@
1
1
  module StandardId
2
- VERSION = "0.22.0"
2
+ VERSION = "0.24.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.22.0
4
+ version: 0.24.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jaryl Sim