standard_id 0.21.1 → 0.22.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: fea1716449529456cd6e771d62e2565d9100b96d10fb1fb93a4e0e13dc22ca74
4
- data.tar.gz: 6403031d85b3a6c50f6a24c1249cb26d3c1b730cc77355d6f5f44946a0a237c4
3
+ metadata.gz: 7caed55f6e5f6f70b01a7e79dabdc5ad9ddd646535f8c10bbde93158c65e32d9
4
+ data.tar.gz: 7ca5e3b573b3718e722eb047f14eefffab20263ee374d23f706f278878bcc640
5
5
  SHA512:
6
- metadata.gz: 33e81b4a0b5d0bf44519c6713e9fd433420362388030fb6bdc22d11e134b342614b92943bfa9b3aacbda9edc4ce63eda3f5345d805514e4db731f35dbfa71d90
7
- data.tar.gz: 4e2400b5411341dfdd7f70d5921f5357f4fc7dbcb48e416fe58252267d0a711ac479d35536340202714fbc34bf6af50e0ca15424a27d2ce667f96ad946153225
6
+ metadata.gz: c68adc74ec092631a1af808cc61ea1d2d8adeb72dbaa70588cd4069edd71b94d57e6ed33f5a9fc5ca4104def616b779e9bbe44f3c9b61e9a861ebdee1eadc946
7
+ data.tar.gz: 6f10a9a4916737e870798e38c96c53e7767fcf9c4e538bd5c0ca0ee43d21843ec3dff838ee384d1acb6d68336655165fc43cf970e3e8a4e65e5e75cba32fd51f
@@ -0,0 +1,91 @@
1
+ module StandardId
2
+ module Api
3
+ module Oauth
4
+ # RFC 7591 Dynamic Client Registration endpoint (POST /oauth/register).
5
+ #
6
+ # The endpoint is fully absent (404) unless
7
+ # `StandardId.config.oauth.dynamic_registration_enabled` is true — an open,
8
+ # unauthenticated registration endpoint is state-mutating attack surface,
9
+ # so it is opt-in. When enabled, the controller stays thin: it parses the
10
+ # JSON client metadata and delegates the RFC 7591 -> ClientApplication
11
+ # mapping (and the engine's security defaults) to
12
+ # StandardId::Oauth::ClientRegistration.
13
+ class RegistrationsController < BaseController
14
+ public_controller
15
+
16
+ # Throttle the open, unauthenticated registration endpoint by IP so an
17
+ # enabled deployment can't be flooded with ClientApplication rows.
18
+ rate_limit to: StandardId.config.rate_limits.dynamic_registration_per_ip,
19
+ within: 1.hour,
20
+ name: "dynamic-registration-ip",
21
+ only: :create,
22
+ store: StandardId::RateLimitHandling::RATE_LIMIT_STORE
23
+
24
+ before_action :require_dynamic_registration_enabled!
25
+
26
+ # POST /oauth/register
27
+ def create
28
+ result = StandardId::Oauth::ClientRegistration.call(client_metadata)
29
+ render json: registration_response(result), status: :created
30
+ end
31
+
32
+ private
33
+
34
+ # Return 404 (not 403) when the feature is off so the endpoint is
35
+ # indistinguishable from one that does not exist.
36
+ def require_dynamic_registration_enabled!
37
+ head(:not_found) unless StandardId.config.oauth.dynamic_registration_enabled
38
+ end
39
+
40
+ # Permit the full RFC 7591 client metadata document. We hand the raw
41
+ # values to the service, which whitelists/maps them; the controller does
42
+ # not need typed param coercion here.
43
+ def client_metadata
44
+ params.permit(
45
+ :client_name,
46
+ :scope,
47
+ :token_endpoint_auth_method,
48
+ redirect_uris: [],
49
+ grant_types: [],
50
+ response_types: []
51
+ ).to_h.tap do |permitted|
52
+ # `params.permit` drops scalars passed where an array was declared
53
+ # (and vice versa); fall back to the raw value so the service can
54
+ # accept either an array or a space-delimited string.
55
+ %i[redirect_uris grant_types response_types].each do |key|
56
+ permitted[key] = params[key] if permitted[key].blank? && params[key].present?
57
+ end
58
+ end
59
+ end
60
+
61
+ # RFC 7591 §3.2.1 success response. Echoes the registered metadata and,
62
+ # for confidential clients, the one-time client_secret with
63
+ # client_secret_expires_at: 0 (never expires).
64
+ def registration_response(result)
65
+ client = result.client
66
+
67
+ body = {
68
+ client_id: client.client_id,
69
+ client_id_issued_at: client.created_at.to_i,
70
+ client_name: client.name,
71
+ redirect_uris: client.redirect_uris_array,
72
+ grant_types: client.grant_types_array,
73
+ response_types: client.response_types_array,
74
+ scope: client.scopes,
75
+ # Echo the registered value (RFC 7591 §3.2.1), not a value derived
76
+ # from client_type — both client_secret_basic and client_secret_post
77
+ # are accepted and both work at the token endpoint.
78
+ token_endpoint_auth_method: result.token_endpoint_auth_method
79
+ }
80
+
81
+ if result.client_secret
82
+ body[:client_secret] = result.client_secret
83
+ body[:client_secret_expires_at] = 0
84
+ end
85
+
86
+ body
87
+ end
88
+ end
89
+ end
90
+ end
91
+ end
@@ -27,7 +27,10 @@ module StandardId
27
27
  end
