standard_id 0.48.1 → 0.49.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.
Files changed (32) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +17 -0
  3. data/README.md +172 -0
  4. data/app/controllers/concerns/standard_id/resource_server.rb +138 -0
  5. data/app/controllers/standard_id/api/authorization_controller.rb +1 -0
  6. data/app/controllers/standard_id/api/oauth/callback/providers_controller.rb +23 -0
  7. data/app/controllers/standard_id/api/oauth/registrations_controller.rb +99 -22
  8. data/app/controllers/standard_id/api/well_known/oauth_protected_resource_controller.rb +46 -0
  9. data/app/jobs/standard_id/cleanup_all_job.rb +4 -3
  10. data/app/jobs/standard_id/purge_stale_dynamic_clients_job.rb +73 -0
  11. data/app/models/standard_id/client_application.rb +38 -0
  12. data/config/routes/api.rb +9 -0
  13. data/db/migrate/20261020120000_add_registration_access_token_digest_to_standard_id_client_applications.rb +14 -0
  14. data/lib/generators/standard_id/install/install_generator.rb +2 -1
  15. data/lib/standard_id/config/callable_validator.rb +20 -0
  16. data/lib/standard_id/config/schema.rb +87 -0
  17. data/lib/standard_id/errors.rb +7 -0
  18. data/lib/standard_id/oauth/audience_resolver.rb +88 -0
  19. data/lib/standard_id/oauth/authorization_code_authorization_flow.rb +1 -1
  20. data/lib/standard_id/oauth/authorization_code_flow.rb +3 -1
  21. data/lib/standard_id/oauth/authorization_flow.rb +21 -1
  22. data/lib/standard_id/oauth/client_registration.rb +151 -23
  23. data/lib/standard_id/oauth/direct_mint_flow.rb +128 -0
  24. data/lib/standard_id/oauth/implicit_authorization_flow.rb +1 -1
  25. data/lib/standard_id/oauth/protected_resource_document.rb +171 -0
  26. data/lib/standard_id/oauth/refresh_token_flow.rb +1 -1
  27. data/lib/standard_id/routing.rb +21 -0
  28. data/lib/standard_id/token_minter.rb +74 -0
  29. data/lib/standard_id/version.rb +1 -1
  30. data/lib/standard_id.rb +4 -0
  31. data/lib/tasks/standard_id_tasks.rake +8 -1
  32. metadata +9 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: cdf22579e98c4ceb592cb20766d510d937d0e5d2f7f70c41074f984ddc3b0393
4
- data.tar.gz: 35fbb21a24d40a307bd55279a86fa0d91549641e30f30d4101d667d9c5d7c793
3
+ metadata.gz: 960b6bdf3cc6fd613e1f3cdb6a172f9b30fb42f2c215174787f7858f0c532075
4
+ data.tar.gz: 654fde0c77971e868578805a3d033909adbe49da8006ca1f1174110975e2b255
5
5
  SHA512:
