standard_id 0.48.1 → 0.49.1
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/CHANGELOG.md +25 -0
- data/README.md +172 -0
- data/app/controllers/concerns/standard_id/resource_server.rb +142 -0
- data/app/controllers/standard_id/api/authorization_controller.rb +1 -0
- data/app/controllers/standard_id/api/oauth/callback/providers_controller.rb +23 -0
- data/app/controllers/standard_id/api/oauth/registrations_controller.rb +99 -22
- data/app/controllers/standard_id/api/well_known/oauth_protected_resource_controller.rb +46 -0
- data/app/jobs/standard_id/cleanup_all_job.rb +4 -3
- data/app/jobs/standard_id/purge_stale_dynamic_clients_job.rb +73 -0
- data/app/models/standard_id/client_application.rb +38 -0
- data/config/routes/api.rb +9 -0
- data/db/migrate/20261020120000_add_registration_access_token_digest_to_standard_id_client_applications.rb +14 -0
- data/lib/generators/standard_id/install/install_generator.rb +2 -1
- data/lib/standard_id/config/callable_validator.rb +20 -0
- data/lib/standard_id/config/schema.rb +87 -0
- data/lib/standard_id/errors.rb +7 -0
- data/lib/standard_id/oauth/audience_resolver.rb +88 -0
- data/lib/standard_id/oauth/authorization_code_authorization_flow.rb +1 -1
- data/lib/standard_id/oauth/authorization_code_flow.rb +3 -1
- data/lib/standard_id/oauth/authorization_flow.rb +21 -1
- data/lib/standard_id/oauth/client_registration.rb +190 -27
- data/lib/standard_id/oauth/direct_mint_flow.rb +128 -0
- data/lib/standard_id/oauth/implicit_authorization_flow.rb +1 -1
- data/lib/standard_id/oauth/protected_resource_document.rb +171 -0
- data/lib/standard_id/oauth/refresh_token_flow.rb +1 -1
- data/lib/standard_id/routing.rb +21 -0
- data/lib/standard_id/token_minter.rb +74 -0
- data/lib/standard_id/version.rb +1 -1
- data/lib/standard_id.rb +4 -0
- data/lib/tasks/standard_id_tasks.rake +8 -1
- metadata +9 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 8b5e02f9651266077753a98125e3772a99bd2cf935da68d17f98df4bfd59505e
|
|
4
|
+
data.tar.gz: e703964c3b6c2c0ffd10a5475f370c9dea0f6f4873fd6827606ee9869a12726d
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 4091588b0c3b2cc3f06c25682e840cb2a1293a94d67abaf25eb81cce6331fe7a8325aea577fd69ceb5405c09dd0aa39fc0e70ed17df493a92da566fd8da10343
|
|
7
|
+
data.tar.gz: a10b76363a599b3c5fc29681665f8a20d564476cb6682a32c8555f4e2464945f41e0ccfcc7897db8c65df57483f8be3b927294282121d47bbc3226564e3dc0b2
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,31 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.49.1] - 2026-10-20
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
|
|
14
|
+
- **Dynamic registration refuses non-https redirect URIs** (`invalid_redirect_uri`): https, or http only on a loopback host (RFC 8252 §7.3), and at most 10 entries — the rules every consumer's own registration operation enforced and the gem's model (which also serves operator-registered clients) does not. Applies to RFC 7592 updates too.
|
|
15
|
+
- **A refused registration no longer leaves an owner row behind**: the `dynamic_registration_owner` callable now runs inside the registration transaction, so a host creating a per-registration owner record gets it rolled back when the client is invalid.
|
|
16
|
+
- **`StandardId::ResourceServer`'s scope check defers to the auth layer**: with no session (the action skipped `verify_access_token!`, e.g. a 405 responder) it no longer raises `NotAuthenticatedError` itself, matching `AudienceVerification`.
|
|
17
|
+
|
|
18
|
+
## [0.49.0] - 2026-10-20
|
|
19
|
+
|
|
20
|
+
### Added
|
|
21
|
+
|
|
22
|
+
- **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.
|
|
23
|
+
- **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.
|
|
24
|
+
|
|
25
|
+
- **`/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).
|
|
26
|
+
- **`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.
|
|
27
|
+
- **`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.
|
|
28
|
+
- `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`.
|
|
29
|
+
|
|
30
|
+
### Added
|
|
31
|
+
|
|
32
|
+
- **`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).
|
|
33
|
+
- **`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).
|
|
34
|
+
|
|
10
35
|
## [0.48.1] - 2026-10-20
|
|
11
36
|
|
|
12
37
|
### 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,142 @@
|
|
|
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
|
+
# Like verify_audience!: without a session there is nothing to check, and
|
|
85
|
+
# either the auth layer is answering the 401 or the action skipped
|
|
86
|
+
# verify_access_token! on purpose (a 405 responder, say).
|
|
87
|
+
def verify_protected_resource_scopes!
|
|
88
|
+
return if _protected_resource_scopes.empty?
|
|
89
|
+
return unless current_session
|
|
90
|
+
|
|
91
|
+
require_scopes!(*_protected_resource_scopes)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def protected_resource_metadata_url
|
|
95
|
+
StandardId::Oauth::ProtectedResourceDocument.metadata_url(audience: protected_resource_audience, origin: request.base_url)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# The description never carries operator- or user-authored text: it goes
|
|
99
|
+
# into a quoted header and to an unauthenticated caller.
|
|
100
|
+
#
|
|
101
|
+
# RFC 6750 §3.1: when the request carried no credential at all, the
|
|
102
|
+
# challenge names the realm and the metadata document but no error code —
|
|
103
|
+
# that is the form an MCP client sees first, before it has any token.
|
|
104
|
+
def render_protected_resource_unauthorized(error = nil)
|
|
105
|
+
if request.authorization.blank?
|
|
106
|
+
set_protected_resource_challenge!(error: nil)
|
|
107
|
+
render json: { error: "invalid_token", error_description: "An access token is required" }, status: :unauthorized
|
|
108
|
+
return
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
description = case error
|
|
112
|
+
when StandardId::AccountDeactivatedError then "The account is deactivated"
|
|
113
|
+
when StandardId::AccountLockedError then "The account is locked"
|
|
114
|
+
else "The access token is invalid or has expired"
|
|
115
|
+
end
|
|
116
|
+
set_protected_resource_challenge!(error: "invalid_token", error_description: description)
|
|
117
|
+
render json: { error: "invalid_token", error_description: description }, status: :unauthorized
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def render_protected_resource_wrong_audience(_error)
|
|
121
|
+
description = StandardId::AudienceVerification::GENERIC_INSUFFICIENT_SCOPE_MESSAGE
|
|
122
|
+
set_protected_resource_challenge!(error: "insufficient_scope", error_description: description)
|
|
123
|
+
render json: { error: "insufficient_scope", error_description: description }, status: :forbidden
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def render_protected_resource_insufficient_scope(error)
|
|
127
|
+
description = error.message.presence || "The access token is missing a required scope"
|
|
128
|
+
set_protected_resource_challenge!(error: "insufficient_scope", error_description: description, scope: _protected_resource_scopes)
|
|
129
|
+
render json: { error: "insufficient_scope", error_description: description }, status: :forbidden
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def set_protected_resource_challenge!(error:, error_description: nil, scope: nil)
|
|
133
|
+
response.set_header(
|
|
134
|
+
"WWW-Authenticate",
|
|
135
|
+
StandardId::Oauth::ProtectedResourceDocument.challenge(
|
|
136
|
+
audience: protected_resource_audience, origin: request.base_url,
|
|
137
|
+
error: error, error_description: error_description, scope: scope
|
|
138
|
+
)
|
|
139
|
+
)
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
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
|
|
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
|
-
#
|
|
7
|
-
# `StandardId.config.oauth.dynamic_registration_enabled` is true — an
|
|
8
|
-
# unauthenticated registration endpoint is state-mutating attack
|
|
9
|
-
# so it is opt-in.
|
|
10
|
-
#
|
|
11
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
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
|