28
28
 
29
29
  response.headers["Cache-Control"] = "public, max-age=3600"
30
- render json: StandardId::Oauth::DiscoveryDocument.build(issuer)
30
+ render json: StandardId::Oauth::DiscoveryDocument.build(
31
+ issuer,
32
+ registration_enabled: StandardId.config.oauth.dynamic_registration_enabled
33
+ )
31
34
  end
32
35
  end
33
36
  end
@@ -14,7 +14,10 @@ module StandardId
14
14
  end
15
15
 
16
16
  response.headers["Cache-Control"] = "public, max-age=3600"
17
- render json: StandardId::Oauth::DiscoveryDocument.build(issuer)
17
+ render json: StandardId::Oauth::DiscoveryDocument.build(
18
+ issuer,
19
+ registration_enabled: StandardId.config.oauth.dynamic_registration_enabled
20
+ )
18
21
  end
19
22
  end
20
23
  end
data/config/routes/api.rb CHANGED
@@ -18,6 +18,11 @@ StandardId::ApiEngine.routes.draw do
18
18
  resource :token, only: [:create]
19
19
  resource :revoke, only: [:create], controller: :revocations
20
20
 
21
+ # RFC 7591 Dynamic Client Registration -> POST /oauth/register.
22
+ # The controller returns 404 when oauth.dynamic_registration_enabled is
23
+ # false, so the endpoint is fully absent unless explicitly enabled.
24
+ resource :register, only: [:create], controller: :registrations
25
+
21
26
  namespace :callback do
22
27
  post ":provider", to: "providers#callback", as: :provider
23
28
  end
@@ -270,6 +270,25 @@ StandardId::ConfigSchema.define do
270
270
  # Must return a Hash of custom claims to merge into the JWT payload.
271
271
  # Example: ->(account:, **) { { channel_id: account.channel_id } }
272
272
  field :custom_claims, type: :any, default: nil
273
+
274
+ # RFC 7591 Dynamic Client Registration.
275
+ #
276
+ # When false (the default), the registration endpoint is fully absent
277
+ # (`POST /oauth/register` returns 404) and `registration_endpoint` is NOT
278
+ # advertised in the discovery documents. An open, unauthenticated
279
+ # registration endpoint is state-mutating attack surface (anyone can mint
280
+ # OAuth clients), so it is opt-in: a deployment must explicitly turn it on.
281
+ field :dynamic_registration_enabled, type: :boolean, default: false
282
+
283
+ # Callable resolving the polymorphic owner assigned to clients created via
284
+ # Dynamic Client Registration (the `owner` association on ClientApplication
285
+ # is required). Example: `-> { Organization.default }`.
286
+ #
287
+ # When `dynamic_registration_enabled` is true but this resolver is nil (or
288
+ # returns nil), registration raises a clear configuration error rather than
289
+ # silently failing the model's presence validation — so misconfiguration is
290
+ # caught loudly at request time.
291
+ field :dynamic_registration_owner, type: :any, default: nil
273
292
  end
274
293
 
275
294
  scope :social do
@@ -308,5 +327,9 @@ StandardId::ConfigSchema.define do
308
327
  field :api_passwordless_start_per_ip, type: :integer, default: 10 # per hour
309
328
  field :api_passwordless_start_per_target, type: :integer, default: 5 # per 15 minutes