6
- metadata.gz: cd202ffabe7287cb374442e6aeee83d0f2853a17d9cb7654ba816f28f48c4d6970b859b7d4d83581f09ef4a2793689b5fc620b545246bfb515691fda9a4cd1dd
7
- data.tar.gz: 0e17bfff08af26fc343dedaf0d9500bbef69fe118031c305bdc29f33ede7231c8641c14b2b6156395702c4957c58b605dc6a24b54b4f975fa19618c8f4cade01
6
+ metadata.gz: f5a099d1243e7ac19872b907c402937941a3718efec2caffa7fc75af717893bc6b685671d528929e99c658bc08187cb58d776fc36c744978190f1c850bf5c457
7
+ data.tar.gz: db01eff1f65d191da0166de6f09def5231d88a9675d6240efd0d2c0eddd02b91553b2aad9f1d062d026fccfcf9d17ee1ffca4e04c2b6e1cfe4b627229d8a56be
data/CHANGELOG.md CHANGED
@@ -7,6 +7,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.49.0] - 2026-10-20
11
+
12
+ ### Added
13
+
14
+ - **Dynamic client registration pins what the client may not choose, and RFC 7592 manages it.** `c.oauth.dynamic_registration_attributes` (Hash or callable `(metadata:, request:)`) pins `scopes`, `metadata` (e.g. `"audience" => "mcp"`, which `/authorize` now reads), `access_token_lifetime`, `authorization_code_lifetime`, `require_consent` and `description` on every registered client; with no `scopes` pinned, a pinned audience's `audience_scopes` vocabulary is the client's scope. `c.oauth.dynamic_registration_auth_methods` allowlists `token_endpoint_auth_method`. `dynamic_registration_owner` may now take `(metadata:, request:)`. Migration **`20261020120000`** adds `registration_access_token_digest` to client applications; once run, registration returns a one-time `registration_access_token` + `registration_client_uri`, and `GET`/`PUT`/`DELETE /oauth/register/:client_id` (RFC 7592) read, update (client-shaped members only) and deregister (deactivate + revoke refresh tokens) the client, authenticated by that token and throttled separately (`c.rate_limits.dynamic_registration_management_per_ip`, 60/hour). **`StandardId::PurgeStaleDynamicClientsJob`** (also in `CleanupAllJob`, `standard_id:cleanup:dynamic_clients`, and scheduled daily by the install generator) deletes deregistered clients and never-authorized ones after a grace window. Replaces the per-app `RegisterDynamicClient` operation (and sidekick's RFC 7592 update/delete/purge) every consumer carried.
15
+ - **RFC 9728 protected-resource metadata and `StandardId::ResourceServer`.** `c.oauth.protected_resources = { "mcp" => { path: "/mcp" } }` declares the resources a host serves, keyed by audience; `standard_id_protected_resource_routes` serves their documents at `/.well-known/oauth-protected-resource(/<path>)` (also under the engine mount), built by `StandardId::Oauth::ProtectedResourceDocument` with `scopes_supported` from `audience_scopes`, `authorization_servers` from the issuer, and any other member from the entry. `include StandardId::ResourceServer` + `protected_resource "mcp", scopes: %w[mcp]` is the bearer check a resource server needs: token verification, audience verification, optional scope requirement, and RFC 6750 `401 invalid_token` / `403 insufficient_scope` responses whose `WWW-Authenticate` challenge carries the `resource_metadata` pointer the MCP authorization spec requires. Replaces the protected-resource controller and the hand-rolled 401 every consumer carried. A request with no credential gets the RFC 6750 §3.1 form (realm + `resource_metadata`, no error code); `metadata_url` raises `ConfigurationError` for an unconfigured audience instead of naming a bare URL; `resource` is computed, never configured.
16
+
17
+ - **`/authorize` resolves the audience server-side** (`StandardId::Oauth::AudienceResolver`): `c.oauth.audience_resolver` (callable) when set, else the `audience` param, else the RFC 8707 **`resource`** param mapped through the new **`c.oauth.resource_audiences`** hash, else the client's registered default `metadata["audience"]`. The result is bound to the authorization code as before. `resource` is now a permitted parameter on both authorize flows and accepted at the token endpoint. A dynamically registered client whose registration stamps `metadata["audience"]` therefore gets its `aud` with no audience-injecting shim in front of the endpoint — the shim every consumer carried (sidekick-web #1126 → nutripod-web #949 → luminality-web #821 → fundbright-web #809).
18
+ - **`c.oauth.audience_scopes` is enforced at `/authorize`**: a scope outside the resolved audience's vocabulary is refused with `invalid_scope` before anything is issued (unconfigured audiences and empty scopes pass through, as before). This is the one behaviour change for a consumer that already configures `audience_scopes`: a client requesting a scope outside its audience's vocabulary now gets `invalid_scope` where it used to get a code. The check is only as strong as `allowed_audiences` at the token endpoint, since a client-supplied `audience` still outranks the registered default.
19
+ - **`StandardId::InvalidTargetError`** (`invalid_target`, RFC 8707 §2): raised at `/authorize` when `c.oauth.resource_audiences` is configured and the `resource` parameter is not in it. With no mapping configured the parameter is ignored, so a host relying on the client's registered default still gets it when MCP clients send `resource` on every request.
20
+ - `c.oauth.audience_resolver` and `c.oauth.audience_scope_resolver` are now checked at boot by `Config::CallableValidator` (an unsupported keyword fails at boot, not at request time). The audience resolver must return a String, Symbol or nil; anything else raises `StandardId::ConfigurationError`.
21
+
22
+ ### Added
23
+
24
+ - **`StandardId::TokenMinter.mint(account:, audience:, grant_type:, scopes:, client_id:, claims:, expires_in:, refresh_token:, request:)`**: mint a token for an account the host already authenticated, through the same pipeline as `/oauth/token` (`Oauth::DirectMintFlow`, not reachable from the endpoint): audience validation, audience → profile binding, `scope_claims` / `claim_resolvers` / `custom_claims`, the AccountStatus / AccountLocking guards, `OAUTH_TOKEN_ISSUING` / `OAUTH_TOKEN_ISSUED`, optional refresh token (bound to `client_id`'s application) and session persistence when a request is passed. Replaces the hand-rolled `JwtService.encode` mint sites that re-derive the initializer's claim resolvers (sidekick-web has nine).
25
+ - **`c.social.before_mint`** (callable): runs on the API social callback after the account is found or created and before the token is minted, with any subset of `account:`, `provider:`, `social_info:`, `provider_tokens:`, `params:`, `request:`. Raising refuses the sign-in through the callback's existing cleanup. Replaces the host subclass of `Api::Oauth::Callback::ProvidersController` that reordered those steps by copying the method body (sidekick-web, re-synced on 6 of the last 10 bumps).
26
+
10
27
  ## [0.48.1] - 2026-10-20
11
28
 
12
29
  ### Added
data/README.md CHANGED
@@ -278,6 +278,39 @@ It honours the current scope, returns `nil` (never raises) for a blank,
278
278
  unknown, mismatched, or malformed-digest token, and has an instance-level
279
279
  counterpart, `session.authenticate_token(token)`.
280
280
 
281
+ ### Minting Tokens Outside the Token Endpoint
282
+
283
+ `StandardId::TokenMinter` mints a token for an account the host has already
284
+ authenticated by its own means — an operator console, a service-to-service
285
+ credential, a self-service "mint me a token" tool — through the same pipeline
286
+ `/oauth/token` uses, so it carries the same claims, obeys the same audience →
287
+ profile binding and account-status guards, and publishes the same
288
+ `OAUTH_TOKEN_ISSUING` / `OAUTH_TOKEN_ISSUED` events:
289
+
290
+ ```ruby
291
+ result = StandardId::TokenMinter.mint(
292
+ account: account,
293
+ audience: "harness",
294
+ scopes: "profile permissions",
295
+ grant_type: "harness.mint", # free-form; lands in the JWT and the audit event
296
+ claims: { permissions: profile.harness_scopes }, # host extras; reserved keys are dropped
297
+ expires_in: 60.minutes,
298
+ refresh_token: false # true also persists and returns a refresh token
299
+ )
300
+ result.access_token
301
+ result.jti # what OAUTH_TOKEN_ISSUED carried
302
+ ```
303
+
304
+ `scope_claims` / `claim_resolvers` and `custom_claims` run exactly as for the
305
+ endpoint (the resolvers see `client:` as the `ClientApplication` named by
306
+ `client_id:`, or nil, and `request:` as nil for a sessionless mint). Pass
307
+ `request:` to let `session.session_type_resolver` materialise a session;
308
+ without it the mint is sessionless, like `client_credentials`. A refreshable
309
+ token needs `client_id:` (the refresh grant matches on it) and, under a
310
+ `login_method_policy`, `auth_method:` / `auth_provider:` so the refresh is not
311
+ judged as `:unspecified`. `expires_in:` must be positive. The grant is not
312
+ reachable from `/oauth/token`.
313
+
281
314
  ### Custom Token Claims
282
315
 
283
316
  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`.
@@ -331,8 +364,121 @@ R.assert!(requested: %w[mcp mcp:admin], audience: "companion_kit") # => raises I
331
364
 
332
365
  An **unconfigured** audience fails **open** — `filter` and `assert!` pass the requested scopes through unchanged — so an app that does not model per-audience vocabularies sees no behaviour change; narrowing only bites once an audience has an explicit vocabulary.
333
366
 
367
+ ### Serving a Protected Resource (RFC 9728)
368
+
369
+ A resource server — an MCP endpoint, a service API — needs three things: the bearer
370
+ check, a 401/403 that tells the client where the authorization server is, and the
371
+ RFC 9728 metadata document that 401 points at. The gem provides all three.
372
+
373
+ ```ruby
374
+ # config/initializers/standard_id.rb
375
+ StandardId.configure do |config|
376
+ config.oauth.audience_scopes = { "mcp" => %w[mcp mcp:read] }
377
+ config.oauth.protected_resources = {
378
+ "mcp" => { path: "/mcp" } # scopes_supported defaults to audience_scopes["mcp"]
379
+ }
380
+ end
381
+
382
+ # config/routes.rb
383
+ standard_id_protected_resource_routes # /.well-known/oauth-protected-resource(/mcp)
384
+
385
+ # app/controllers/mcp/base_controller.rb
386
+ class Mcp::BaseController < ActionController::API
387
+ include StandardId::ResourceServer
388
+ protected_resource "mcp", scopes: %w[mcp]
389
+ end
390
+ ```
391
+
392
+ `protected_resource` verifies the access token, requires its `aud` to carry the
393
+ audience (with the audience → profile-type binding when configured) and one of
394
+ `scopes:` when given. A missing, expired, revoked or invalid token, or a deactivated
395
+ or locked account, is `401 invalid_token`; a token for another audience or without a
396
+ required scope is `403 insufficient_scope`. Every refusal carries
397
+ `WWW-Authenticate: Bearer realm="mcp", error=…, resource_metadata="<origin>/.well-known/oauth-protected-resource/mcp"`,
398
+ which is how an MCP client discovers the authorization server and starts the
399
+ authorization-code + PKCE flow. The document itself is built by
400
+ `StandardId::Oauth::ProtectedResourceDocument` from the entry: `path` (required, or
401
+ an absolute URL on another origin), `scopes_supported`, `authorization_servers`
402
+ (default `[issuer]`), `bearer_methods_supported` (default `["header"]`) and any other
403
+ RFC 9728 member; callables receive `{ origin:, audience:, issuer:, request: }` and
404
+ `nil` removes a member. Rate limiting and resolving the token's claims to a profile
405
+ stay in the host controller.
406
+
407
+ Two things to know. The document's `resource` and the challenge URL are built from
408
+ the request origin, as RFC 9728 §3.3 requires, so pin `Host` (`config.hosts`, or at
409
+ the proxy): the document is served with `Cache-Control: public`. And a token minted
410
+ for an audience outside `config.oauth.allowed_audiences` is refused at decode time
411
+ as `401 invalid_token`, before the concern sees it; the `403 insufficient_scope` is
412
+ for a token minted for another *allowed* audience.
413
+
414
+ ### Dynamic Client Registration (RFC 7591 / RFC 7592)
415
+
416
+ `POST /oauth/register` is off by default (`config.oauth.dynamic_registration_enabled`).
417
+ When on, a self-registering client (an MCP connector, say) chooses only what a client
418
+ should: its name, redirect URIs, grant and response types, and its
419
+ `token_endpoint_auth_method` from `config.oauth.dynamic_registration_auth_methods`
420
+ (`%w[none]` to accept public PKCE clients only). Everything else is yours to pin:
421
+
422
+ ```ruby
423
+ StandardId.configure do |config|
424
+ config.oauth.dynamic_registration_enabled = true
425
+ config.oauth.dynamic_registration_owner = ->(metadata:) { Oauth::DynamicRegistration.create!(client_metadata: metadata) }
426
+ config.oauth.dynamic_registration_attributes = {
427
+ scopes: %w[mcp mcp:read], # whatever scope was requested
428
+ metadata: { "audience" => "mcp", "dcr" => true }, # what AudienceResolver reads at /authorize
429
+ access_token_lifetime: 3600
430
+ }
431
+ end
432
+ ```
433
+
434
+ `dynamic_registration_attributes` may also be a callable taking `metadata:` and/or
435
+ `request:`. Pinnable keys: `scopes`, `metadata`, `access_token_lifetime`,
436
+ `authorization_code_lifetime`, `require_consent`, `description`. With no `scopes`
437
+ pinned, a pinned `metadata["audience"]` that has an `audience_scopes` vocabulary
438
+ gives the client that vocabulary. Every registered client is PKCE/S256 and
439
+ consent-gated unless you pin `require_consent: false`; do that knowingly, because a
440
+ consent-free client never records a `ClientGrant`, so the stale-client purge below
441
+ cannot tell an abandoned registration from one in daily use and reaps such clients
442
+ only once they deregister themselves. The per-IP registration limit is then the only
443
+ defence against bot-created rows.
444
+
445
+ Once migration `20261020120000` has run, the registration response also carries
446
+ `registration_client_uri` and a one-time `registration_access_token` (stored as a
447
+ digest), and the RFC 7592 client-configuration endpoint is live:
448
+ `GET`/`PUT`/`DELETE /oauth/register/:client_id`, authenticated by that token as a
449
+ bearer credential. `PUT` changes the client-shaped members under the registration
450
+ rules (never the pinned attributes or the client type); `DELETE` deactivates the
451
+ client and revokes its refresh tokens. An unknown client, a wrong token and a
452
+ deregistered client are the same `401`. `StandardId::PurgeStaleDynamicClientsJob`
453
+ (in `CleanupAllJob`, `standard_id:cleanup:dynamic_clients`, scheduled daily by the
454
+ install generator) deletes deregistered clients and clients nobody ever authorized
455
+ after a 7-day grace window.
456
+
334
457
  ### Social Login Setup
335
458
 
459
+ Need the account ready before the token is minted — a profile the requested
460
+ audience is bound to, a region check, a first-touch record? Set
461
+ `config.social.before_mint`; it runs on the API callback after the account is
462
+ found or created and before `SocialFlow` mints, and a raise refuses the
463
+ sign-in through the callback's normal cleanup (nothing minted, staged link
464
+ dropped, a just-created account removed):
465
+
466
+ ```ruby
467
+ config.social.before_mint = ->(account:, audience:, params:) {
468
+ DeviceUserProfiles::Ensure.call(account:, region: params["region"]) if audience == "companion_kit"
469
+ }
470
+ ```
471
+
472
+ The hook receives any subset of `account:`, `provider:`, `social_info:`,
473
+ `provider_tokens:`, `audience:`, `scopes:`, `params:` (the forwarded request
474
+ params; the reserved OAuth ones such as `audience` and `scope` are stripped,
475
+ which is why those two arrive as their own keywords) and `request:`. It runs
476
+ before the login-method policy and the audience → profile binding, so if either
477
+ refuses the sign-in on an account that already existed, what the hook wrote
478
+ stays: make it idempotent. It covers the API callback only; the WebEngine's
479
+ social callback does not run it.
480
+
481
+
336
482
  The `social.google_*` and `social.apple_*` fields are declared by the **provider
337
483
  plugin gems**, not by `standard_id` itself — add `standard_id-google` and/or
338
484
  `standard_id-apple` to your Gemfile first. Writing a field whose gem is absent
@@ -1219,6 +1365,32 @@ redirect_to "/api/authorize?" + {
1219
1365
  }.to_query
1220
1366
  ```
1221
1367
 
1368
+ **Which audience is the token for?** `/authorize` resolves it server-side
1369
+ (`StandardId::Oauth::AudienceResolver`), in this order: `config.oauth.audience_resolver`
1370
+ when set; the `audience` parameter; the RFC 8707 `resource` parameter mapped through
1371
+ `config.oauth.resource_audiences`; the client's registered default
1372
+ `metadata["audience"]`. The result is bound to the authorization code and reaches
1373
+ the token as `aud`, and `config.oauth.audience_scopes` is enforced for it at
1374
+ `/authorize` (a scope outside the audience's vocabulary is `invalid_scope`). So a
1375
+ dynamically registered MCP client whose registration stamps
1376
+ `metadata: { "audience" => "mcp" }` gets `aud=mcp` with no audience-injecting shim
1377
+ in front of the endpoint. The `resource` parameter is ignored until
1378
+ `resource_audiences` is set; once it is, an unmapped resource is `invalid_target`
1379
+ (RFC 8707 §2).
1380
+
1381
+ A client-supplied `audience` still outranks the registered default, so
1382
+ `audience_scopes` enforcement is only as strong as `config.oauth.allowed_audiences`
1383
+ at the token endpoint. A host that wants the registered default to be the only
1384
+ answer sets `audience_resolver` to `->(client:) { client.metadata["audience"] }`.
1385
+
1386
+ ```ruby
1387
+ StandardId.configure do |config|
1388
+ config.oauth.resource_audiences = { "https://app.example.com/mcp" => "mcp" } # RFC 8707
1389
+ # or decide yourself:
1390
+ config.oauth.audience_resolver = ->(client:, resource:) { client.metadata["audience"] || (resource && "mcp") }
1391
+ end
1392
+ ```
1393
+
1222
1394
  ### Social Login
1223
1395
 
1224
1396
  ```ruby
@@ -0,0 +1,138 @@
1
+ # frozen_string_literal: true
2
+
3
+ module StandardId
4
+ # The bearer check a resource server (an MCP endpoint, a service API) needs,
5
+ # in one line:
6
+ #
7
+ # class Mcp::BaseController < ActionController::API
8
+ # include StandardId::ResourceServer
9
+ # protected_resource "mcp", scopes: %w[mcp]
10
+ # end
11
+ #
12
+ # It verifies the access token (ApiAuthentication), requires the token's
13
+ # `aud` to carry the resource's audience (AudienceVerification, including the
14
+ # audience → profile-type binding when configured), requires one of `scopes:`
15
+ # when given, and answers every refusal the way RFC 6750 and the MCP
16
+ # authorization spec expect:
17
+ #
18
+ # * 401 `invalid_token` for a missing, expired, revoked or otherwise invalid
19
+ # token, and for a deactivated or locked account, with a
20
+ # `WWW-Authenticate` challenge that names the RFC 9728 metadata document
21
+ # for this resource (`resource_metadata=`) so a client can discover the
22
+ # authorization server and start the authorization-code + PKCE flow;
23
+ # * 403 `insufficient_scope` for a token minted for another audience, or
24
+ # without a required scope, with the same pointer and (for scope) the
25
+ # `scope` parameter listing what the resource needed.
26
+ #
27
+ # The audience must be configured in `config.oauth.protected_resources`
28
+ # (see StandardId::Oauth::ProtectedResourceDocument) so the challenge can
29
+ # name a document that exists. Anything else — rate limiting, resolving the
30
+ # token's `gid` to a profile — stays the host's, as before.
31
+ module ResourceServer
32
+ extend ActiveSupport::Concern
33
+
34
+ included do
35
+ include StandardId::ApiAuthentication unless include?(StandardId::ApiAuthentication)
36
+
37
+ # Order matters: AudienceVerification's verify_audience! returns early
38
+ # unless current_session is set, so the token must be verified first.
39
+ before_action :verify_access_token!
40
+
41
+ include StandardId::AudienceVerification
42
+
43
+ before_action :verify_protected_resource_scopes!
44
+
45
+ class_attribute :_protected_resource_audience, instance_writer: false, default: nil
46
+ class_attribute :_protected_resource_scopes, instance_writer: false, default: []
47
+
48
+ rescue_from StandardId::NotAuthenticatedError,
49
+ StandardId::InvalidSessionError,
50
+ StandardId::InvalidTokenError,
51
+ StandardId::AccountDeactivatedError,
52
+ StandardId::AccountLockedError,
53
+ with: :render_protected_resource_unauthorized
54
+
55
+ # Registered after AudienceVerification's own handler so this one wins:
56
+ # same 403 insufficient_scope, same generic description, plus the
57
+ # resource_metadata pointer.
58
+ rescue_from StandardId::InvalidAudienceError,
59
+ StandardId::InvalidAudienceProfileError,
60
+ with: :render_protected_resource_wrong_audience
61
+
62
+ rescue_from StandardId::InvalidScopeError, with: :render_protected_resource_insufficient_scope
63
+ end
64
+
65
+ class_methods do
66
+ # @param audience [String] the audience configured in
67
+ # `config.oauth.protected_resources` that tokens for this resource carry
68
+ # @param scopes [Array<String>, String, nil] when given, the token must
69
+ # carry at least one of these
70
+ def protected_resource(audience, scopes: nil)
71
+ self._protected_resource_audience = audience.to_s
72
+ self._protected_resource_scopes = Array(scopes).flat_map { |s| s.to_s.split }.reject(&:blank?).uniq
73
+ verify_audience audience.to_s
74
+ end
75
+ end
76
+
77
+ private
78
+
79
+ def protected_resource_audience
80
+ _protected_resource_audience.presence ||
81
+ raise(StandardId::ConfigurationError, "#{self.class.name} includes StandardId::ResourceServer but never called protected_resource")
82
+ end
83
+
84
+ def verify_protected_resource_scopes!
85
+ return if _protected_resource_scopes.empty?
86
+
87
+ require_scopes!(*_protected_resource_scopes)
88
+ end
89
+
90
+ def protected_resource_metadata_url
91
+ StandardId::Oauth::ProtectedResourceDocument.metadata_url(audience: protected_resource_audience, origin: request.base_url)
92
+ end
93
+
94
+ # The description never carries operator- or user-authored text: it goes
95
+ # into a quoted header and to an unauthenticated caller.
96
+ #
97
+ # RFC 6750 §3.1: when the request carried no credential at all, the
98
+ # challenge names the realm and the metadata document but no error code —
99
+ # that is the form an MCP client sees first, before it has any token.
100
+ def render_protected_resource_unauthorized(error = nil)
101
+ if request.authorization.blank?
102
+ set_protected_resource_challenge!(error: nil)
103
+ render json: { error: "invalid_token", error_description: "An access token is required" }, status: :unauthorized
104
+ return
105
+ end
106
+
107
+ description = case error
108
+ when StandardId::AccountDeactivatedError then "The account is deactivated"
109
+ when StandardId::AccountLockedError then "The account is locked"
110
+ else "The access token is invalid or has expired"
111
+ end
112
+ set_protected_resource_challenge!(error: "invalid_token", error_description: description)
113
+ render json: { error: "invalid_token", error_description: description }, status: :unauthorized
114
+ end
115
+
116
+ def render_protected_resource_wrong_audience(_error)
117
+ description = StandardId::AudienceVerification::GENERIC_INSUFFICIENT_SCOPE_MESSAGE
118
+ set_protected_resource_challenge!(error: "insufficient_scope", error_description: description)
119
+ render json: { error: "insufficient_scope", error_description: description }, status: :forbidden
120
+ end
121
+
122
+ def render_protected_resource_insufficient_scope(error)
123
+ description = error.message.presence || "The access token is missing a required scope"
124
+ set_protected_resource_challenge!(error: "insufficient_scope", error_description: description, scope: _protected_resource_scopes)
125
+ render json: { error: "insufficient_scope", error_description: description }, status: :forbidden
126
+ end
127
+
128
+ def set_protected_resource_challenge!(error:, error_description: nil, scope: nil)
129
+ response.set_header(
130
+ "WWW-Authenticate",
131
+ StandardId::Oauth::ProtectedResourceDocument.challenge(
132
+ audience: protected_resource_audience, origin: request.base_url,
133
+ error: error, error_description: error_description, scope: scope
134
+ )
135
+ )
136
+ end
137
+ end
138
+ end
@@ -106,6 +106,7 @@ module StandardId
106
106
  redirect_uri: params[:redirect_uri],
107
107
  scope: params[:scope],
108
108
  audience: params[:audience],
109
+ resource: params[:resource],
109
110
  state: params[:state],
110
111
  code_challenge: params[:code_challenge],
111
112
  code_challenge_method: params[:code_challenge_method],
@@ -34,6 +34,7 @@ module StandardId
34
34
  token_response = nil
35
35
  flow = nil
36
36
  begin
37
+ run_before_social_mint(account:, social_info:, provider_tokens:)
37
38
  flow = StandardId::Oauth::SocialFlow.new(
38
39
  params,
39
40
  request,
@@ -71,6 +72,28 @@ module StandardId
71
72
  true
72
73
  end
73
74
 
75
+ # config.social.before_mint, inside the same begin/rescue as the mint
76
+ # so a refusal here is cleaned up exactly like one from the grant.
77
+ def run_before_social_mint(account:, social_info:, provider_tokens:)
78
+ hook = StandardId.config.social.before_mint
79
+ return unless hook.respond_to?(:call)
80
+
81
+ # audience: and scopes: are passed explicitly: forwarded_request_params
82
+ # strips both (RESERVED_CALLBACK_PARAMS), and the audience is the
83
+ # thing the headline use case keys on.
84
+ context = {
85
+ account: account,
86
+ provider: provider.provider_name,
87
+ social_info: social_info,
88
+ provider_tokens: provider_tokens,
89
+ audience: params[:audience].presence,
90
+ scopes: params[:scope].presence,
91
+ params: forwarded_request_params,
92
+ request: request
93
+ }
94
+ hook.call(**StandardId::Utils::CallableParameterFilter.filter(hook, context))
95
+ end
96
+
74
97
  # The response was never sent, but the grant may already have
75
98
  # persisted a refresh token and a session — also when the grant itself
76
99
  # raised after writing them (e.g. an OAUTH_TOKEN_ISSUED subscriber), so
@@ -1,34 +1,68 @@
1
1
  module StandardId
2
2
  module Api
3
3
  module Oauth
4
- # RFC 7591 Dynamic Client Registration endpoint (POST /oauth/register).
4
+ # RFC 7591 Dynamic Client Registration (POST /oauth/register) and RFC
5
+ # 7592 client-configuration management (GET/PUT/DELETE
6
+ # /oauth/register/:client_id).
5
7
  #
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.
8
+ # Everything is absent (404) unless
9
+ # `StandardId.config.oauth.dynamic_registration_enabled` is true — an
10
+ # open, unauthenticated registration endpoint is state-mutating attack
11
+ # surface, so it is opt-in. The controller stays thin: it parses the JSON
12
+ # client metadata and delegates the RFC 7591 -> ClientApplication mapping
13
+ # (and the engine's security defaults and the host's pinned attributes)
14
+ # to StandardId::Oauth::ClientRegistration.
15
+ #
16
+ # The management actions are authenticated by the per-client registration
17
+ # access token issued at registration (returned once, stored as a digest),
18
+ # presented as a bearer credential; a client manages only itself. They
19
+ # exist once migration 20261020120000 has run.
13
20
  class RegistrationsController < BaseController
14
21
  public_controller
15
22
 
16
23
  # Throttle the open, unauthenticated registration endpoint by IP so an
17
- # enabled deployment can't be flooded with ClientApplication rows.
24
+ # enabled deployment can't be flooded with ClientApplication rows, and
25
+ # the management endpoint separately so token guessing is slow without
26
+ # management traffic competing with registrations for one budget.
18
27
  rate_limit to: StandardId.config.rate_limits.dynamic_registration_per_ip,
19
28
  within: 1.hour,
20
29
  name: "dynamic-registration-ip",
21
30
  only: :create,
22
31
  store: StandardId::RateLimitHandling::RATE_LIMIT_STORE
32
+ rate_limit to: StandardId.config.rate_limits.dynamic_registration_management_per_ip,
33
+ within: 1.hour,
34
+ name: "dynamic-registration-management-ip",
35
+ only: %i[show update destroy],
36
+ store: StandardId::RateLimitHandling::RATE_LIMIT_STORE
23
37
 
38
+ # GET and DELETE carry no body, so no Content-Type to validate.
39
+ skip_before_action :validate_content_type!, only: %i[show destroy]
24
40
  before_action :require_dynamic_registration_enabled!
41
+ before_action :authenticate_registration_access!, only: %i[show update destroy]
25
42
 
26
43
  # POST /oauth/register
27
44
  def create
28
- result = StandardId::Oauth::ClientRegistration.call(client_metadata)
45
+ result = StandardId::Oauth::ClientRegistration.call(client_metadata, request: request)
29
46
  render json: registration_response(result), status: :created
30
47
  end
31
48
 
49
+ # GET /oauth/register/:client_id — RFC 7592 §2.1
50
+ def show
51
+ render json: client_information_response(@client)
52
+ end
53
+
54
+ # PUT /oauth/register/:client_id — RFC 7592 §2.2
55
+ def update
56
+ result = StandardId::Oauth::ClientRegistration.update(@client, client_metadata, request: request)
57
+ render json: client_information_response(result.client)
58
+ end
59
+
60
+ # DELETE /oauth/register/:client_id — RFC 7592 §2.3
61
+ def destroy
62
+ StandardId::Oauth::ClientRegistration.deregister(@client)
63
+ head :no_content
64
+ end
65
+
32
66
  private
33
67
 
34
68
  # Return 404 (not 403) when the feature is off so the endpoint is
@@ -37,11 +71,34 @@ module StandardId
37
71
  head(:not_found) unless StandardId.config.oauth.dynamic_registration_enabled
38
72
  end
39
73
 
74
+ # RFC 7592 §2: an unknown client, a client that was not dynamically
75
+ # registered, a deregistered one, and a wrong or missing token are all
76
+ # the same 401, so the endpoint does not reveal which client_ids exist.
77
+ def authenticate_registration_access!
78
+ return head(:not_found) unless StandardId::ClientApplication.registration_management_column?
79
+
80
+ token = StandardId::BearerTokenExtraction.extract(request.headers["Authorization"])
81
+ client = StandardId::ClientApplication.live.dynamically_registered.find_by(client_id: params[:client_id])
82
+
83
+ if client&.authenticate_registration_access_token(token)
84
+ @client = client
85
+ return
86
+ end
87
+
88
+ challenge = token.present? ? %(Bearer error="invalid_token") : "Bearer"
89
+ response.set_header("WWW-Authenticate", challenge)
90
+ render json: {
91
+ error: "invalid_token",
92
+ error_description: "The registration access token does not authorize this client"
93
+ }, status: :unauthorized
94
+ end
95
+
40
96
  # Permit the full RFC 7591 client metadata document. We hand the raw
41
97
  # values to the service, which whitelists/maps them; the controller does
42
98
  # not need typed param coercion here.
43
99
  def client_metadata
44
100
  params.permit(
101
+ :client_id,
45
102
  :client_name,
46
103
  :scope,
47
104
  :token_endpoint_auth_method,
@@ -55,15 +112,36 @@ module StandardId
55
112
  %i[redirect_uris grant_types response_types].each do |key|
56
113
  permitted[key] = params[key] if permitted[key].blank? && params[key].present?
57
114
  end
115
+ # The route's :client_id shadows a body client_id in `params`; RFC
116
+ # 7592 §2.2 wants the DOCUMENT's id checked against the path.
117
+ permitted[:client_id] = request.request_parameters["client_id"]
58
118
  end
59
119
  end
60
120
 
61
121
  # RFC 7591 §3.2.1 success response. Echoes the registered metadata and,
62
122
  # for confidential clients, the one-time client_secret with
63
- # client_secret_expires_at: 0 (never expires).
123
+ # client_secret_expires_at: 0 (never expires); plus, when management is
124
+ # available, the RFC 7592 registration_client_uri and the one-time
125
+ # registration_access_token.
64
126
  def registration_response(result)
65
- client = result.client
127
+ body = client_information_response(result.client)
128
+ # Echo the registered value (RFC 7591 §3.2.1), not a value derived
129
+ # from client_type — both client_secret_basic and client_secret_post
130
+ # are accepted and both work at the token endpoint.
131
+ body[:token_endpoint_auth_method] = result.token_endpoint_auth_method
66
132
 
133
+ if result.client_secret
134
+ body[:client_secret] = result.client_secret
135
+ body[:client_secret_expires_at] = 0
136
+ end
137
+ body[:registration_access_token] = result.registration_access_token if result.registration_access_token
138
+ body
139
+ end
140
+
141
+ # The stable client information document shared by create/show/update
142
+ # (RFC 7591 §3.2.1, RFC 7592 §3). Never carries a secret or the
143
+ # registration access token; those are returned once, at registration.
144
+ def client_information_response(client)
67
145
  body = {
68
146
  client_id: client.client_id,
69
147
  client_id_issued_at: client.created_at.to_i,
@@ -72,19 +150,18 @@ module StandardId
72
150
  grant_types: client.grant_types_array,
73
151
  response_types: client.response_types_array,
74
152
  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
153
+ token_endpoint_auth_method: client.token_endpoint_auth_method
79
154
  }
80
-
81
- if result.client_secret
82
- body[:client_secret] = result.client_secret
83
- body[:client_secret_expires_at] = 0
84
- end
85
-
155
+ body[:registration_client_uri] = registration_client_uri(client) if client.dynamically_registered?
86
156
  body
87
157
  end
158
+
159
+ # Built from the request the way DiscoveryResolver builds endpoint URLs
160
+ # for a document served inside the mount: script_name IS the mount path.
161
+ def registration_client_uri(client)
162
+ mount = StandardId::Oauth::DiscoveryResolver.normalize_path(request.script_name)
163
+ "#{request.base_url}#{mount}/oauth/register/#{client.client_id}"
164
+ end
88
165
  end
89
166
  end
90
167
  end
@@ -0,0 +1,46 @@
1
+ module StandardId
2
+ module Api
3
+ module WellKnown
4
+ # RFC 9728 OAuth 2.0 Protected Resource Metadata.
5
+ #
6
+ # `GET /.well-known/oauth-protected-resource/<resource path>` serves the
7
+ # document for the resource configured at that path in
8
+ # `config.oauth.protected_resources`; the bare form serves the default
9
+ # (the only configured resource, or the one marked `root: true`). 404
10
+ # when nothing matches, so a host with no protected resources exposes no
11
+ # document. Public and cacheable, like the other discovery documents.
12
+ #
13
+ # Drawn at the origin root by `standard_id_protected_resource_routes`
14
+ # (the challenge in StandardId::ResourceServer names that URL) and also
15
+ # under the engine's own mount.
16
+ class OauthProtectedResourceController < ActionController::API
17
+ include StandardId::ControllerPolicy
18
+ public_controller
19
+
20
+ def show
21
+ audience = resolve_audience
22
+
23
+ unless audience
24
+ render json: { error: "not_found", error_description: "No protected resource is configured at this path" },
25
+ status: :not_found
26
+ return
27
+ end
28
+
29
+ response.headers["Cache-Control"] = "public, max-age=3600"
30
+ render json: StandardId::Oauth::ProtectedResourceDocument.build(
31
+ audience: audience, origin: request.base_url, request: request
32
+ )
33
+ end
34
+
35
+ private
36
+
37
+ def resolve_audience
38
+ path = params[:resource_path].presence
39
+ return StandardId::Oauth::ProtectedResourceDocument.default_audience if path.nil?
40
+
41
+ StandardId::Oauth::ProtectedResourceDocument.audience_for_path("/#{path}")
42
+ end
43
+ end
44
+ end
45
+ end
46
+ end