diff --git a/docs/specs/access-token-audience-binding.md b/docs/specs/access-token-audience-binding.md new file mode 100644 index 0000000000..6eb7aeac37 --- /dev/null +++ b/docs/specs/access-token-audience-binding.md @@ -0,0 +1,273 @@ +# Access Token Audience Binding + +Access token audience binding is the mechanism by which Authgear binds the `aud` claim of a JWT access token to one or more specific resource server URIs, preventing a token issued for one resource from being accepted by another. + +This is implemented via [RFC 8707 — Resource Indicators for OAuth 2.0](https://www.rfc-editor.org/rfc/rfc8707). + +## Table of Contents + +- [Glossary](#glossary) +- [Background](#background) +- [Default Audience and Audience Confusion Risk](#default-audience-and-audience-confusion-risk) +- [How It Works](#how-it-works) + - [Without Resource Indicator](#without-resource-indicator) + - [With Resource Indicator](#with-resource-indicator) +- [Authorization Endpoint](#authorization-endpoint) +- [Token Endpoint](#token-endpoint) + - [authorization_code grant](#authorization_code-grant) + - [refresh_token grant](#refresh_token-grant) +- [Access Token Claims](#access-token-claims) +- [Error Cases](#error-cases) +- [Backward Compatibility](#backward-compatibility) +- [Relationship to M2M](#relationship-to-m2m) + +## Glossary + +**Resource** — a protected API or service identified by an `https://` URI (e.g. `https://api.example.com/orders`). Resources are pre-registered in the portal and optionally configured with `access_policy.allow_dynamic_third_party_client_access: true` to permit third-party client access. See [API Resources and Scopes](./api-resource.md). + +**Resource-specific Scope** — a scope value (e.g. `read:orders`) that is defined on a Resource and only meaningful when the corresponding Resource is included in the `resource` parameter. + +**Resource Indicator** — the `resource` request parameter defined by RFC 8707, used by clients to declare which resource(s) they want a token to be bound to. + +**Access Token Audience Binding** — the act of including one or more resource URIs in the `aud` claim of an access token, so that each resource server can validate that the token was intended for it. + +## Background + +Without access token audience binding, all Authgear access tokens share `aud = []`. A resource server that only validates `aud` cannot distinguish tokens intended for different services — a token issued to a third-party client would be structurally accepted by a first-party client on the same project. This is the **audience confusion** risk. + +The standard solution is RFC 8707 resource indicators: clients declare their target resource at request time, and Authgear binds the `aud` of the issued token to that resource URI. Resource servers can then enforce `aud` contains their own URI. + +Authgear previously supported resource indicators only for `m2m` clients using the `client_credentials` grant. This spec extends support to all client types using the `authorization_code` and `refresh_token` grants. + +## Default Audience and Audience Confusion Risk + +### The problem with `aud = []` + +Without any resource binding, all JWT access tokens issued by a project share `aud = []`. This means a token issued to client A is structurally accepted by any resource server that validates against the same project endpoint — including APIs that were never intended to accept tokens from client A. The audience confusion risk is especially acute for third-party clients, which are operated by external developers. + +### Competitor analysis + +We reviewed how other providers handle this: + +| Provider | Default `aud` without explicit audience config | Out-of-box isolation | +|---|---|---| +| Auth0 | Issues an **opaque** (non-JWT) token scoped only to userinfo | **Enforced by design.** Without specifying `audience=` (a pre-registered API identifier), callers cannot obtain a JWT at all — forcing developers to consciously bind every token to a resource. | +| Keycloak | No meaningful resource server audience | **None by default.** Keycloak provides "Audience Mapper" configuration: admins create a Client Scope, attach an Audience Mapper with the resource server URI, and assign that scope to specific clients. This works when configured, but requires deliberate per-resource setup. Deployments that skip this configuration remain fully exposed. | +| Okta | Fixed audience set at the authorization server level (e.g. `api://default`) | **Partial, coarse-grained.** All tokens from one authorization server share a fixed `aud`. Isolation between different resource servers requires deploying separate authorization servers — impractical for most projects. | + +### Authgear's decision + +Authgear takes a different approach for first-party and third-party clients: + +**First-party clients:** + +The JWT access token retains the existing default: + +``` +aud = [""] +``` + +This preserves backward compatibility for existing first-party deployments. + +**Third-party clients:** + +An **opaque** access token is issued instead of a JWT. The opaque token: + +- Can be presented to the userinfo endpoint (`/oauth2/userinfo`) to retrieve user information. +- Cannot be used with the `/resolve` endpoint. +- Has no `aud` claim and cannot be validated by a resource server independently. + +This solves the audience confusion problem for third-party clients by design: without specifying a `resource`, a third-party client can only access userinfo and nothing else. + +**Both client types (with `resource` parameter):** + +A JWT access token is issued with: + +``` +aud = [""] +``` + +The project endpoint is **not** included. See [How It Works](#how-it-works) for the access precondition. + +## How It Works + +### Without Resource Indicator + +| Client type | Token type | `aud` | +|---|---|---| +| First-party | JWT | `[]` | +| Third-party | Opaque | N/A | + +### With Resource Indicator + +When `resource` is specified, Authgear checks whether the client is permitted to access that resource using the following logic: + +1. If the Resource has `access_policy.allow_dynamic_third_party_client_access: true` **and** the requested Scope(s) have `access_policy.allow_dynamic_third_party_client_access: true` — any third-party client is allowed. +2. Otherwise, an explicit Client-Resource Association is required. Currently only M2M clients support explicit associations (see [API Resources and Scopes](./api-resource.md#client-resource-association)). Third-party clients without the access policy set on the resource cannot use it. + +When access is permitted, a JWT access token is issued with `aud = []`. The project endpoint is **not** included in `aud`. + +See [API Resources and Scopes](./api-resource.md) for how to register Resources and configure access. + +## Authorization Endpoint + +``` +GET /oauth2/authorize + ?client_id= + &response_type=code + &scope=openid offline_access read:orders + &redirect_uri= + &code_challenge= + &code_challenge_method=S256 + &resource=https://api.example.com/orders ← optional, repeatable + &resource=https://api.example.com/inventory ← multiple resources allowed +``` + +**Rules:** + +- `resource` is optional. + - First-party client, omitted: issues a JWT with `aud = []`. + - Third-party client, omitted: issues an opaque access token. +- Each `resource` value must refer to a Resource the client is permitted to access: either the Resource and requested Scopes have `access_policy.allow_dynamic_third_party_client_access: true` (for third-party clients), or the client is an M2M client with an explicit Client-Resource Association for that Resource. Otherwise `invalid_target` is returned. +- Resource URIs must not be prefixed by the Authgear project endpoint. +- The granted resources are bound to the authorization code and stored server-side. + +## Token Endpoint + +### `authorization_code` grant + +``` +POST /oauth2/token +Content-Type: application/x-www-form-urlencoded + +grant_type=authorization_code +&code= +&code_verifier= +&client_id= +&redirect_uri= +&resource=https://api.example.com/orders ← optional +``` + +**Rules:** + +- `resource` is optional at this step. +- If provided, it must be a subset of the resources bound to the authorization code. Requesting a resource outside the bound set returns `invalid_target`. +- If omitted: + - If resources were bound to the authorization code, the token is issued as a JWT with `aud` containing those resource URIs. + - If no resources were bound (first-party client only): JWT with `aud = []`. + - If no resources were bound (third-party client): opaque access token. + +### `refresh_token` grant + +``` +POST /oauth2/token +Content-Type: application/x-www-form-urlencoded + +grant_type=refresh_token +&refresh_token= +&client_id= +&resource=https://api.example.com/orders ← optional, downscoping allowed +``` + +**Rules:** + +- `resource` is optional. +- If provided, it must be a subset of the resources originally authorized (downscoping is allowed; upscoping is not). +- If omitted, the new access token is issued for the same resources as the previous access token in this session. +- Requesting a resource not in the original grant returns `invalid_target`. + +## Access Token Claims + +### With Resource Indicator + +When `resource` is specified, `aud` contains **only** the requested resource URI(s). The Authgear project endpoint is not included. The `scope_by_aud` claim maps which scopes apply to which resource. OIDC scopes (e.g. `openid`, `offline_access`) that were granted appear in the top-level `scope` field even though there is no corresponding `aud` entry for the project endpoint. + +```json +{ + "iss": "https://myapp.authgear.cloud", + "sub": "user-id", + "aud": ["https://api.example.com/orders"], + "client_id": "dcrc_Xf2kLmNpQrStUvWx", + "scope": "openid offline_access read:orders", + "https://authgear.com/claims/scope_by_aud": [ + { + "aud": "https://api.example.com/orders", + "scope": "read:orders" + } + ] +} +``` + +The userinfo endpoint accepts tokens where `scope` contains OIDC scopes (e.g. `openid`, `profile`, `email`), regardless of the `aud` claim. Resource servers should validate `aud` contains their own URI and `scope` contains the required resource-specific scopes. + +### Default — first-party client + +A JWT is issued with `aud` set to the project endpoint: + +```json +{ + "iss": "https://myapp.authgear.cloud", + "sub": "user-id", + "aud": ["https://myapp.authgear.cloud"], + "client_id": "spa-client-id", + "scope": "openid offline_access" +} +``` + +### Default — third-party client + +An opaque access token is issued. It has no `aud` claim and cannot be decoded by the caller. It is only accepted by the userinfo endpoint. + +### Resource server validation + +A resource server at `https://api.example.com/orders` should validate: + +1. `access_token` is a valid JWT signed by the Authgear project key (via `jwks_uri`). +2. `iss` matches the expected Authgear project endpoint. +3. `aud` includes `https://api.example.com/orders`. +4. `scope` (or `scope_by_aud` for the resource's entry) contains the required scopes. + +## Error Cases + +Error response format differs by endpoint: + +- **Authorization endpoint** — errors are returned as a redirect to `redirect_uri` with `error` and `error_description` query parameters (per RFC 6749 §4.1.2.1). There is no direct HTTP error response. +- **Token endpoint** — errors are returned as a JSON body with HTTP 400 (per RFC 6749 §5.2). + +### Authorization endpoint errors + +| Condition | `error` | +|---|---| +| `resource` URI is not a pre-registered Resource | `invalid_target` | +| `resource` URI is prefixed by the Authgear project endpoint | `invalid_target` | +| Client is third-party and the Resource does not have `access_policy.allow_dynamic_third_party_client_access: true` | `invalid_target` | +| Client is an M2M client and no explicit Client-Resource Association exists | `invalid_target` | +| `scope` includes a resource-specific scope but no matching `resource` was requested | `invalid_scope` | +| Requested scope is not permitted for the client on that resource | `invalid_scope` | + +### Token endpoint errors + +| Condition | `error` | HTTP status | +|---|---|---| +| `resource` URI at token exchange (`authorization_code` grant) is not a subset of what was authorized | `invalid_target` | 400 | +| `resource` URI at refresh (`refresh_token` grant) is not a subset of the original grant | `invalid_target` | 400 | + +## Backward Compatibility + +### First-party clients + +Unchanged. JWT with `aud = []`. Existing resource servers that validate `aud` contains `` continue to work without modification. + +### Third-party clients + +Third-party clients are new. No existing behavior is affected. + +### `aud` when `resource` is specified + +When `resource` is specified, `aud` contains **only** the resource URI(s). This is new behavior — `resource` support for `authorization_code` and `refresh_token` grants did not previously exist. + +## Relationship to M2M + +The `m2m` client type (`client_credentials` grant) already supports resource indicators as described in `docs/specs/m2m.md`. This spec extends the same mechanism — the same pre-registered Resources, the same client-resource association model, and the same `scope_by_aud` claim — to the `authorization_code` and `refresh_token` grants for all client types. + +The key difference is that for `client_credentials`, `resource` is **required** (per existing implementation). For `authorization_code` and `refresh_token`, `resource` is **optional** to preserve backward compatibility. diff --git a/docs/specs/api-resource.md b/docs/specs/api-resource.md new file mode 100644 index 0000000000..61817225e2 --- /dev/null +++ b/docs/specs/api-resource.md @@ -0,0 +1,378 @@ +# API Resources and Scopes + +API Resources represent protected external services identified by HTTPS URIs. Together with their Scopes, they are the mechanism by which Authgear binds access tokens to specific audiences and controls what permissions a client may request. + +Resources are shared across multiple features: + +- **M2M** (`client_credentials` grant) — confidential clients request tokens bound to a specific Resource. See [M2M spec](./m2m.md). +- **Third-party clients** — clients can request tokens for Resources where `access_policy.allow_dynamic_third_party_client_access` is `true`. See [DCR spec](./dcr.md) and [Third-Party Client spec](./third-party-client.md). +- **First-party clients** (all grant types) — first-party clients can request resource-bound tokens if they are explicitly associated with the Resource. + +## Table of Contents + +- [Glossary](#glossary) +- [Resource URI Requirements](#resource-uri-requirements) +- [Scope Requirements](#scope-requirements) +- [Access Policy](#access-policy) +- [Client-Resource Association](#client-resource-association) +- [Access Token Behavior](#access-token-behavior) +- [Data Model](#data-model) +- [Admin API](#admin-api) + +## Glossary + +**Resource** — a protected external API or service, uniquely identified within a project by an HTTPS URI. + +**Scope** — a permission value defined on a Resource (e.g. `read:orders`). Scopes are local to their Resource; `read:orders` on `https://onlinestore.myapp.com` is a different permission from `read:orders` on `https://inventory.myapp.com`. + +**Client-Resource Association** — an explicit link between an OAuth client and a Resource, together with a subset of the Resource's Scopes that the client may request. Required for first-party M2M clients. + +## Resource URI Requirements + +The URI of a Resource must satisfy the following: + +- It is a URI as defined in [RFC 3986](https://datatracker.ietf.org/doc/html/rfc3986). +- It must be unique within a Project. +- It must use the `https:` scheme. +- It must not be a domain or subdomain of Authgear's default domains (e.g. `authgearapps.com`, `authgear.cloud`). +- It may have a path component. `https://api.myapp.com` and `https://api.myapp.com/` are both valid but are treated as different Resources. +- It must not have a query component. +- It must not have a fragment component. +- It must not have a userinfo component. + +## Scope Requirements + +Scopes are defined per-Resource. A scope value must: + +- Not be any of the following reserved values: `openid`, `profile`, `email`, `address`, `phone`, `offline_access`, `device_sso`. +- Not start with `https://authgear.com`. +- Conform to the `scope-token` grammar defined in [RFC 6749 §3.3](https://datatracker.ietf.org/doc/html/rfc6749#section-3.3). + +## Access Policy + +Each Resource and Scope has an `access_policy` JSON object that controls which clients may access it without a per-client association. By default all policy flags are `false` (absent keys are treated as `false`). + +### Current policy keys + +| Key | Type | Default | Meaning | +|---|---|---|---| +| `allow_dynamic_third_party_client_access` | boolean | `false` | When `true`, all third-party clients may access this Resource or Scope without a per-client association | + +### Two-level check + +Both the Resource and the individual Scope must have `allow_dynamic_third_party_client_access: true` for a third-party client to successfully request that scope: + +- **Resource level** — when `true`, any third-party client may include that Resource URI in the `resource` parameter of their authorization requests. +- **Scope level** — when `true`, that scope may be requested by any third-party client. When `false` (the default), the scope is inaccessible to third-party clients even if the parent Resource has the flag set. + +This allows fine-grained control: for example, a Resource may expose `read:orders` to third-party clients but keep `delete:orders` restricted to explicitly associated clients. + +> **Rationale:** Third-party clients are not created by project collaborators and cannot be individually trusted with per-client associations. The `access_policy` on each Resource/Scope lets admins declare, once, which permissions are safe to expose to all third-party clients. Any third-party client may then access those resources without further admin action per client. + +> **Extensibility:** `access_policy` is a JSON object rather than a plain boolean column so that new policy dimensions can be added in the future without a schema migration. New keys default to `false` when absent, preserving the behavior of existing records. + +## Client-Resource Association + +First-party M2M clients (using `client_credentials`) require explicit associations: + +1. The admin associates a client with a Resource in the portal. +2. The admin grants specific Scopes from that Resource to the client. +3. The client may then request tokens using `resource=` and (optionally) `scope=`. + +If a client requests a Resource it is not associated with, the server returns `invalid_resource`. If a client requests a Scope not in its grant, the server returns `invalid_scope`. If no `scope` is specified, all scopes in the client's association are granted. + +Third-party clients accessing a Resource where `access_policy.allow_dynamic_third_party_client_access` is `true` do not require a per-client association. + +## Access Token Behavior + +When a client requests a token with `resource=`, the issued access token has: + +- `aud = []` — the audience is set to the requested resource URI only. The Authgear project endpoint is **not** included. +- `scope` — includes both resource-specific scopes and any OIDC scopes (e.g. `openid`, `profile`, `email`) that were requested and granted. + +The userinfo endpoint accepts tokens where `scope` contains OIDC scopes (e.g. `openid`, `profile`, `email`), regardless of whether the Authgear project endpoint is present in `aud`. This allows clients that specify `resource` to still call userinfo if the token contains the appropriate OIDC scopes. + +When multiple `resource` values are requested (first-party clients only), `aud` includes all requested resource URIs and a `scope_by_aud` claim maps which scopes apply to which audience. When scopes are ambiguous across resources, the token is downscoped to the intersection. See [M2M spec](./m2m.md) for details on downscoping. + +See [Access Token Audience Binding](./access-token-audience-binding.md) for the full specification of audience behavior. + +## Data Model + +```sql +CREATE TABLE _auth_resource ( + id text PRIMARY KEY, + app_id text NOT NULL, + created_at timestamp without time zone NOT NULL, + updated_at timestamp without time zone NOT NULL, + uri text NOT NULL, + name text, + metadata jsonb, + -- Access policy JSON object. Missing keys default to false. + -- Current keys: allow_dynamic_third_party_client_access (bool) + access_policy jsonb NOT NULL DEFAULT '{}' +); +-- Each project has its own set of Resources. The URI must be unique within a project. +CREATE UNIQUE INDEX _auth_resource_uri_unique ON _auth_resource USING btree (app_id, uri); +-- Support typeahead search +CREATE INDEX _auth_resource_uri_typeahead ON _auth_resource USING btree (app_id, uri text_pattern_ops); +CREATE INDEX _auth_resource_name_typeahead ON _auth_resource USING btree (app_id, name text_pattern_ops); + +CREATE TABLE _auth_resource_scope ( + id text PRIMARY KEY, + app_id text NOT NULL, + created_at timestamp without time zone NOT NULL, + updated_at timestamp without time zone NOT NULL, + resource_id text NOT NULL REFERENCES _auth_resource(id), + scope text NOT NULL, + description text, + metadata jsonb, + -- Access policy JSON object. Missing keys default to false. + -- Current keys: allow_dynamic_third_party_client_access (bool) + access_policy jsonb NOT NULL DEFAULT '{}' +); +-- Each Resource has its own set of Scopes. The scope must be unique within a Resource. +CREATE UNIQUE INDEX _auth_resource_scope_unique ON _auth_resource_scope USING btree (app_id, resource_id, scope); +-- Support typeahead search +CREATE INDEX _auth_resource_scope_scope_typeahead ON _auth_resource_scope USING btree (app_id, resource_id, scope text_pattern_ops); + +CREATE TABLE _auth_client_resource ( + id text PRIMARY KEY, + app_id text NOT NULL, + created_at timestamp without time zone NOT NULL, + updated_at timestamp without time zone NOT NULL, + -- Since client is not stored in the database, it is not a foreign key. + client_id text NOT NULL, + resource_id text NOT NULL REFERENCES _auth_resource(id) +); +-- Each Client can only be associated with a Resource once. +CREATE UNIQUE INDEX _auth_client_resource_unique ON _auth_client_resource USING btree (app_id, client_id, resource_id); + +CREATE TABLE _auth_client_resource_scope ( + id text PRIMARY KEY, + app_id text NOT NULL, + created_at timestamp without time zone NOT NULL, + updated_at timestamp without time zone NOT NULL, + -- Since client is not stored in the database, it is not a foreign key. + client_id text NOT NULL, + resource_id text NOT NULL REFERENCES _auth_resource(id), + scope_id text NOT NULL REFERENCES _auth_resource_scope(id) +); +-- Each Client can only be associated with a Resource Scope once. +CREATE UNIQUE INDEX _auth_client_resource_scope_unique ON _auth_client_resource_scope USING btree (app_id, client_id, resource_id, scope_id); +``` + +## Admin API + +The following GraphQL schema changes support managing Resources and Scopes via the Admin API. + +Resource and Scope CRUD operations do **not** generate events. + +```graphql +type Query { + """If clientID is null, then all resources are returned in a paginated fashion.""" + """If clientID is specified, then all resources associated with the clientID are returned in a paginated fashion.""" + """If searchKeyword is non-null, a prefix search of resourceURI or name is performed.""" + """If both clientID and searchKeyword are specified, they are AND-ed.""" + resources(clientID: String, searchKeyword: String, after: String, before: String, first: Int, last: Int): ResourceConnection +} + +type Mutation { + createResource(input: CreateResourceInput!): CreateResourcePayload! + updateResource(input: UpdateResourceInput!): UpdateResourcePayload! + deleteResource(input: DeleteResourceInput!): DeleteResourcePayload! + + createScope(input: CreateScopeInput!): CreateScopePayload! + updateScope(input: UpdateScopeInput!): UpdateScopePayload! + deleteScope(input: DeleteScopeInput!): DeleteScopePayload! + + addResourceToClientID(input: AddResourceToClientIDInput!): AddResourceToClientIDPayload! + removeResourceFromClientID(input: RemoveResourceFromClientIDInput!): RemoveResourceFromClientIDPayload! + addScopesToClientID(input: AddScopesToClientIDInput!): AddScopesToClientIDPayload! + removeScopesFromClientID(input: RemoveScopesFromClientIDInput!): RemoveScopesFromClientIDPayload! + replaceScopesOfClientID(input: ReplaceScopesOfClientIDInput!): ReplaceScopesOfClientIDPayload! +} + +""" +Access policy for a Resource or Scope. Controls which clients may access it +without a per-client association. All fields default to false when absent +from the underlying JSON storage. +""" +type AccessPolicy { + """ + When true, all third-party clients may access this Resource or Scope + without a per-client association. + """ + allowDynamicThirdPartyClientAccess: Boolean! +} + +""" +Input for setting an access policy. When provided on an update mutation, +replaces the entire stored policy — omitted fields are reset to false. +When omitted on an update mutation, the existing policy is left unchanged. +""" +input AccessPolicyInput { + """Default false.""" + allowDynamicThirdPartyClientAccess: Boolean +} + +type Resource implements Entity & Node { + id: ID! + createdAt: DateTime! + updatedAt: DateTime! + resourceURI: String! + name: String + accessPolicy: AccessPolicy! + """If clientID is null, then all scopes of this Resource is returned.""" + """If clientID is specified, then only scopes that are associated with clientID is returned.""" + """If searchKeyword is non-null, a prefix search of scope is performed.""" + """If both clientID and searchKeyword are specified, they are AND-ed.""" + scopes(clientID: String, searchKeyword: String, after: String, before: String, first: Int, last: Int): ScopeConnection + """The list of client IDs associated with this Resource.""" + clientIDs: [String!]! +} + +type Scope implements Entity & Node { + id: ID! + createdAt: DateTime! + updatedAt: DateTime! + resourceID: ID! + scope: String! + description: String + accessPolicy: AccessPolicy! +} + +type ResourceEdge { + cursor: String! + resource: Resource +} + +type ResourceConnection { + edges: [ResourceEdge] + pageInfo: PageInfo! + totalCount: Int +} + +type ScopeEdge { + cursor: String! + scope: Scope +} + +type ScopeConnection { + edges: [ScopeEdge] + pageInfo: PageInfo! + totalCount: Int +} + +input CreateResourceInput { + resourceURI: String! + name: String + """If omitted, all access policy fields default to false.""" + accessPolicy: AccessPolicyInput +} + +type CreateResourcePayload { + resource: Resource! +} + +input UpdateResourceInput { + resourceURI: String! + """The new name.""" + name: String + """If omitted, the existing access policy is unchanged.""" + accessPolicy: AccessPolicyInput +} + +type UpdateResourcePayload { + resource: Resource! +} + +input DeleteResourceInput { + resourceURI: String! +} + +type DeleteResourcePayload { + ok: Boolean +} + +input CreateScopeInput { + resourceURI: String! + scope: String! + description: String + """If omitted, all access policy fields default to false.""" + accessPolicy: AccessPolicyInput +} + +type CreateScopePayload { + scope: Scope! +} + +input UpdateScopeInput { + resourceURI: String! + scope: String! + """The new description.""" + description: String + """If omitted, the existing access policy is unchanged.""" + accessPolicy: AccessPolicyInput +} + +type UpdateScopePayload { + scope: Scope! +} + +input DeleteScopeInput { + resourceURI: String! + scope: String! +} + +type DeleteScopePayload { + ok: Boolean +} + +input AddResourceToClientIDInput { + resourceURI: String! + clientID: String! +} + +type AddResourceToClientIDPayload { + resource: Resource! +} + +input RemoveResourceFromClientIDInput { + resourceURI: String! + clientID: String! +} + +type RemoveResourceFromClientIDPayload { + resource: Resource! +} + +input AddScopesToClientIDInput { + resourceURI: String! + scopes: [String!]! + clientID: String! +} + +type AddScopesToClientIDPayload { + scopes: [Scope!]! +} + +input RemoveScopesFromClientIDInput { + resourceURI: String! + scopes: [String!]! + clientID: String! +} + +type RemoveScopesFromClientIDPayload { + scopes: [Scope!]! +} + +input ReplaceScopesOfClientIDInput { + resourceURI: String! + clientID: String! + scopes: [String!]! +} + +type ReplaceScopesOfClientIDPayload { + scopes: [Scope!]! +} +``` diff --git a/docs/specs/client.md b/docs/specs/client.md new file mode 100644 index 0000000000..5058839047 --- /dev/null +++ b/docs/specs/client.md @@ -0,0 +1,311 @@ +# Client Model + +An Authgear **client** is any OAuth 2.0 / OIDC application that interacts with the authorization server. Clients may originate from two sources: + +1. **Static clients** — declared in `authgear.yaml` under `oauth.clients`. Changes require a configuration deploy. +2. **Dynamic clients** — registered at runtime via [Dynamic Client Registration (DCR)](./dcr.md). Stored in the database. + +This document defines the unified client model and presents it as a GraphQL type. It then describes how each source maps into the model. + +## Table of Contents + +- [GraphQL Type](#graphql-type) +- [Mapping from Static Config](#mapping-from-static-config) +- [Mapping from DCR](#mapping-from-dcr) + +## GraphQL Type + +```graphql +type OAuthClient { + """Unique, immutable client identifier.""" + clientID: String! + + """ + Whether the client is first-party or third-party. Third-party clients always + show a consent screen before issuing tokens; first-party clients skip it. + """ + kind: OAuthClientKind! + + """ + Whether the client is confidential. Confidential clients authenticate at the + token endpoint using a client_secret. Public clients use PKCE instead. + """ + isConfidential: Boolean! + + """ + Whether the client is a service client (M2M). When true, the client acts as + its own principal — API resource assignments grant access to the client itself. + When false, the client acts on behalf of a user — API resource assignments + grant user-delegated access. + """ + isServiceClient: Boolean! + + """ + OIDC application_type of this client. Only set for DCR clients; null for + static config clients. Stored to support future RFC 7592 management — when a + DCR client updates its redirect_uris, this value is used to re-validate them. + "web": redirect URIs must use https://; localhost is not allowed. + "native": custom URI schemes and http://localhost are allowed. + """ + applicationType: String + + """ + Human-readable display name shown in the portal. + For static clients this is the `name` field. + For DCR clients this is client_name (auto-generated as "Client " if omitted). + """ + name: String! + + """ + OIDC client_name presented on the consent screen. + Null for static clients that do not set client_name (spa, traditional_webapp, native, m2m). + """ + clientName: String + + """URI of the client's home page. https:// only.""" + clientURI: String + + """URI of the client's logo image. Shown on the consent screen. https:// only.""" + logoURI: String + + """URI of the client's Terms of Service page. https:// only.""" + tosURI: String + + """URI of the client's Privacy Policy page. https:// only.""" + policyURI: String + + """Redirect URIs the client is allowed to use. Empty for M2M clients.""" + redirectURIs: [String!]! + + """ + Post-logout redirect URIs. Non-empty when the client requires back-channel + logout notification (i.e. x_application_type: traditional_webapp in config). + Always empty for DCR clients. + """ + postLogoutRedirectURIs: [String!]! + + """Grant types the client is permitted to use.""" + grantTypes: [String!]! + + """Response types the client is permitted to request.""" + responseTypes: [String!]! + + """Access token lifetime in seconds.""" + accessTokenLifetimeSeconds: Int! + + """Refresh token lifetime in seconds.""" + refreshTokenLifetimeSeconds: Int! + + """Whether the refresh token idle timeout is active.""" + refreshTokenIdleTimeoutEnabled: Boolean! + + """Idle timeout for refresh tokens in seconds.""" + refreshTokenIdleTimeoutSeconds: Int! + + """Whether refresh token rotation is enabled.""" + refreshTokenRotationEnabled: Boolean! + + """ + Whether the server issues JWT access tokens instead of opaque tokens. + Always true for M2M clients. + """ + issueJWTAccessToken: Boolean! + + """ + Maximum number of concurrent sessions. 0 = unlimited, 1 = at most one. + Always 0 for DCR clients. + """ + maxConcurrentSession: Int! + + """ + URI of a custom auth UI. When set, the auth server responds HTTP 200 instead + of redirecting. Static clients only; always null for DCR clients. + """ + customUIURI: String + + """Whether App2App is enabled. Static clients only; always false for DCR clients.""" + app2appEnabled: Boolean! + + """Static clients only; always false for DCR clients.""" + app2appInsecureDeviceKeyBindingEnabled: Boolean! + + """Whether DPoP sender-constraint is disabled. Static clients only; always false for DCR clients.""" + dpopDisabled: Boolean! + + """Allowed authentication flows. Static clients only; always null for DCR clients.""" + authenticationFlowAllowlist: AuthenticationFlowAllowlist + + """Whether the pre-authenticated URL feature is enabled. Static clients only; always false for DCR clients.""" + preAuthenticatedURLEnabled: Boolean! + + """Allowed origins for pre-authenticated URL. Static clients only; always empty for DCR clients.""" + preAuthenticatedURLAllowedOrigins: [String!]! + + """ + When true, the project logo is replaced with logoURI on the auth UI. + Static clients only; always false for DCR clients. + """ + replaceProjectLogoWithLogoURI: Boolean! + + """RFC 3339 timestamp of DCR registration. Null for static clients.""" + registeredAt: DateTime +} + +type AuthenticationFlowAllowlist { + groups: [AuthenticationFlowAllowlistGroup!]! + flows: [AuthenticationFlowAllowlistFlow!]! +} + +type AuthenticationFlowAllowlistGroup { + name: String! +} + +type AuthenticationFlowAllowlistFlow { + """One of: signup, promote, login, signup_login, reauth, account_recovery.""" + type: String! + name: String! +} + +enum OAuthClientKind { + """Operated by a project collaborator. Consent screen is not shown.""" + FIRST_PARTY + + """Operated by an external developer. Consent screen is always shown.""" + THIRD_PARTY +} +``` + +## Mapping from Static Config + +The config field `x_application_type` is a shorthand that encodes `isThirdParty`, `isConfidential`, and `applicationType` together. The table below shows the decomposition. The `spa` and `traditional_webapp` values both map to the same three fields; the distinction between them is preserved in `postLogoutRedirectURIs` (non-empty for `traditional_webapp`). + +| `x_application_type` | `kind` | `isConfidential` | `isServiceClient` | `applicationType` | +|---|---|---|---|---| +| `spa` | `FIRST_PARTY` | `false` | `false` | `null` | +| `traditional_webapp` | `FIRST_PARTY` | `false` | `false` | `null` | +| `native` | `FIRST_PARTY` | `false` | `false` | `null` | +| `confidential` | `FIRST_PARTY` | `true` | `false` | `null` | +| `third_party_app` *(deprecated)* | `THIRD_PARTY` | `true` | `false` | `null` | +| `m2m` | `FIRST_PARTY` | `true` | `true` | `null` | + +All other fields map directly by name. Given this `authgear.yaml` entry: + +```yaml +oauth: + clients: + - client_id: myapp + name: My SPA + x_application_type: spa + redirect_uris: + - https://myapp.example.com/callback + access_token_lifetime_seconds: 1800 + refresh_token_lifetime_seconds: 86400 + refresh_token_idle_timeout_enabled: true + refresh_token_idle_timeout_seconds: 3600 + issue_jwt_access_token: true +``` + +The resulting `OAuthClient` object is: + +```json +{ + "clientID": "myapp", + "kind": "FIRST_PARTY", + "isConfidential": false, + "isServiceClient": false, + "applicationType": null, + "name": "My SPA", + "clientName": null, + "clientURI": null, + "logoURI": null, + "tosURI": null, + "policyURI": null, + "redirectURIs": ["https://myapp.example.com/callback"], + "postLogoutRedirectURIs": [], + "grantTypes": ["authorization_code", "refresh_token"], + "responseTypes": ["code"], + "accessTokenLifetimeSeconds": 1800, + "refreshTokenLifetimeSeconds": 86400, + "refreshTokenIdleTimeoutEnabled": true, + "refreshTokenIdleTimeoutSeconds": 3600, + "refreshTokenRotationEnabled": false, + "issueJWTAccessToken": true, + "maxConcurrentSession": 0, + "customUIURI": null, + "app2appEnabled": false, + "app2appInsecureDeviceKeyBindingEnabled": false, + "dpopDisabled": false, + "authenticationFlowAllowlist": null, + "preAuthenticatedURLEnabled": false, + "preAuthenticatedURLAllowedOrigins": [], + "replaceProjectLogoWithLogoURI": false, + "registeredAt": null +} +``` + +Fields absent from `authgear.yaml` resolve to their defaults via `OAuthClientConfig.SetDefaults()`. Extension fields not present in the config resolve to `false` / `null` / `[]`. + +## Mapping from DCR + +When a client is registered via `POST /oauth2/register`, `kind` is determined by the IAT type and `applicationType` comes directly from the OIDC `application_type` field in the request body. DCR clients are always public, so `isConfidential` is always `false`. + +| DCR `application_type` | IAT type | `kind` | `isConfidential` | `isServiceClient` | `applicationType` | +|---|---|---|---|---|---| +| `web` (or omitted) | First-party (`iat_fp_`) | `FIRST_PARTY` | `false` | `false` | `"web"` | +| `native` | First-party (`iat_fp_`) | `FIRST_PARTY` | `false` | `false` | `"native"` | +| `web` (or omitted) | Third-party (`iat_tp_`) or none | `THIRD_PARTY` | `false` | `false` | `"web"` | +| `native` | Third-party (`iat_tp_`) or none | `THIRD_PARTY` | `false` | `false` | `"native"` | + +Given this DCR request (with a first-party IAT): + +```http +POST /oauth2/register +Authorization: Bearer iat_fp_Xf2kLmNpQrStUvWx +Content-Type: application/json + +{ + "client_name": "PR #123 preview", + "redirect_uris": ["https://pr-123.preview.example.com/callback"], + "application_type": "web" +} +``` + +The resulting `OAuthClient` object is: + +```json +{ + "clientID": "dcrc_Xf2kLmNpQrStUvWx", + "kind": "FIRST_PARTY", + "isConfidential": false, + "isServiceClient": false, + "applicationType": "web", + "name": "PR #123 preview", + "clientName": "PR #123 preview", + "clientURI": null, + "logoURI": null, + "tosURI": null, + "policyURI": null, + "redirectURIs": ["https://pr-123.preview.example.com/callback"], + "postLogoutRedirectURIs": [], + "grantTypes": ["authorization_code", "refresh_token"], + "responseTypes": ["code"], + "accessTokenLifetimeSeconds": 1800, + "refreshTokenLifetimeSeconds": 2592000, + "refreshTokenIdleTimeoutEnabled": true, + "refreshTokenIdleTimeoutSeconds": 1209600, + "refreshTokenRotationEnabled": false, + "issueJWTAccessToken": false, + "maxConcurrentSession": 0, + "customUIURI": null, + "app2appEnabled": false, + "app2appInsecureDeviceKeyBindingEnabled": false, + "dpopDisabled": false, + "authenticationFlowAllowlist": null, + "preAuthenticatedURLEnabled": false, + "preAuthenticatedURLAllowedOrigins": [], + "replaceProjectLogoWithLogoURI": false, + "registeredAt": "2024-11-15T00:00:00Z" +} +``` + +Token lifetime fields are populated from `oauth.dynamic_client_registration.default_client_config` when set, otherwise from the project defaults. All Authgear extension fields are fixed at their zero values for DCR clients and cannot be changed at registration time. diff --git a/docs/specs/dcr.md b/docs/specs/dcr.md new file mode 100644 index 0000000000..fee7ad25fc --- /dev/null +++ b/docs/specs/dcr.md @@ -0,0 +1,593 @@ +# Dynamic Client Registration (DCR) + +Authgear supports Dynamic Client Registration as defined by: + +- [RFC 7591 — OAuth 2.0 Dynamic Client Registration Protocol](https://www.rfc-editor.org/rfc/rfc7591) +- [OpenID Connect Dynamic Client Registration 1.0](https://openid.net/specs/openid-connect-registration-1_0.html) + +## Table of Contents + +- [Glossary](#glossary) +- [Use Cases](#use-cases) +- [Configuration](#configuration) +- [OIDC Discovery Metadata](#oidc-discovery-metadata) +- [Initial Access Token](#initial-access-token) +- [Registration Endpoint](#registration-endpoint) + - [Request](#request) + - [Response](#response) + - [Errors](#errors) +- [Accepted Client Metadata](#accepted-client-metadata) +- [Client ID Format](#client-id-format) +- [Storage Architecture](#storage-architecture) +- [Security Considerations](#security-considerations) + - [Access Token Audience Binding](#access-token-audience-binding) +- [Admin API](#admin-api) + - [IAT management](#iat-management) +- [Future Works](#future-works) + +## Glossary + +**Dynamic Client Registration (DCR)** — the process by which an OAuth client registers itself programmatically with an Authorization Server at runtime, rather than being statically configured in `authgear.yaml`. + +**Initial Access Token (IAT)** — an opaque token issued by the Admin API and presented to the registration endpoint. Two types exist, with distinct token prefixes that make their privilege level immediately visible: + +- **Third-party IAT** (prefix `iat_tp_`) — allows registration of `web` and `native` clients as third-party clients (consent screen shown). Lower privilege; safe to distribute to developers building integrations. +- **First-party IAT** (prefix `iat_fp_`) — allows registration of `web` and `native` clients as first-party clients (consent screen bypassed). High privilege — treat with the same care as the Admin API private key. + +When `initial_access_token_required: false` (open registration), no IAT is required and only third-party clients may be registered. + +## Use Cases + +### UC1. Ephemeral clients for CI / pull-request preview environments + +A CI system holds the Admin API private key for a project. For each pull request, the CI registers a new first-party client scoped to that PR's redirect URI. + +A first-party IAT (`iat_fp_`) is required because first-party clients bypass the consent screen and must only be created by an authorized administrator. + +**Required configuration:** + +```yaml +oauth: + dynamic_client_registration: + enabled: true + initial_access_token_required: true # default; explicitly set for clarity +``` + +No `default_client_config` override is needed — CI clients use the project-level token lifetimes and do not require resource indicator support. + +**Step 1 — Create a first-party IAT via the Admin API** + +Call the `createInitialAccessToken` Admin API mutation (see [Admin API](#admin-api)): + +```graphql +mutation { + createInitialAccessToken(input: { type: FIRST_PARTY, expiresIn: 3600 }) { + token # iat_fp_Xf2kLmNpQrStUvWx + expiresAt + } +} +``` + +Store the returned `token` value securely — it is returned once only. + +**Step 2 — Register the client** + +``` +POST /oauth2/register HTTP/1.1 +Host: myapp.authgear.cloud +Content-Type: application/json +Authorization: Bearer + +{ + "client_name": "PR #123 preview", + "redirect_uris": ["https://pr-123.preview.example.com/callback"], + "application_type": "web" +} +``` + +Response: + +```json +{ + "client_id": "dcrc_Xf2kLmNpQrStUvWx", + "client_id_issued_at": 1700000000, + "client_name": "PR #123 preview", + "redirect_uris": ["https://pr-123.preview.example.com/callback"], + "grant_types": ["authorization_code", "refresh_token"], + "response_types": ["code"], + "application_type": "web" +} +``` + +**Step 3 — Use the client in the authorization code flow** + +The PR preview app uses `client_id=dcrc_Xf2kLmNpQrStUvWx` as a normal SPA client for the lifetime of the PR. + +**Step 4 — Backend validates the access token** + +Because no `resource` parameter is used, the issued access token is a JWT with the default audience: + +```json +{ + "iss": "https://myapp.authgear.cloud", + "sub": "", + "aud": ["https://myapp.authgear.cloud"], + "client_id": "dcrc_Xf2kLmNpQrStUvWx", + "scope": "openid" +} +``` + +The PR preview backend validates the token as follows: + +1. Confirm the token is a JWT. +2. Fetch `jwks_uri` from `https://myapp.authgear.cloud/.well-known/openid-configuration` and verify the JWT signature. +3. Check `iss` equals `https://myapp.authgear.cloud`. +4. Check `aud` includes `https://myapp.authgear.cloud`. +5. Check `exp` has not elapsed. + +> Client deletion is not supported in this version. See [Future Works](#future-works). + +--- + +### UC2. MCP (Model Context Protocol) clients + +Per the [MCP Authorization specification](https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization), each MCP client registers itself with the Authorization Server at first use. With open registration enabled, MCP clients self-register without any admin involvement per client. + +**Required configuration:** + +```yaml +oauth: + dynamic_client_registration: + enabled: true + initial_access_token_required: false # open registration — no IAT needed +``` + +**Admin setup (once)** + +1. Enable open registration as shown above. +2. In the portal, create an API Resource for `https://mcp-server.example.com` with scopes `read:tools` and `execute:tools`. +3. On the Resource and on each scope that MCP clients should be able to request, set `access_policy.allow_dynamic_third_party_client_access: true`. + +No further per-client admin action is required — any MCP client can self-register and immediately use the declared resources. + +**Step 1 — Discover the authorization server** + +``` +GET /.well-known/oauth-authorization-server HTTP/1.1 +Host: myapp.authgear.cloud +MCP-Protocol-Version: 2025-11-25 +``` + +Response includes `registration_endpoint`. + +**Step 2 — Register the client** + +``` +POST /oauth2/register HTTP/1.1 +Host: myapp.authgear.cloud +Content-Type: application/json + +{ + "redirect_uris": ["https://mcp-client.example.com/callback"] +} +``` + +Response: + +```json +{ + "client_id": "dcrc_AbCdEfGhIjKlMnOpQr", + "client_id_issued_at": 1700000000, + "client_name": "Client dcrc_AbCdEfGhIjKlMnOpQr", + "redirect_uris": ["https://mcp-client.example.com/callback"], + "grant_types": ["authorization_code", "refresh_token"], + "response_types": ["code"], + "application_type": "web" +} +``` + +**Step 3 — Authorization code flow with resource indicator** + +``` +GET /oauth2/authorize + ?client_id=dcrc_AbCdEfGhIjKlMnOpQr + &response_type=code + &scope=openid+read:tools + &redirect_uri=https://mcp-client.example.com/callback + &code_challenge= + &code_challenge_method=S256 + &resource=https://mcp-server.example.com HTTP/1.1 +Host: myapp.authgear.cloud +``` + +The user sees a consent screen and authorizes the MCP client. + +**Step 4 — Exchange code for tokens** + +``` +POST /oauth2/token HTTP/1.1 +Host: myapp.authgear.cloud +Content-Type: application/x-www-form-urlencoded + +grant_type=authorization_code +&code= +&code_verifier= +&client_id=dcrc_AbCdEfGhIjKlMnOpQr +&redirect_uri=https://mcp-client.example.com/callback +&resource=https://mcp-server.example.com +``` + +The issued access token has `aud: ["https://mcp-server.example.com"]` (the resource URI only; the project endpoint is not included). The MCP server validates `aud` contains its own URI. If `openid` or other OIDC scopes were also requested and granted, the userinfo endpoint remains accessible via that token. + +## Configuration + +```yaml +oauth: + dynamic_client_registration: + enabled: true + initial_access_token_required: true + default_client_config: + access_token_lifetime_seconds: 1800 + refresh_token_lifetime_seconds: 2592000 + refresh_token_idle_timeout_enabled: true + refresh_token_idle_timeout_seconds: 1209600 +``` + +- `oauth.dynamic_client_registration.enabled`: Optional. Boolean. Default `false`. Enables `POST /oauth2/register`. +- `oauth.dynamic_client_registration.initial_access_token_required`: Optional. Boolean. Default `true`. When `true`, registration requires a valid IAT in the `Authorization: Bearer` header; all `application_type` values are accepted. When `false`, open registration is permitted but only `application_type: web` and `application_type: native` are accepted. + +- `oauth.dynamic_client_registration.default_client_config`: Optional. Object. The default client config applied to all DCR-registered clients. Useful when stricter settings are needed for the DCR cohort. Per-client overrides are not yet supported; see [Future Works](#future-works). Supports a subset of the fields defined in [Custom Client Metadata](./oidc.md#custom-client-metadata): `access_token_lifetime_seconds`, `refresh_token_lifetime_seconds`, `refresh_token_idle_timeout_enabled`, `refresh_token_idle_timeout_seconds`. + +> **Note:** Resource access for third-party clients is configured via the portal, not `authgear.yaml`. Resources and Scopes with `access_policy.allow_dynamic_third_party_client_access: true` are accessible to all third-party clients, including DCR-registered ones. See [API Resources and Scopes](./api-resource.md#access-policy). + +## OIDC Discovery Metadata + +When DCR is enabled, `registration_endpoint` is added to the discovery documents at: + +- `/.well-known/openid-configuration` +- `/.well-known/oauth-authorization-server` + +Full example of `/.well-known/openid-configuration` with DCR enabled (fields taken from the actual Authgear implementation): + +```jsonc +{ + "issuer": "https://myapp.authgear.cloud", + "authorization_endpoint": "https://myapp.authgear.cloud/oauth2/authorize", + "token_endpoint": "https://myapp.authgear.cloud/oauth2/token", + "userinfo_endpoint": "https://myapp.authgear.cloud/oauth2/userinfo", + "end_session_endpoint": "https://myapp.authgear.cloud/oauth2/logout", + "revocation_endpoint": "https://myapp.authgear.cloud/oauth2/revoke", + "jwks_uri": "https://myapp.authgear.cloud/oauth2/jwks", + "registration_endpoint": "https://myapp.authgear.cloud/oauth2/register", // Added + // ... +} +``` + +## Initial Access Token + +An IAT is an **opaque** token issued by the Admin API (see [Admin API — IAT mutation](#new-mutation-createinitialaccesstoken)). It is passed as `Authorization: Bearer ` to the registration endpoint. + +An IAT authorizes the bearer to register a new OAuth client. The key behavioral rules are: + +- **With a first-party IAT** (`iat_fp_`) — `web` and `native` clients are registered as first-party (consent screen bypassed). +- **With a third-party IAT** (`iat_tp_`) — `web` and `native` clients are registered as third-party (consent screen shown). +- **Without an IAT** (open registration, `initial_access_token_required: false`) — `web` and `native` clients are registered as third-party. + +### Per-IAT configuration + +The Admin API may attach per-token configuration when creating an IAT. The exact set of supported config options is not yet defined and will be extended over time. The current behavior (IAT presence grants first-party registration) requires no additional config. + +### IAT storage + +IATs are stored hashed in the database. The plaintext value is returned exactly once at creation time and is not recoverable afterwards. + +```sql +CREATE TABLE _auth_oauth_initial_access_token ( + id text PRIMARY KEY, + app_id text NOT NULL, + created_at timestamp without time zone NOT NULL, + expires_at timestamp without time zone NOT NULL, + token_hash text NOT NULL +); +CREATE UNIQUE INDEX _auth_oauth_initial_access_token_hash_unique ON _auth_oauth_initial_access_token USING btree (app_id, token_hash); +``` + +## Registration Endpoint + +``` +POST /oauth2/register +``` + +### Request + +``` +POST /oauth2/register HTTP/1.1 +Host: myapp.authgear.cloud +Content-Type: application/json +Authorization: Bearer (omit when initial_access_token_required: false) +``` + +See [Accepted Client Metadata](#accepted-client-metadata) for the full list of request body fields. + +### Response + +**201 Created** on success. + +```json +{ + "client_id": "dcrc_Xf2kLmNpQrStUvWx", + "client_id_issued_at": 1700000000, + "client_name": "PR #123 preview", + "redirect_uris": ["https://pr-123.preview.example.com/callback"], + "grant_types": ["authorization_code", "refresh_token"], + "response_types": ["code"], + "application_type": "web" +} +``` + +- `client_secret` is not issued in this version. Confidential clients are not supported via DCR. +- `client_secret_expires_at: 0` means non-expiring (per RFC 7591 §3.2.1). +- `client_secret` is returned **once only** and is not recoverable afterwards. The caller must store it securely. + +### Errors + +Error responses follow [RFC 7591 §3.2.2](https://www.rfc-editor.org/rfc/rfc7591#section-3.2.2): + +```json +{ + "error": "invalid_client_metadata", + "error_description": "redirect_uris must use HTTPS. See https://docs.authgear.com/..." +} +``` + +| `error` value | HTTP status | Meaning | +|---|---|---| +| `invalid_redirect_uri` | 400 | One or more `redirect_uris` are invalid (e.g. plain `http://` for non-localhost) | +| `invalid_client_metadata` | 400 | Other metadata validation failure — see table below | +| `invalid_initial_access_token` | 401 | IAT is missing, expired, or not recognized | +| `access_denied` | 403 | Registration is not permitted (e.g. DCR is disabled, or a first-party IAT is required but a third-party IAT or no IAT was presented) | + +**`invalid_client_metadata` causes:** + +| Condition | Example | +|---|---| +| `redirect_uris` is missing | omitted from request body | +| `redirect_uris` contains a URI with a fragment component | `https://example.com/callback#section` | +| `token_endpoint_auth_method` is provided (field not accepted) | `token_endpoint_auth_method=client_secret_post` | +| `grant_types` contains an unsupported value | `grant_types=["implicit"]` | +| `response_types` contains an unsupported value | `response_types=["token"]` | +| `response_types` is inconsistent with `grant_types` | `grant_types=["refresh_token"]` + `response_types=["code"]` without `authorization_code` | +| `logo_uri`, `client_uri`, `tos_uri`, or `policy_uri` is not `https://` | `logo_uri=http://example.com/logo.png` | + +## Accepted Client Metadata + +The following fields are accepted in the registration request body. All other client configuration fields require direct admin access through the portal. + +### `client_name` (optional) + +Human-readable name for the client, displayed on the consent screen and in the portal. When omitted, Authgear generates a default name from the `client_id` (e.g. `Client dcrc_Xf2kLmNpQrStUvWx`). + +### `redirect_uris` (required) + +Array of redirect URIs the client will use in authorization code flows. Each URI must be: + +- An `https://` URI, **or** +- A custom URI scheme (e.g., `com.example.app://callback`) for native apps. + +Plain `http://` URIs are rejected except for `http://localhost` (loopback), which is allowed for native app development. + +Each URI must be an absolute URI (per RFC 3986 §4.3) and must not contain a fragment component (`#`). + +If `redirect_uris` is omitted, the server returns `invalid_client_metadata`. + +### `grant_types` (optional) + +Array of grant types the client is allowed to use. Accepted values: + +| Value | Meaning | +|---|---| +| `authorization_code` | Standard OAuth 2.0 authorization code flow | +| `refresh_token` | Allows the client to exchange a refresh token for new access tokens | + +Default: `["authorization_code", "refresh_token"]`. + +### `response_types` (optional) + +Array of response types. Must be consistent with `grant_types`. The only accepted value is `code`, which must be paired with the `authorization_code` grant type. Requesting `response_types=["code"]` without `authorization_code` in `grant_types`, or vice versa, returns `invalid_client_metadata`. + +Default: `["code"]`. + +### `application_type` (optional) + +Controls the client's technical profile (redirect URI rules, PKCE requirements). Authgear accepts the two standard OIDC DCR values: + +| Value | IAT type required | Consent screen | `kind` | Redirect URI validation | +|---|---|---|---|---| +| `web` (default) | none or `iat_tp_` | Yes | `THIRD_PARTY` | Must use `https://`; `localhost` not allowed | +| `native` | none or `iat_tp_` | Yes | `THIRD_PARTY` | Custom URI scheme or `http://localhost` | +| `web` | `iat_fp_` | No | `FIRST_PARTY` | Must use `https://`; `localhost` not allowed | +| `native` | `iat_fp_` | No | `FIRST_PARTY` | Custom URI scheme or `http://localhost` | + +Default: `web`. + +The IAT type — not `application_type` — determines whether the registered client is first-party or third-party. `application_type` describes only the technical profile (redirect URI rules, etc.). + +### `logo_uri` (optional) + +URL of the client's logo image, shown on the consent screen. Must be an `https://` URL. + +### `client_uri` (optional) + +URL of the client's home page. Must be an `https://` URL. + +### `tos_uri` (optional) + +URL of the client's Terms of Service page, shown on the consent screen. Must be an `https://` URL. + +### `policy_uri` (optional) + +URL of the client's Privacy Policy page, shown on the consent screen. Must be an `https://` URL. + +## Client ID Format + +DCR-registered clients and IATs use the following prefixed formats: + +| Token | Prefix | Entropy | Example | +|---|---|---|---| +| `client_id` | `dcrc_` | 22 chars URL-safe base64 (16 bytes) | `dcrc_Xf2kLmNpQrStUvWx` | +| Third-party IAT | `iat_tp_` | 22 chars URL-safe base64 (16 bytes) | `iat_tp_Xf2kLmNpQrStUvWx` | +| First-party IAT | `iat_fp_` | 22 chars URL-safe base64 (16 bytes) | `iat_fp_Xf2kLmNpQrStUvWx` | + +`dcrc` = **D**ynamic **C**lient **R**egistration **C**lient. The prefix distinguishes DCR clients from statically configured clients in `authgear.yaml`. The `iat_tp_` / `iat_fp_` prefixes make the privilege level of an IAT immediately visible — a leaked `iat_fp_` token has significantly higher blast radius than a leaked `iat_tp_` token. + +## Storage Architecture + +DCR-registered clients are stored in the **database**, not in `authgear.yaml`. Authgear loads both static clients (from `authgear.yaml`) and DCR clients (from the database) at request time, merging them into a unified client list. + +The runtime behavior of a DCR client (authorization code flow, token endpoint, consent screen, etc.) is identical to that of a static client with the same `kind` and `application_type`. + +DCR client secrets are stored hashed in the database. + +## Security Considerations + +### Access Token Audience Binding + +By default, all Authgear access tokens share `aud = []`. A resource server that only validates `aud` cannot distinguish tokens intended for different services — this is the **audience confusion** risk. + +Authgear mitigates this via RFC 8707 resource indicators. Resource owners pre-register their API as a Resource in the portal and associate it with allowed clients. When a client requests a token with `resource=`, the issued access token includes that URI in `aud`, and the resource server can enforce `aud` contains its own URI. + +DCR-registered clients, being third-party clients, support resource indicators via API Resources registered in the portal. Only Resources with `access_policy.allow_dynamic_third_party_client_access: true` are accessible to third-party clients, and only Scopes with `access_policy.allow_dynamic_third_party_client_access: true` may be requested. All other project resources and scopes remain inaccessible, preventing audience confusion against first-party clients. + +The admin configures the access policy once per Resource/Scope in the portal. Individual DCR clients then autonomously use `resource=` in their authorization requests without any further admin action per client. See [API Resources and Scopes](./api-resource.md#access-policy) and [Access Token Audience Binding](./access-token-audience-binding.md) for the full design. + +## Admin API + +The portal displays registered clients by querying the Admin GraphQL API. Client creation is done by calling `POST /oauth2/register` directly with an IAT (when required); client management (read, update, delete) is deferred to RFC 7592. + +### IAT management + +```graphql +type Query { + """Returns all active (non-expired) Initial Access Tokens for the project.""" + initialAccessTokens: [InitialAccessToken!]! +} + +type Mutation { + """Creates an opaque Initial Access Token for use with POST /oauth2/register.""" + createInitialAccessToken(input: CreateInitialAccessTokenInput!): CreateInitialAccessTokenPayload! + + """Revokes an Initial Access Token so it can no longer be used for registration.""" + revokeInitialAccessToken(input: RevokeInitialAccessTokenInput!): RevokeInitialAccessTokenPayload! +} + +enum InitialAccessTokenType { + """ + Can register web and native clients as third-party (consent screen shown). + Token prefix: iat_tp_ + """ + THIRD_PARTY + + """ + Can register web and native clients as first-party (consent screen bypassed). + Token prefix: iat_fp_ + High privilege — protect this token like the Admin API private key. + """ + FIRST_PARTY +} + +type InitialAccessToken implements Node { + id: ID! + createdAt: DateTime! + expiresAt: DateTime! + type: InitialAccessTokenType! +} + +input CreateInitialAccessTokenInput { + """ + Token lifetime in seconds. If omitted, a server default is used (e.g. 3600). + """ + expiresIn: Int + """ + Defaults to THIRD_PARTY. Specify FIRST_PARTY only when registering + first-party clients is required (e.g. CI/CD pipelines). The issued token + will carry the iat_fp_ prefix as a visible indicator of its elevated privilege. + """ + type: InitialAccessTokenType +} + +type CreateInitialAccessTokenPayload { + """ + The opaque IAT value. Returned ONCE only — not recoverable after this response. + Store it securely and pass it as Authorization: Bearer to POST /oauth2/register. + """ + token: String! + initialAccessToken: InitialAccessToken! +} + +input RevokeInitialAccessTokenInput { + id: ID! +} + +type RevokeInitialAccessTokenPayload { + ok: Boolean +} +``` + +### Client model + +DCR-registered clients are represented using the unified `OAuthClient` model defined in [Client Model](./client.md). See that document for the full type definition and the mapping from DCR registration fields to model fields. + +### New query + +```graphql +extend type Query { + """Returns DCR-registered clients only. Static clients are managed via authgear.yaml.""" + dynamicClients( + first: Int + after: String + last: Int + before: String + ): DynamicClientConnection! +} + +type DynamicClientConnection { + edges: [DynamicClientEdge!]! + pageInfo: PageInfo! + totalCount: Int! +} + +type DynamicClientEdge { + node: OAuthClient! + cursor: String! +} +``` + +## Future Works + +### Per client config update + +Currently `default_client_config` applies a single set of token lifetimes to all DCR clients. Providers such as Keycloak support per-client config overrides configured by an admin after registration. This will be supported via the Admin API or portal once per-client management of DCR clients is implemented. + +### Client management (RFC 7592) + +DCR clients cannot currently be read, updated, or deleted after registration. RFC 7592 is the planned mechanism for all post-registration client management — see below. + +### RFC 7592 — Client Registration Management + +[RFC 7592](https://www.rfc-editor.org/rfc/rfc7592) defines three endpoints for managing a registered client after initial registration, each protected by a per-client **Registration Access Token (RAT)**: + +- `GET /oauth2/register/{client_id}` — read current client metadata +- `PUT /oauth2/register/{client_id}` — replace mutable metadata fields +- `DELETE /oauth2/register/{client_id}` — delete the client and revoke all its tokens + +When RFC 7592 is implemented, the registration response (`POST /oauth2/register`) will also include: + +```json +{ + "registration_access_token": "rat_Yz9mAbCdEfGhIjKlMnOpQrStUvWxYz", + "registration_client_uri": "https://myapp.authgear.cloud/oauth2/register/dcrc_Xf2kLmNpQrStUvWx" +} +``` + +The RAT will use the prefix `rat_` (32 chars URL-safe base64, 24 bytes entropy) and be stored hashed in the database. It will be issued once and not recoverable if lost. + diff --git a/docs/specs/m2m.md b/docs/specs/m2m.md index da4077d688..a6c6b1a2dd 100644 --- a/docs/specs/m2m.md +++ b/docs/specs/m2m.md @@ -337,21 +337,12 @@ In my own interpretation: To support M2M, we need to introduce Resource and its associated Scope. +The canonical specification of Resources and Scopes — including URI requirements, scope requirements, the access policy, and the data model — is in [API Resources and Scopes](./api-resource.md). + Per `RFC8707`, resource has to be identified with a URI. Therefore, it follows naturally that we mandate a Resource identified by a non-modifiable URI. Both Auth0 and Kinde disallow changing this URI identifier, so it should be a sane design decision. -The URI of a Resource must satisfy the following requirements: - -- It is a URI as defined in [RFC3986](https://datatracker.ietf.org/doc/html/rfc3986). -- It must be unique within a Project. -- It must be of `https:` scheme. -- It must not be a subdomain of the default domains of Authgear. For example, if Authgear has default domains `authgearapps.com` and `authgear.cloud`, then its domain must not be those, of subdomains of those. -- It can optionally have a path component. For example, both `https://api.myapp.com` and `https://api.myapp.com/` are valid. They are treated as different Resources though. No path normalization is taken. -- It must not have a query component. For example, `https://api.myapp.com?a=b` is NOT a valid URI of a Resource. -- It must not have a fragment component. For example, `https://api.myapp.com#a` is NOT a valid URI of a Resource. -- It must not have a userinfo component. For example, `https://username:password@api.myapp.com` is NOT a valid URI of a Resource. - To maximize the compatibility of M2M with a wide range of software, we make Scope local to a specific Resource. This means the `read:orders` of `https://onlinestore.myapp.com` is different from `https://inventory.myapp.com`. @@ -360,18 +351,7 @@ This means the `read:orders` of `https://onlinestore.myapp.com` is different fro It is observed that `OIDC-Core` defined scopes **CANNOT** be used to create Permission of API in Auth0. -A list of well-known scopes: - -- [openid](https://openid.net/specs/openid-connect-core-1_0.html#AuthRequest) -- [profile email address phone](https://openid.net/specs/openid-connect-core-1_0.html#ScopeClaims) -- [offline_access](https://openid.net/specs/openid-connect-core-1_0.html#OfflineAccess) -- [device_sso](https://openid.net/specs/openid-connect-native-sso-1_0.html#section-3.1) - -The Scope of a Resource must satisfy the following requirements: - -- It must not be one of the following: `openid`, `profile`, `email`, `address`, `phone`, `offline_access`, or `device_sso`. -- It must not start with `https://authgear.com`. -- It must be valid for the grammar defined in [RFC6749 section-3.3](https://datatracker.ietf.org/doc/html/rfc6749#section-3.3) +See [API Resources and Scopes — Scope Requirements](./api-resource.md#scope-requirements) for the canonical list of constraints. ### Discussion: Resource, Scope, Client, and downscoping @@ -855,65 +835,13 @@ This means ### Changes in data models -Here are the schema of the changes: - -```sql -CREATE TABLE _auth_resource ( - id text PRIMARY KEY, - app_id text NOT NULL, - created_at timestamp without time zone NOT NULL, - updated_at timestamp without time zone NOT NULL, - uri text NOT NULL, - name text, - metadata jsonb -); --- Each project has its own set of Resources. The URI must be unique within a project. -CREATE UNIQUE INDEX _auth_resource_uri_unique ON _auth_resource USING btree (app_id, uri); --- Support typeahead search -CREATE INDEX _auth_resource_uri_typeahead ON _auth_resource USING btree (app_id, uri text_pattern_ops); -CREATE INDEX _auth_resource_name_typeahead ON _auth_resource USING btree (app_id, name text_pattern_ops); - -CREATE TABLE _auth_resource_scope ( - id text PRIMARY KEY, - app_id text NOT NULL, - created_at timestamp without time zone NOT NULL, - updated_at timestamp without time zone NOT NULL, - resource_id text NOT NULL REFERENCES _auth_resource(id), - scope text NOT NULL, - description text, - metadata jsonb -); --- Each Resource has its own set of Scopes. The scope must be unique within a Resource. -CREATE UNIQUE INDEX _auth_resource_scope_unique ON _auth_resource_scope USING btree (app_id, resource_id, scope); --- Support typeahead search -CREATE INDEX _auth_resource_scope_scope_typeahead ON _auth_resource_scope USING btree (app_id, resource_id, scope text_pattern_ops); - -CREATE TABLE _auth_client_resource ( - id text PRIMARY KEY, - app_id text NOT NULL, - created_at timestamp without time zone NOT NULL, - updated_at timestamp without time zone NOT NULL, - -- Since client is not stored in the database, it is not a foreign key. - client_id text NOT NULL, - resource_id text NOT NULL REFERENCES _auth_resource(id), -); --- Each Client can only associate with a Resource once. -CREATE UNIQUE INDEX _auth_client_resource_unique ON _auth_client_resource USING btree (app_id, client_id, resource_id); +The `_auth_resource`, `_auth_resource_scope`, `_auth_client_resource`, and `_auth_client_resource_scope` tables are specified in [API Resources and Scopes — Data Model](./api-resource.md#data-model). -CREATE TABLE _auth_client_resource_scope ( - id text PRIMARY KEY, - app_id text NOT NULL, - created_at timestamp without time zone NOT NULL, - updated_at timestamp without time zone NOT NULL, - -- Since client is not stored in the database, it is not a foreign key. - client_id text NOT NULL, - resource_id text NOT NULL REFERENCES _auth_resource(id), - scope_id text NOT NULL REFERENCES _auth_resource_scope(id) -); --- Each Client can only associate with a Resource scope once. -CREATE UNIQUE INDEX _auth_client_resource_scope_unique ON _auth_client_resource USING btree (app_id, client_id, resource_id, scope_id); +The following table tracks user consent for a specific (client, resource) pair and is **not** part of the M2M MVP. It is included here for completeness: +```sql -- A sibling table of _auth_oauth_authorization, that takes resource_id into account. +-- NOT part of the MVP. Required when resource indicators are supported in the Authorization Code flow. CREATE TABLE _auth_oauth_authorization_resource ( id text PRIMARY KEY, app_id text NOT NULL, @@ -921,194 +849,13 @@ CREATE TABLE _auth_oauth_authorization_resource ( user_id text NOT NULL REFERENCES _auth_user(id), resource_id text NOT NULL REFERENCES _auth_resource(id), scope_id text NOT NULL REFERENCES _auth_resource_scope(id) -) +); CREATE UNIQUE INDEX _auth_oauth_authorization_resource_unique ON _auth_oauth_authorization_resource USING btree (app_id, client_id, user_id, resource_id, scope_id); ``` ### Changes in Admin API -The changes are mainly the CRUD of Resources and Scopes. - -The CRUD of Resources and Scopes DO NOT generate events. - -The following GraphQL schema snippet describe the changes to the Admin API GraphQL schema. - -```graphql -type Query { - """If clientID is null, then all resources are returned in a paginated fashion.""" - """If clientID is specified, then all resources associated with the clientID are returned in a paginated fashion.""" - """If searchKeyword is non-null, a prefix search of resourceURI or name is performed.""" - """If both clientID and searchKeyword are specified, they are AND-ed.""" - resources(clientID: String, searchKeyword: String, after: String, before: String, first: Int, last: Int): ResourceConnection -} - -type Mutation { - createResource(input: CreateResourceInput!): CreateResourcePayload! - updateResource(input: UpdateResourceInput!): UpdateResourcePayload! - deleteResource(input: DeleteResourceInput!): DeleteResourcePayload! - - createScope(input: CreateScopeInput!): CreateScopePayload! - updateScope(input: UpdateScopeInput!): UpdateScopePayload! - deleteScope(input: DeleteScopeInput!): DeleteScopePayload! - - addResourceToClientID(input: AddResourceToClientIDInput!): AddResourceToClientIDPayload! - removeResourceFromClientID(input: RemoveResourceFromClientIDInput!): RemoveResourceFromClientIDPayload! - addScopesToClientID(input: AddScopesToClientIDInput!): AddScopesToClientIDPayload! - removeScopesFromClientID(input: RemoveScopesFromClientIDInput!): RemoveScopesFromClientIDPayload! - replaceScopesOfClientID(input: ReplaceScopesOfClientIDInput!): ReplaceScopesOfClientIDPayload! -} - -type Resource implements Entity & Node { - id: ID! - createdAt: DateTime! - updatedAt: DateTime! - resourceURI: String! - name: String - """If clientID is null, then all scopes of this Resource is returned.""" - """If clientID is specified, then only scopes that are associated with clientID is returned.""" - """If searchKeyword is non-null, a prefix search of scope is performed.""" - """If both clientID and searchKeyword are specified, they are AND-ed.""" - scopes(clientID: String, searchKeyword: String, after: String, before: string, first: Int, last: Int): ScopeConnection - """The list of client IDs associated with this Resource.""" - clientIDs: [String!]! -} - -type Scope implements Entity & Node { - id: ID! - createdAt: DateTime! - updatedAt: DateTime! - resourceID: ID! - scope: String! - description: String -} - -type ResourceEdge { - cursor: String! - resource: Resource -} - -type ResourceConnection { - edges: [ResourceEdge] - pageInfo: PageInfo! - totalCount: Int -} - -type ScopeEdge { - cursor: String! - scope: Scope -} - -type ScopeConnection { - edges: [ScopeEdge] - pageInfo: PageInfo! - totalCount: Int -} - -input CreateResourceInput { - resourceURI: String! - name: String -} - -type CreateResourcePayload { - resource: Resource! -} - -input UpdateResourceInput { - resourceURI: String! - """The new name""" - name: String -} - -type UpdateResourcePayload { - resource: Resource! -} - -input DeleteResourceInput { - resourceURI: String! -} - -type DeleteResourcePayload { - ok: Boolean -} - -input CreateScopeInput { - resourceURI: String! - scope: String! - description: String -} - -type CreateScopePayload { - scope: Scope! -} - -input UpdateScopeInput { - resourceURI: String! - scope: String! - """The new description""" - description: String -} - -type UpdateScopePayload { - scope: Scope! -} - -input DeleteScopeInput { - resourceURI: String! - scope: String! -} - -type DeleteScopePayload { - ok: Boolean -} - -input AddResourceToClientIDInput { - resourceURI: String! - clientID: String! -} - -type AddResourceToClientIDPayload { - resource: Resource! -} - -input RemoveResourceFromClientIDInput { - resourceURI: String! - clientID: String! -} - -type RemoveResourceFromClientIDPayload { - resource: Resource! -} - -input AddScopesToClientIDInput { - resourceURI: String! - scopes: [String!]! - clientID: String! -} - -type AddScopesToClientIDPayload { - scopes: [Scope!]! -} - -input RemoveScopesFromClientIDInput { - resourceURI: String! - scopes: [String!]! - clientID: String! -} - -type RemoveScopesFromClientIDPayload { - scopes: [Scope!]! -} - -input ReplaceScopesOfClientIDInput { - resourceURI: String! - clientID: String! - scopes: [String!]! -} - -type ReplaceScopesOfClientIDPayload { - scopes: [Scope!]! -} -``` +The Admin API changes for managing Resources, Scopes, and Client-Resource Associations are specified in [API Resources and Scopes — Admin API](./api-resource.md#admin-api). ### Changes in OAuth 2.0 implementation diff --git a/docs/specs/oidc.md b/docs/specs/oidc.md index d1a6746bfc..8220ef38c5 100644 --- a/docs/specs/oidc.md +++ b/docs/specs/oidc.md @@ -20,8 +20,10 @@ Supported [standard client metadata](https://openid.net/specs/openid-connect-reg ### Custom Client Metadata - `client_id`: OIDC client ID. -- `access_token_lifetime`: Access token lifetime in seconds, default to 1800. -- `refresh_token_lifetime`: Refresh token lifetime in seconds, default to max(access_token_lifetime, 86400). It must be greater than or equal to `access_token_lifetime`. +- `access_token_lifetime_seconds`: Access token lifetime in seconds, default to 1800. +- `refresh_token_lifetime_seconds`: Refresh token lifetime in seconds, default to max(access_token_lifetime_seconds, 86400). It must be greater than or equal to `access_token_lifetime_seconds`. +- `refresh_token_idle_timeout_enabled`: Whether idle refresh token expiry is enabled. +- `refresh_token_idle_timeout_seconds`: Idle timeout for refresh tokens in seconds. The refresh token expires if it has not been used for this duration. Only meaningful when `refresh_token_idle_timeout_enabled` is `true`. - `x_application_type`: Indicate the application type. See [Clients](#clients) for the meaning of the value. The application type is not changeable after creation on the portal. Supported values: `spa`, `traditional_webapp`, `native`, `confidential`, `third_party_app`. - `x_max_concurrent_session`: Indicate whether the client restricts the number of concurrent sessions, `0` means no restriction, default is `0`. Currently, only `0` or `1` are supported. If `x_max_concurrent_session` is `1`, all refresh tokens of the client will be revoked when a new one is requested. - `x_authentication_flow_allowlist`: Indicate the allowed authentication flows. See [Flow Allowlist](./authentication-flow-selection.md#flow-allowlist) for details. @@ -457,7 +459,9 @@ The content of this table is explained in [Rationale of limitations](#rationale- |`traditional_webapp`|First-party|public|No|Yes| |`native`|First-party|public|No|Yes| |`confidential`|First-party|confidential|Yes|No| -|`third_party_app`|Third-party|confidential|Yes|No| +|`third_party_app`|Third-party|public or confidential|Yes|No| + +See [Third-Party Client spec](./third-party-client.md) for full details on `third_party_app`. ### Rationale of limitations @@ -494,11 +498,7 @@ First-party confidential clients have NO access to privileged user operations, a ### Third-Party clients -Third-party clients are always confidential, thus they have `client_secret`. During code exchange, `client_secret` must be present. - -Third-party clients have NO access to privileged user operations, and CANNOT request the special scope value `https://authgear.com/scopes/full-access`. - -Third-party clients can use the scope value `https://authgear.com/scopes/full-userinfo` to request complete user info. +See [Third-Party Client spec](./third-party-client.md). ### Confidential clients @@ -522,40 +522,11 @@ The client secrets of confidential clients are stored in `authgear.secrets.yaml` First-party clients are trusted so consent screen is skipped. -Third-party clients are NOT trusted so the end-user must give explicit consent in the consent screen. - -The consent screen will be shown only if there is no authorization record (first-time login / the user revokes it). The consent screen will show the client name and the requested permissions. - -The concent screen example: - -``` - - -# Authorize - - wants to access your account. - -- Allows to access your email, phone number or username if available. -- Allows to access other information of your profile. -- Allows to access your information after login. - -[Cancel] [Authorize] - -``` - -The list will be changed based on the requested scopes. The copywriting are listed as follows: - -- `https://authgear.com/scopes/full-userinfo`: - - Allows to access your email, phone number or username if available. - - Allows to access other information of your profile. -- `offline_access`: - - Allows to access your information after login. - -### Authorized Apps page +Third-party clients are NOT trusted so the end-user must give explicit consent in the consent screen. See [Third-Party Client spec — Consent Screen](./third-party-client.md#consent-screen) for details. -In the **Signed in Sessions** page, only IdP sessions and sessions of first-party clients are listed. The refresh token of third-party clients are NOT listed in this page because revoking a refresh token of third-party clients DOES NOT affect the login in the third-party app. +### Sessions and Authorized Apps -The **Authorized Apps** page lists authorizations of third-party client only. Revoking an authorization revokes all the refresh tokens of the third-party client. +See [Third-Party Client spec — Sessions and Authorization Management](./third-party-client.md#sessions-and-authorization-management). ### App Session Token diff --git a/docs/specs/third-party-client.md b/docs/specs/third-party-client.md new file mode 100644 index 0000000000..a192033412 --- /dev/null +++ b/docs/specs/third-party-client.md @@ -0,0 +1,86 @@ +# Third-Party Clients + +A third-party client is an OAuth client operated by an external developer — someone who is not a collaborator of the Authgear project. Because the operator is not trusted by the project, users must grant explicit consent before the client may access their account. + +> **Note on `x_application_type: third_party_app`:** Statically configuring an OAuth client with `x_application_type: third_party_app` in `authgear.yaml` is deprecated. Existing OAuth clients of this type remain functional but are not recommended for new integrations. New third-party clients must be created via Dynamic Client Registration (DCR). See [DCR spec](./dcr.md). + +## Table of Contents + +- [First-Party vs Third-Party Clients](#first-party-vs-third-party-clients) +- [Registration](#registration) +- [Consent Screen](#consent-screen) +- [Supported Flows](#supported-flows) +- [Scopes](#scopes) +- [Token Behavior](#token-behavior) +- [Sessions and Authorization Management](#sessions-and-authorization-management) + +## First-Party vs Third-Party Clients + +| Property | First-Party Client | Third-Party Client | +|---|---|---| +| Operated by | Project collaborator | External developer | +| Consent screen | Not shown | Required | +| Access token without resource indicator | JWT (`aud = []`) | Opaque | +| `client_credentials` grant | Allowed (M2M clients) | Not allowed | +| `https://authgear.com/scopes/full-access` | Allowed (public clients only) | Not allowed | +| `https://authgear.com/scopes/full-userinfo` | Allowed | Not allowed | +| PII in ID token | No (public clients) / Yes (confidential clients) | Yes | +| Session management | Sessions page | Authorized Apps page | +| Registration | `authgear.yaml` or DCR | DCR only | + +**Public vs confidential clients.** A first-party client is *public* if it has no `client_secret` (e.g. `spa`, `native` application types) and *confidential* if it does (e.g. `confidential` type). The `https://authgear.com/scopes/full-access` scope grants access to voluntary reauthentication and app session token exchange (`/oauth2/app-session-token`); it is restricted to public clients because they are the only client type that supports these operations. + +**Trust model.** First-party clients are created by project collaborators and are implicitly trusted — the consent screen is skipped. Third-party clients are created by external developers and are not trusted — users must explicitly grant access on the consent screen. + +**PII in ID token.** First-party public clients have access to voluntary reauthentication, which passes an `id_token_hint` in the URL; including PII there would expose it in browser history. Third-party clients and first-party confidential clients have no access to reauthentication, so it is safe to include PII in their ID tokens. + +## Registration + +Third-party clients can only be created via Dynamic Client Registration (DCR) at the moment. When DCR is enabled with `initial_access_token_required: false`, any caller may register a third-party client without presenting an Initial Access Token. + +See [DCR spec](./dcr.md) for the full registration flow. + +## Consent Screen + +The consent screen is shown when a user authorizes a third-party client for the first time, or after the user has revoked a previous authorization. It displays the client name, the requested permissions, and optionally a privacy policy and terms of service link. + +The consent screen is **not** shown if there is an existing valid authorization record for the same user, client, and scope set. + +## Supported Flows + +| Grant type | Supported | +|---|---| +| `authorization_code` | Yes | +| `refresh_token` | Yes (when `offline_access` is granted) | +| `client_credentials` | No | + +## Scopes + +| Scope | Allowed | Notes | +|---|---|---| +| `openid` | Yes | Required | +| `profile` | Yes | | +| `email` | Yes | | +| `phone` | Yes | | +| `address` | Yes | | +| `offline_access` | Yes | Issues a refresh token | +| `https://authgear.com/scopes/full-userinfo` | **No** | Exposes internal identities and authenticators; standard OIDC scopes are sufficient | +| `https://authgear.com/scopes/full-access` | **No** | Grants privileged user operations (e.g. app session token); restricted to first-party public clients | +| `device_sso` | **No** | Restricted to clients with pre-authenticated URL enabled | +| `https://authgear.com/scopes/pre-authenticated-url` | **No** | Restricted to clients with pre-authenticated URL enabled | + +## Token Behavior + +By default (no `resource` parameter), a third-party client receives an **opaque** access token. The opaque token can only be used with the userinfo endpoint and cannot be validated independently by a resource server. + +When the `resource` parameter is specified and the referenced Resource permits access, a **JWT** access token is issued with `aud` set to the resource URI only. + +See [Access Token Audience Binding](./access-token-audience-binding.md) for the full specification. + +## Sessions and Authorization Management + +Third-party client authorizations are tracked separately from IdP sessions. + +The **Sessions** page (`/settings/sessions`) lists IdP sessions and first-party client sessions only. Third-party client authorizations are excluded because revoking a first-party session terminates the session itself, whereas revoking a third-party authorization only removes that client's access tokens — the user's login session with Authgear is unaffected. + +The **Authorized Apps** page (`/settings/authorized-apps`) lists per-client authorizations for third-party clients. Each entry shows the client name and the granted scopes. Revoking an authorization deletes all refresh tokens issued to that client for the current user.