310
329
  field :api_token_per_ip, type: :integer, default: 30 # per 15 minutes
330
+
331
+ # Dynamic client registration (RFC 7591) — throttle the open registration
332
+ # endpoint by IP so an enabled deployment can't be flooded with client rows.
333
+ field :dynamic_registration_per_ip, type: :integer, default: 10 # per hour
311
334
  end
312
335
  end
@@ -110,6 +110,18 @@ module StandardId
110
110
  def oauth_error_code = :unsupported_response_type
111
111
  end
112
112
 
113
+ # RFC 7591 §3.2.2 client registration errors. Both render as HTTP 400 with
114
+ # an `error` of `invalid_redirect_uri` / `invalid_client_metadata` and an
115
+ # `error_description`. They subclass OAuthError so the existing OAuth error
116
+ # handling renders them in the standard error shape.
117
+ class InvalidRedirectUriError < OAuthError
118
+ def oauth_error_code = :invalid_redirect_uri
119
+ end
120
+
121
+ class InvalidClientMetadataError < OAuthError
122
+ def oauth_error_code = :invalid_client_metadata
123
+ end
124
+
113
125
  # Lifecycle hook errors
114
126
  class AuthenticationDenied < StandardError; end
115
127
 
@@ -0,0 +1,205 @@
1
+ require "securerandom"
2
+
3
+ module StandardId
4
+ module Oauth
5
+ # RFC 7591 Dynamic Client Registration.
6
+ #
7
+ # Maps a client metadata document (the JSON body of POST /oauth/register)
8
+ # onto a StandardId::ClientApplication, applying the engine's security
9
+ # defaults (PKCE-forced public clients, S256, consent-by-default) and a
10
+ # conservative whitelist of grant/response types. Keeps the controller thin
11
+ # in the same flow-object style as the other lib/standard_id/oauth/ objects.
12
+ #
13
+ # On success #call returns a Result carrying the persisted client plus the
14
+ # one-time plaintext secret (confidential clients only); on a metadata or
15
+ # redirect-uri problem it raises the matching RFC 7591 §3.2.2 error
16
+ # (InvalidRedirectUriError / InvalidClientMetadataError), which the
17
+ # controller renders as HTTP 400. A nil owner resolver while the feature is
18
+ # enabled raises ConfigurationError (a host-app bug, not client input).
19
+ class ClientRegistration
20
+ # RFC 7591 grant_types we support. M2M (client_credentials) is deliberately
21
+ # excluded from DCR — self-registered clients are public/interactive.
22
+ ALLOWED_GRANT_TYPES = %w[authorization_code refresh_token].freeze
23
+ # Only the authorization-code response type is supported.
24
+ ALLOWED_RESPONSE_TYPES = %w[code].freeze
25
+ # token_endpoint_auth_method -> client_type mapping.
26
+ PUBLIC_AUTH_METHOD = "none".freeze
27
+ CONFIDENTIAL_AUTH_METHODS = %w[client_secret_basic client_secret_post].freeze
28
+ DEFAULT_AUTH_METHOD = PUBLIC_AUTH_METHOD
29
+ DEFAULT_SCOPE = "openid profile email".freeze
30
+
31
+ # Minimal result object mirroring the gem's `result.success?` /
32
+ # `result.value` convention. `client_secret` is the one-time plaintext for
33
+ # confidential clients (nil for public clients).
34
+ Result = Struct.new(:client, :client_secret, :token_endpoint_auth_method, keyword_init: true) do
35
+ def success? = true
36
+ def value = client
37
+ end
38
+
39
+ # @param metadata [Hash] RFC 7591 client metadata (symbolized or stringified keys)
40
+ def initialize(metadata)
41
+ @metadata = (metadata || {}).to_h.symbolize_keys
42
+ end
43
+
44
+ def self.call(metadata)
45
+ new(metadata).call
46
+ end
47
+
48
+ def call
49
+ attrs = mapped_attributes
50
+ client = StandardId::ClientApplication.new(attrs)
51
+
52
+ secret_plaintext = nil
53
+ StandardId::ClientApplication.transaction do
54
+ client.save!
55
+ if client.confidential?
56
+ secret_plaintext = SecureRandom.hex(32)
57
+ client.create_client_secret!(
58
+ name: "Dynamic Registration Secret",
59
+ client_secret: secret_plaintext
60
+ )
61
+ end
62
+ end
63
+
64
+ Result.new(client: client, client_secret: secret_plaintext, token_endpoint_auth_method: auth_method)
65
+ rescue ActiveRecord::RecordInvalid => e
66
+ raise_for(e.record)
67
+ end
68
+
69
+ private
70
+
71
+ attr_reader :metadata
72
+
73
+ def mapped_attributes
74
+ {
75
+ owner: resolve_owner!,
76
+ name: client_name,
77
+ redirect_uris: redirect_uris,
78
+ grant_types: grant_types,
79
+ response_types: response_types,
80
+ scopes: scope,
81
+ client_type: client_type,
82
+ require_pkce: require_pkce?,
83
+ code_challenge_methods: code_challenge_methods,
84
+ require_consent: true
85
+ }
86
+ end
87
+
88
+ # redirect_uris is REQUIRED (RFC 7591 §2). We pass the raw value through to
89
+ # the model and let its redirect-uri validation surface an invalid_redirect_uri
90
+ # error — keeping a single source of truth for URI rules.
91
+ def redirect_uris
92
+ Array(metadata[:redirect_uris]).map { |u| u.to_s.strip }.reject(&:blank?).join(" ")
93
+ end
94
+
95
+ def client_name
96
+ name = metadata[:client_name].to_s.strip
97
+ name.presence || "Dynamically Registered Client #{SecureRandom.hex(4)}"
98
+ end
99
+
100
+ # Whitelist grant_types. Any value outside ALLOWED_GRANT_TYPES is rejected
101
+ # as invalid_client_metadata (RFC 7591 §3.2.2). Absent -> authorization_code.
102
+ def grant_types
103
+ requested = list_param(:grant_types)
104
+ return "authorization_code" if requested.empty?
105
+
106
+ disallowed = requested - ALLOWED_GRANT_TYPES
107
+ if disallowed.any?
108
+ raise StandardId::InvalidClientMetadataError,
109
+ "Unsupported grant_types: #{disallowed.join(', ')}. Allowed: #{ALLOWED_GRANT_TYPES.join(', ')}"
110
+ end
111
+
112
+ requested.join(" ")
113
+ end
114
+
115
+ def response_types
116
+ requested = list_param(:response_types)
117
+ return "code" if requested.empty?
118
+
119
+ disallowed = requested - ALLOWED_RESPONSE_TYPES
120
+ if disallowed.any?
121
+ raise StandardId::InvalidClientMetadataError,
122
+ "Unsupported response_types: #{disallowed.join(', ')}. Allowed: #{ALLOWED_RESPONSE_TYPES.join(', ')}"
123
+ end
124
+
125
+ requested.join(" ")
126
+ end
127
+
128
+ def scope
129
+ scope = metadata[:scope].to_s.strip
130
+ scope.presence || DEFAULT_SCOPE
131
+ end
132
+
133
+ def auth_method
134
+ method = metadata[:token_endpoint_auth_method].to_s.strip
135
+ method.presence || DEFAULT_AUTH_METHOD
136
+ end
137
+
138
+ def client_type
139
+ method = auth_method
140
+ return "public" if method == PUBLIC_AUTH_METHOD
141
+ return "confidential" if CONFIDENTIAL_AUTH_METHODS.include?(method)
142
+
143
+ raise StandardId::InvalidClientMetadataError,
144
+ "Unsupported token_endpoint_auth_method: #{method.inspect}. " \
145
+ "Allowed: #{(CONFIDENTIAL_AUTH_METHODS + [PUBLIC_AUTH_METHOD]).join(', ')}"
146
+ end
147
+
148
+ # Public clients are always forced onto PKCE/S256 (the model also validates
149
+ # this). Confidential clients also default to PKCE here for defense in depth.
150
+ def require_pkce?
151
+ true
152
+ end
153
+
154
+ def code_challenge_methods
155
+ "S256"
156
+ end
157
+
158
+ def resolve_owner!
159
+ resolver = StandardId.config.oauth.dynamic_registration_owner
160
+ unless resolver.respond_to?(:call)
161
+ raise StandardId::ConfigurationError,
162
+ "oauth.dynamic_registration_owner must be set to a callable resolving the " \
163
+ "client owner when oauth.dynamic_registration_enabled is true " \
164
+ "(e.g. -> { Organization.default })"
165
+ end
166
+
167
+ owner = resolver.call
168
+ if owner.nil?
169
+ raise StandardId::ConfigurationError,
170
+ "oauth.dynamic_registration_owner resolved to nil; it must return the " \
171
+ "polymorphic owner record for dynamically registered clients"
172
+ end
173
+
174
+ owner
175
+ end
176
+
177
+ # Accept either an Array or a space-delimited String for list-shaped
178
+ # metadata fields (RFC 7591 uses JSON arrays; be lenient with strings too).
179
+ def list_param(key)
180
+ value = metadata[key]
181
+ case value
182
+ when Array
183
+ value.map { |v| v.to_s.strip }.reject(&:blank?)
184
+ else
185
+ value.to_s.split(/\s+/).map(&:strip).reject(&:blank?)
186
+ end
187
+ end
188
+
189
+ # Translate an ActiveRecord validation failure into the matching RFC 7591
190
+ # error. A redirect_uris failure is invalid_redirect_uri; everything else
191
+ # (including a blank redirect_uris which the model reports as a presence
192
+ # error) maps to invalid_client_metadata.
193
+ def raise_for(record)
194
+ errors = record.errors
195
+ message = errors.full_messages.join("; ")
196
+
197
+ if errors.key?(:redirect_uris)
198
+ raise StandardId::InvalidRedirectUriError, message.presence || "Invalid redirect_uris"
199
+ end
200
+
201
+ raise StandardId::InvalidClientMetadataError, message.presence || "Invalid client metadata"
202
+ end
203
+ end
204
+ end
205
+ end
@@ -20,9 +20,10 @@ module StandardId
20
20
 
