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 +4 -4
- data/app/controllers/standard_id/api/oauth/callback/providers_controller.rb +17 -1
- data/app/controllers/standard_id/api/oauth/tokens_controller.rb +26 -0
- data/app/controllers/standard_id/web/consent_controller.rb +17 -2
- data/lib/generators/standard_id/install/templates/standard_id.rb +5 -0
- data/lib/standard_id/config/schema.rb +24 -0
- data/lib/standard_id/oauth/authorization_code_flow.rb +24 -6
- data/lib/standard_id/oauth/client_registration.rb +18 -3
- data/lib/standard_id/oauth/discovery_document.rb +3 -1
- data/lib/standard_id/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 96bb41b62e41b340083b98a19c91913d8a1342a1895150d68595cba6a576d039
|
|
4
|
+
data.tar.gz: b85e690728fad61be9a1ab5fa047096cf3db2b97bea9b9403774cd53895216f6
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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 =
|
|
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
|
-
|
|
87
|
+
redirect_out(result[:redirect_to], status: result[:status] || :found)
|
|
88
88
|
end
|
|
89
89
|
|
|
90
90
|
def deny!
|
|
91
|
-
|
|
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, :
|
|
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
|
-
@
|
|
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: @
|
|
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
|
-
@
|
|
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
|
-
@
|
|
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
|
-
|
|
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 ||
|
|
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: #{
|
|
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
|
-
|
|
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]
|
data/lib/standard_id/version.rb
CHANGED