21
21
  # @param issuer [String] the configured issuer (e.g. "https://auth.example.com")
22
22
  # @param registration_enabled [Boolean] when true, advertises the RFC 7591
23
- # dynamic client registration endpoint. Defaults to false; the seam is
24
- # kept here so Phase 2 (DCR) can flip it on via config without touching
25
- # either controller. While false, no registration_endpoint is emitted.
23
+ # dynamic client registration endpoint. The well-known controllers pass
24
+ # `StandardId.config.oauth.dynamic_registration_enabled` here, so the
25
+ # `registration_endpoint` is emitted only when DCR is turned on. Defaults
26
+ # to false so callers (and tests) that omit it get no registration_endpoint.
26
27
  # @return [Hash]
27
28
  def build(issuer, registration_enabled: false)
28
29
  base = issuer.to_s.chomp("/")
@@ -1,3 +1,3 @@
1
1
  module StandardId
2
- VERSION = "0.21.1"
2
+ VERSION = "0.22.0"
3
3
  end
data/lib/standard_id.rb CHANGED
@@ -46,6 +46,7 @@ require "standard_id/oauth/subflows/social_login_grant"
46
46
  require "standard_id/oauth/passwordless_otp_flow"
47
47
  require "standard_id/oauth/discovery_document"
48
48
  require "standard_id/oauth/consent_payload"
49
+ require "standard_id/oauth/client_registration"
49
50
  require "standard_id/passwordless/base_strategy"
50
51
  require "standard_id/passwordless/email_strategy"
51
52
  require "standard_id/passwordless/sms_strategy"
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.21.1
4
+ version: 0.22.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jaryl Sim
@@ -132,6 +132,7 @@ files:
132
132
  - app/controllers/standard_id/api/base_controller.rb
133
133
  - app/controllers/standard_id/api/oauth/base_controller.rb
134
134
  - app/controllers/standard_id/api/oauth/callback/providers_controller.rb
135
+ - app/controllers/standard_id/api/oauth/registrations_controller.rb
135
136
  - app/controllers/standard_id/api/oauth/revocations_controller.rb
136
137
  - app/controllers/standard_id/api/oauth/tokens_controller.rb
137
138
  - app/controllers/standard_id/api/oidc/logout_controller.rb
@@ -261,6 +262,7 @@ files:
261
262
  - lib/standard_id/oauth/authorization_flow.rb
262
263
  - lib/standard_id/oauth/base_request_flow.rb
263
264
  - lib/standard_id/oauth/client_credentials_flow.rb
265
+ - lib/standard_id/oauth/client_registration.rb
264
266
  - lib/standard_id/oauth/consent_payload.rb
265
267
  - lib/standard_id/oauth/discovery_document.rb
266
268
  - lib/standard_id/oauth/implicit_authorization_flow.rb