From 736b2c0441e9b8c5682be35009ee8600a0627d76 Mon Sep 17 00:00:00 2001 From: Georgii Kovalev Date: Fri, 14 Aug 2026 14:47:24 +0000 Subject: [PATCH] docs: add OpenAPI specs for auth service public endpoints One spec per endpoint (grouped by path) covering the public OAuth/OIDC surface: authorize, par, token, introspect, revoke, userinfo, logout, jwks, and discovery metadata. Validated with redocly/cli. --- auth/open-api/authorize.yaml | 401 ++++++++++++++++++++++++++++++++++ auth/open-api/introspect.yaml | 196 +++++++++++++++++ auth/open-api/jwks.yaml | 95 ++++++++ auth/open-api/logout.yaml | 178 +++++++++++++++ auth/open-api/metadata.yaml | 148 +++++++++++++ auth/open-api/par.yaml | 254 +++++++++++++++++++++ auth/open-api/revoke.yaml | 103 +++++++++ auth/open-api/token.yaml | 287 ++++++++++++++++++++++++ auth/open-api/userinfo.yaml | 156 +++++++++++++ 9 files changed, 1818 insertions(+) create mode 100644 auth/open-api/authorize.yaml create mode 100644 auth/open-api/introspect.yaml create mode 100644 auth/open-api/jwks.yaml create mode 100644 auth/open-api/logout.yaml create mode 100644 auth/open-api/metadata.yaml create mode 100644 auth/open-api/par.yaml create mode 100644 auth/open-api/revoke.yaml create mode 100644 auth/open-api/token.yaml create mode 100644 auth/open-api/userinfo.yaml diff --git a/auth/open-api/authorize.yaml b/auth/open-api/authorize.yaml new file mode 100644 index 00000000..7af458d4 --- /dev/null +++ b/auth/open-api/authorize.yaml @@ -0,0 +1,401 @@ +openapi: 3.1.0 +info: + title: Versola Auth — Authorization Endpoint + version: 1.0.0 + description: | + OAuth 2.1 authorization endpoint. Starts the Authorization Code flow with PKCE. + + The endpoint accepts the same parameter set over `GET` (query string) and `POST` + (`application/x-www-form-urlencoded` body). A parameter supplied more than once is + rejected, except `resource`, which is repeatable. + + Either the request carries its parameters inline, or it carries a single `request_uri` + obtained from `POST /par` alongside `client_id`; in the latter case the pushed payload + replaces every other parameter and is consumed on first use. + license: + name: Business Source License 1.1 + url: https://github.com/versolauth/versola/blob/main/LICENCE.md + +servers: + - url: https://auth.example.com + description: Auth service issuer origin + +paths: + /authorize: + get: + operationId: authorizeGet + summary: Start an authorization request + description: | + Parameters are read from the query string. + tags: [Authorization] + security: [] + parameters: + - $ref: '#/components/parameters/ClientId' + - $ref: '#/components/parameters/RedirectUri' + - $ref: '#/components/parameters/ResponseType' + - $ref: '#/components/parameters/Scope' + - $ref: '#/components/parameters/CodeChallenge' + - $ref: '#/components/parameters/CodeChallengeMethod' + - $ref: '#/components/parameters/State' + - $ref: '#/components/parameters/Nonce' + - $ref: '#/components/parameters/Prompt' + - $ref: '#/components/parameters/MaxAge' + - $ref: '#/components/parameters/AcrValues' + - $ref: '#/components/parameters/UiLocales' + - $ref: '#/components/parameters/Claims' + - $ref: '#/components/parameters/LoginHint' + - $ref: '#/components/parameters/IdTokenHint' + - $ref: '#/components/parameters/Resource' + - $ref: '#/components/parameters/RequestUri' + - $ref: '#/components/parameters/UserAgent' + - $ref: '#/components/parameters/SessionCookie' + - $ref: '#/components/parameters/UserAgentCookie' + responses: + '303': + $ref: '#/components/responses/SeeOther' + '400': + $ref: '#/components/responses/BadRequest' + + post: + operationId: authorizePost + summary: Start an authorization request + description: | + Parameters are read from the form body. The repeatable `resource` parameter may also be + sent as a single space-delimited field value. + tags: [Authorization] + security: [] + parameters: + - $ref: '#/components/parameters/UserAgent' + - $ref: '#/components/parameters/SessionCookie' + - $ref: '#/components/parameters/UserAgentCookie' + requestBody: + required: true + content: + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/AuthorizeRequest' + responses: + '303': + $ref: '#/components/responses/SeeOther' + '400': + $ref: '#/components/responses/BadRequest' + +components: + parameters: + ClientId: + name: client_id + in: query + required: true + description: Identifier of the registered client. Required even when `request_uri` is used. + schema: + type: string + example: web-app + + RedirectUri: + name: redirect_uri + in: query + required: true + description: | + Absolute URI without a fragment, exactly matching one of the client's registered + redirect URIs. + schema: + type: string + format: uri + example: https://app.example.com/callback + + ResponseType: + name: response_type + in: query + required: true + description: | + `code` for the plain authorization code flow, `code id_token` to additionally receive an + ID token in the redirect (OIDC hybrid). + schema: + type: string + enum: [code, code id_token] + example: code + + Scope: + name: scope + in: query + required: true + description: | + Space-delimited scope tokens. Include `openid` to obtain an ID token from `/token` + and to be allowed to call `/userinfo`. + schema: + type: string + example: openid profile email + + CodeChallenge: + name: code_challenge + in: query + required: true + description: PKCE code challenge (RFC 7636 §4.2). Always required. + schema: + type: string + pattern: '^[A-Za-z0-9\-._~]{43,128}$' + example: E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM + + CodeChallengeMethod: + name: code_challenge_method + in: query + required: false + description: | + Transformation applied to the code verifier. Defaults to `plain` when omitted; `S256` + is strongly recommended and is the only method advertised in server metadata. + schema: + type: string + enum: [S256, plain] + default: plain + example: S256 + + State: + name: state + in: query + required: false + description: | + Opaque value echoed back on the redirect, used by the client for CSRF protection. + Limited to 128 characters, because it is also carried in the conversation cookie. + schema: + type: string + maxLength: 128 + example: af0ifjsldkj + + Nonce: + name: nonce + in: query + required: false + description: Value bound into the ID token to associate it with this client session. + schema: + type: string + + Prompt: + name: prompt + in: query + required: false + description: | + Space-delimited list. `none` must not be combined with any other value. + schema: + type: string + enum: [none, login, consent, select_account] + + MaxAge: + name: max_age + in: query + required: false + description: | + Maximum acceptable age of the existing authentication, in seconds. An older + authentication forces re-authentication. Non-numeric values are ignored. + schema: + type: integer + format: int64 + minimum: 0 + + AcrValues: + name: acr_values + in: query + required: false + description: | + Space-delimited Authentication Context Class Reference values, in order of preference. + Present but empty is rejected. + schema: + type: string + + UiLocales: + name: ui_locales + in: query + required: false + description: Space-delimited BCP 47 language tags, in order of preference. + schema: + type: string + example: ru en + + Claims: + name: claims + in: query + required: false + description: | + OIDC claims request as a JSON object (OpenID Connect Core §5.5), selecting individual + claims for the ID token and the UserInfo response. + schema: + type: string + contentMediaType: application/json + + LoginHint: + name: login_hint + in: query + required: false + description: | + Pre-fills the identifier on the login form. A value beginning with `+` followed by + digits is treated as a phone number, anything else as an email address. The hint is + rejected unless the client's auth flow accepts that credential type, and phone hints + must additionally match one of the configured phone prefixes. + schema: + type: string + example: user@example.com + + IdTokenHint: + name: id_token_hint + in: query + required: false + description: Previously issued ID token identifying the user the client expects to authenticate. + schema: + type: string + + Resource: + name: resource + in: query + required: false + description: | + RFC 8707 resource indicator. Repeatable; each value must resolve to a resource + registered for the client's tenant, and determines the `aud` of the issued access + token. When omitted, the client's public resources plus the edge indicator are used. + schema: + type: array + items: + type: string + format: uri + style: form + explode: true + example: [https://api.example.com] + + RequestUri: + name: request_uri + in: query + required: false + description: | + Reference returned by `POST /par`. Replaces every other authorization parameter except + `client_id`, which must match the client that pushed the request. Single-use and + rejected once expired. + schema: + type: string + pattern: '^urn:ietf:params:oauth:request_uri:' + example: urn:ietf:params:oauth:request_uri:6esc_11ACC5bwc014ltc14eY22c + + UserAgent: + name: User-Agent + in: header + required: false + description: Recorded with the authentication session for device recognition. + schema: + type: string + + SessionCookie: + name: SSO_SESSION + in: cookie + required: false + description: | + Signed session cookie from an earlier authentication. When it resolves to a live + session that satisfies `prompt`, `max_age` and `acr_values`, the request is + authorized without showing the login form. + schema: + type: string + + UserAgentCookie: + name: SSO_USER_AGENT_ID + in: cookie + required: false + description: Signed device identifier, used to recognise a returning browser. + schema: + type: string + + schemas: + AuthorizeRequest: + type: object + required: [client_id, redirect_uri, response_type, scope, code_challenge] + properties: + client_id: + type: string + redirect_uri: + type: string + format: uri + response_type: + type: string + enum: [code, code id_token] + scope: + type: string + code_challenge: + type: string + code_challenge_method: + type: string + enum: [S256, plain] + default: plain + state: + type: string + maxLength: 128 + nonce: + type: string + prompt: + type: string + max_age: + type: integer + format: int64 + acr_values: + type: string + ui_locales: + type: string + claims: + type: string + contentMediaType: application/json + login_hint: + type: string + id_token_hint: + type: string + resource: + type: array + items: + type: string + format: uri + request_uri: + type: string + + responses: + SeeOther: + description: | + Every outcome is a redirect. There are three shapes: + + - **Authorized** — back to `redirect_uri` with `code` (and `state`, and `id_token` + when `response_type=code id_token`). + - **Authentication required** — to `/challenge`, setting the `SSO_CONVERSATION` + cookie that carries the pending authorization request. + - **Failed** — back to `redirect_uri` with `error`, `error_description`, `error_uri` + and `state`. + + Failures that cannot be attributed to a registered redirect URI are answered with + `400` instead, since redirecting them would be an open redirect. + + Error codes returned on the redirect: `invalid_request`, `unsupported_response_type`, + `invalid_scope`, `login_required`, `access_denied`, `invalid_target`, + `unmet_authentication_requirements`. + headers: + Location: + required: true + schema: + type: string + format: uri + examples: + authorized: + summary: Authorization code issued + value: https://app.example.com/callback?code=SplxlOBeZQQYbYS6WxSbIA&state=af0ifjsldkj + challenge: + summary: User must authenticate + value: /challenge + failed: + summary: Request rejected + value: https://app.example.com/callback?error=invalid_request&error_description=Missing+required+parameter+-+code_challenge&state=af0ifjsldkj + Set-Cookie: + description: | + `SSO_CONVERSATION`, set only when redirecting to `/challenge`. Holds the pending + authorization request and expires with the client's conversation TTL. + schema: + type: string + + BadRequest: + description: | + `client_id` or `redirect_uri` is missing, malformed, supplied more than once, or not + registered for the client — so there is no verified URI to redirect the error to. + content: + text/plain: + schema: + type: string + example: Either client_id or redirect_uri is somehow missing, invalid, provided multiple times or not registered diff --git a/auth/open-api/introspect.yaml b/auth/open-api/introspect.yaml new file mode 100644 index 00000000..3c5415f1 --- /dev/null +++ b/auth/open-api/introspect.yaml @@ -0,0 +1,196 @@ +openapi: 3.1.0 +info: + title: Versola Auth — Token Introspection Endpoint + version: 1.0.0 + description: | + OAuth 2.0 Token Introspection (RFC 7662). Lets an authenticated client or resource server + ask whether a token is currently active and retrieve its metadata. + + Both access tokens (JWTs) and refresh tokens (opaque) are accepted; the token format is + detected automatically, so `token_type_hint` is unnecessary. + + A token that cannot be verified, has expired, was revoked, or does not belong to the + calling client is reported as `{"active": false}` rather than as an error — the caller is + never told why a token is not active. + license: + name: Business Source License 1.1 + url: https://github.com/versolauth/versola/blob/main/LICENCE.md + +servers: + - url: https://auth.example.com + description: Auth service issuer origin + +paths: + /introspect: + post: + operationId: introspect + summary: Introspect a token + tags: [Token] + security: + - clientSecretBasic: [] + - clientSecretPost: [] + requestBody: + required: true + content: + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/IntrospectionRequest' + example: + token: eyJhbGciOiJSUzI1NiIsInR5cCI6ImF0K2p3dCJ9... + responses: + '200': + $ref: '#/components/responses/Introspection' + '400': + $ref: '#/components/responses/InvalidRequest' + '401': + $ref: '#/components/responses/InvalidClient' + +components: + securitySchemes: + clientSecretBasic: + type: http + scheme: basic + description: '`client_secret_basic` — `client_id` as the username, `client_secret` as the password.' + clientSecretPost: + type: apiKey + in: cookie + name: client_secret + description: | + `client_secret_post` — `client_id` and `client_secret` sent as form fields. Modelled + here as a scheme only because OpenAPI has no dedicated form-credential type. + + schemas: + IntrospectionRequest: + type: object + required: [token] + properties: + token: + type: string + description: | + The token to introspect. A value in JWT form is treated as an access token, + anything else as an opaque refresh token. + client_id: + type: string + description: Required for `client_secret_post`. + client_secret: + type: string + description: Confidential clients using `client_secret_post`. + + IntrospectionResponse: + type: object + required: [active] + description: | + Only `active` is guaranteed. When `active` is `false` every other member is absent. + properties: + active: + type: boolean + client_id: + type: string + description: Client the token was issued to. + scope: + type: string + description: Space-delimited granted scopes. + username: + type: string + token_type: + type: string + description: '`Bearer` for access tokens.' + exp: + type: integer + format: int64 + description: Expiry, seconds since the epoch. + iat: + type: integer + format: int64 + description: Issued at, seconds since the epoch. + nbf: + type: integer + format: int64 + description: Not valid before, seconds since the epoch. + sub: + type: string + description: User id, or the `client_id` for a `client_credentials` token. + aud: + type: array + items: + type: string + iss: + type: string + jti: + type: string + + ErrorResponse: + type: object + required: [error] + properties: + error: + type: string + enum: [invalid_request, invalid_client] + error_description: + type: string + + responses: + Introspection: + description: | + Introspection result. Returned with `200` whether or not the token is active. + headers: + Cache-Control: + schema: + type: string + const: no-store + Pragma: + schema: + type: string + const: no-cache + content: + application/json: + schema: + $ref: '#/components/schemas/IntrospectionResponse' + examples: + active: + summary: Active access token + value: + active: true + client_id: web-app + scope: openid profile email + token_type: Bearer + exp: 1735689600 + iat: 1735686000 + sub: 018f3c6e-5a2b-7c11-9d0e-4f8a2b6c1d33 + aud: [https://api.example.com] + iss: https://auth.example.com + jti: 9x8VJ0mQ0iKQxRZ8m6bTsw + inactive: + summary: Unknown, expired, revoked, or another client's token + value: + active: false + + InvalidRequest: + description: The body is not a readable form, or the `token` field is missing. + headers: + Cache-Control: + schema: + type: string + const: no-store + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + example: + error: invalid_request + error_description: Request is missing a required parameter or is otherwise malformed + + InvalidClient: + description: Client authentication failed. + headers: + Cache-Control: + schema: + type: string + const: no-store + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + example: + error: invalid_client + error_description: Client authentication failed diff --git a/auth/open-api/jwks.yaml b/auth/open-api/jwks.yaml new file mode 100644 index 00000000..689ef964 --- /dev/null +++ b/auth/open-api/jwks.yaml @@ -0,0 +1,95 @@ +openapi: 3.1.0 +info: + title: Versola Auth — JWKS Endpoint + version: 1.0.0 + description: | + JSON Web Key Set endpoint (RFC 7517, OpenID Connect Discovery §3). Publishes the public + keys that verify the signatures on tokens issued by this server. + + Resource servers fetch this document to validate access tokens and ID tokens offline. The + `kid` in a token's JOSE header selects the key to verify it with. + + The set contains the currently active signing key plus any keys still needed to verify + tokens issued before the last rotation, so a resource server that caches the document + keeps working across a rotation. Only public key material is exposed. + + The response is cacheable for 24 hours. On encountering an unknown `kid`, refetch rather + than waiting for the cache to expire. + license: + name: Business Source License 1.1 + url: https://github.com/versolauth/versola/blob/main/LICENCE.md + +servers: + - url: https://auth.example.com + description: Auth service issuer origin + +paths: + /.well-known/jwks.json: + get: + operationId: jwks + summary: Retrieve the JSON Web Key Set + description: No authentication required. + tags: [Discovery] + security: [] + responses: + '200': + $ref: '#/components/responses/Jwks' + +components: + schemas: + JsonWebKeySet: + type: object + required: [keys] + properties: + keys: + type: array + items: + $ref: '#/components/schemas/JsonWebKey' + + JsonWebKey: + type: object + required: [kty, kid] + additionalProperties: true + properties: + kty: + type: string + description: Key type. + example: RSA + use: + type: string + description: Intended use; signature verification. + const: sig + kid: + type: string + description: Key identifier, matched against the `kid` in a token's JOSE header. + alg: + type: string + description: Signing algorithm the key is used with. + example: RS256 + n: + type: string + description: RSA modulus, base64url-encoded. + e: + type: string + description: RSA public exponent, base64url-encoded. + + responses: + Jwks: + description: The current key set. + headers: + Cache-Control: + schema: + type: string + const: public, max-age=86400 + content: + application/json: + schema: + $ref: '#/components/schemas/JsonWebKeySet' + example: + keys: + - kty: RSA + use: sig + kid: 2026-08-01 + alg: RS256 + n: 0vx7agoebGcQSuuPiLJXZptN9nndrQmbXEps2aiAFbWhM78LhWx4... + e: AQAB diff --git a/auth/open-api/logout.yaml b/auth/open-api/logout.yaml new file mode 100644 index 00000000..3ab2407b --- /dev/null +++ b/auth/open-api/logout.yaml @@ -0,0 +1,178 @@ +openapi: 3.1.0 +info: + title: Versola Auth — Logout Endpoint + version: 1.0.0 + description: | + RP-Initiated Logout (OpenID Connect RP-Initiated Logout 1.0) combined with Front-Channel + Logout. Terminates the user's authentication session and notifies the clients that share it. + + The user is identified either by the `SSO_SESSION` cookie or by `id_token_hint`, and the + behaviour depends on which is present: + + - **Cookie only** — `GET` renders a confirmation page, because a bare cookie could be + driven by a third-party page and logging the user out unasked would be a CSRF. `POST` + with the page's CSRF token then performs the logout. + - **`id_token_hint`** — the logout happens immediately on either method: the hint proves + the client already knows which session it is ending. With a cookie present, the hint + must identify that same session. + - **Neither** — there is no session to end; the signed-out page is rendered. + + On success the session is deleted, `SSO_SESSION` is expired, and the returned page embeds + a hidden iframe per participating client so each can clear its own local session. Clients + that registered a back-channel logout URI are notified server-to-server instead. + license: + name: Business Source License 1.1 + url: https://github.com/versolauth/versola/blob/main/LICENCE.md + +servers: + - url: https://auth.example.com + description: Auth service issuer origin + +paths: + /logout: + get: + operationId: logoutGet + summary: Start RP-initiated logout + description: | + Renders the confirmation page for a cookie-only session, or logs out immediately when + `id_token_hint` is supplied. + tags: [Logout] + security: [] + parameters: + - $ref: '#/components/parameters/PostLogoutRedirectUri' + - $ref: '#/components/parameters/State' + - $ref: '#/components/parameters/IdTokenHint' + - $ref: '#/components/parameters/SessionCookie' + responses: + '200': + $ref: '#/components/responses/LogoutPage' + '303': + $ref: '#/components/responses/PostLogoutRedirect' + + post: + operationId: logoutPost + summary: Confirm logout, or log out directly + description: | + Submission target of the confirmation page. When the session comes from the cookie + alone, the logout parameters are read from the signed form body and the query string + is ignored; `id_token_hint` is still read from the query string for clients that POST + their logout request directly. + tags: [Logout] + security: [] + parameters: + - $ref: '#/components/parameters/PostLogoutRedirectUri' + - $ref: '#/components/parameters/State' + - $ref: '#/components/parameters/IdTokenHint' + - $ref: '#/components/parameters/SessionCookie' + requestBody: + required: false + description: Sent by the confirmation page. Not used when `id_token_hint` is supplied. + content: + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/LogoutConfirmation' + responses: + '200': + $ref: '#/components/responses/LogoutPage' + '303': + $ref: '#/components/responses/PostLogoutRedirect' + '403': + $ref: '#/components/responses/Forbidden' + +components: + parameters: + PostLogoutRedirectUri: + name: post_logout_redirect_uri + in: query + required: false + description: | + Where to send the user after logout. Must be registered as a post-logout redirect URI + for the client; an unvalidated or unregistered value is dropped and the signed-out + page is shown instead. + schema: + type: string + format: uri + example: https://app.example.com/goodbye + + State: + name: state + in: query + required: false + description: Opaque value echoed back on the post-logout redirect. + schema: + type: string + + IdTokenHint: + name: id_token_hint + in: query + required: false + description: | + ID token previously issued by this server, identifying the session to end. It is + accepted only if its signature verifies, its `iss` matches this server, and it has an + audience. Supplying it skips the confirmation page. + schema: + type: string + + SessionCookie: + name: SSO_SESSION + in: cookie + required: false + description: Signed session cookie identifying the session to end. + schema: + type: string + + schemas: + LogoutConfirmation: + type: object + required: [csrf_token] + properties: + csrf_token: + type: string + description: | + Token minted on `GET` and bound to the session together with + `post_logout_redirect_uri` and `state`; altering either value invalidates it. + post_logout_redirect_uri: + type: string + format: uri + state: + type: string + + responses: + LogoutPage: + description: | + An HTML page. Either the confirmation prompt, or the signed-out page carrying one + hidden front-channel logout iframe per participating client. + + Also returned — without iframes — when there is nothing to log out, when the session + no longer exists, or when `id_token_hint` does not match the cookie's session. + headers: + Set-Cookie: + description: '`SSO_SESSION`, expired, when a session was actually terminated.' + schema: + type: string + content: + text/html: + schema: + type: string + + PostLogoutRedirect: + description: | + The session was terminated and a registered `post_logout_redirect_uri` was supplied, + so the user is sent there with `state` echoed back. + headers: + Location: + required: true + schema: + type: string + format: uri + example: https://app.example.com/goodbye?state=af0ifjsldkj + Set-Cookie: + description: '`SSO_SESSION`, expired.' + schema: + type: string + + Forbidden: + description: | + The confirmation form was malformed or its CSRF token does not match. The session is + left intact — the signed-out page is deliberately not rendered, since that would tell + the user they are logged out while their session is still live. diff --git a/auth/open-api/metadata.yaml b/auth/open-api/metadata.yaml new file mode 100644 index 00000000..3f8c4453 --- /dev/null +++ b/auth/open-api/metadata.yaml @@ -0,0 +1,148 @@ +openapi: 3.1.0 +info: + title: Versola Auth — Discovery Metadata Endpoints + version: 1.0.0 + description: | + Authorization server discovery metadata, published under both well-known locations + defined by RFC 8414 (OAuth 2.0 Authorization Server Metadata) and OpenID Connect + Discovery 1.0. + + Both paths serve the same document, so an OAuth-only client and an OIDC client discover + the same server. Clients typically fetch this once at startup to learn the endpoint URLs + and the capabilities to negotiate, rather than hard-coding them. + + The document is administrator-configured in `central` and synced to `auth`, so the exact + member set reflects how the deployment is configured. The properties below are the ones a + client should expect. + license: + name: Business Source License 1.1 + url: https://github.com/versolauth/versola/blob/main/LICENCE.md + +servers: + - url: https://auth.example.com + description: Auth service issuer origin + +paths: + /.well-known/oauth-authorization-server: + get: + operationId: oauthAuthorizationServerMetadata + summary: Retrieve authorization server metadata + description: RFC 8414 location. No authentication required. + tags: [Discovery] + security: [] + responses: + '200': + $ref: '#/components/responses/Metadata' + + /.well-known/openid-configuration: + get: + operationId: openIdConfiguration + summary: Retrieve authorization server metadata + description: | + OpenID Connect Discovery location. Serves the same document as + `/.well-known/oauth-authorization-server`. + tags: [Discovery] + security: [] + responses: + '200': + $ref: '#/components/responses/Metadata' + +components: + schemas: + ServerMetadata: + type: object + additionalProperties: true + required: [issuer, authorization_endpoint, token_endpoint, jwks_uri, response_types_supported] + properties: + issuer: + type: string + format: uri + description: | + Identifier of this authorization server. Must equal the `iss` claim of issued + tokens, and clients must reject tokens whose issuer differs. + authorization_endpoint: + type: string + format: uri + token_endpoint: + type: string + format: uri + userinfo_endpoint: + type: string + format: uri + jwks_uri: + type: string + format: uri + introspection_endpoint: + type: string + format: uri + revocation_endpoint: + type: string + format: uri + pushed_authorization_request_endpoint: + type: string + format: uri + end_session_endpoint: + type: string + format: uri + response_types_supported: + type: array + items: + type: string + grant_types_supported: + type: array + items: + type: string + scopes_supported: + type: array + items: + type: string + code_challenge_methods_supported: + type: array + items: + type: string + description: PKCE is mandatory, so this always includes `S256`. + token_endpoint_auth_methods_supported: + type: array + items: + type: string + id_token_signing_alg_values_supported: + type: array + items: + type: string + subject_types_supported: + type: array + items: + type: string + claims_supported: + type: array + items: + type: string + acr_values_supported: + type: array + items: + type: string + + responses: + Metadata: + description: The server's discovery document. + content: + application/json: + schema: + $ref: '#/components/schemas/ServerMetadata' + example: + issuer: https://auth.example.com + authorization_endpoint: https://auth.example.com/authorize + token_endpoint: https://auth.example.com/token + userinfo_endpoint: https://auth.example.com/userinfo + jwks_uri: https://auth.example.com/.well-known/jwks.json + introspection_endpoint: https://auth.example.com/introspect + revocation_endpoint: https://auth.example.com/revoke + pushed_authorization_request_endpoint: https://auth.example.com/par + end_session_endpoint: https://auth.example.com/logout + response_types_supported: [code, code id_token] + grant_types_supported: [authorization_code, refresh_token, client_credentials] + scopes_supported: [openid, profile, email, phone] + code_challenge_methods_supported: [S256] + token_endpoint_auth_methods_supported: [client_secret_basic, client_secret_post, none] + id_token_signing_alg_values_supported: [RS256] + subject_types_supported: [public] diff --git a/auth/open-api/par.yaml b/auth/open-api/par.yaml new file mode 100644 index 00000000..2d7f4470 --- /dev/null +++ b/auth/open-api/par.yaml @@ -0,0 +1,254 @@ +openapi: 3.1.0 +info: + title: Versola Auth — Pushed Authorization Request Endpoint + version: 1.0.0 + description: | + OAuth 2.0 Pushed Authorization Requests (RFC 9126). + + The client sends the authorization request directly to the server over an authenticated + back channel and receives a `request_uri` in return. The subsequent `/authorize` call + then carries only `client_id` and that `request_uri`, so the authorization parameters + never pass through the user agent and cannot be tampered with in the browser. + + The pushed request is validated exactly as `/authorize` would validate it, but errors are + returned to the client in the token-endpoint error format rather than redirected. + license: + name: Business Source License 1.1 + url: https://github.com/versolauth/versola/blob/main/LICENCE.md + +servers: + - url: https://auth.example.com + description: Auth service issuer origin + +paths: + /par: + post: + operationId: pushedAuthorizationRequest + summary: Push an authorization request + description: | + Accepts the full `/authorize` parameter set as a form body. `client_id` is mandatory + and must identify the authenticated client; `request_uri` must not be present. + + The body is read as a bounded stream, so a request that understates or omits its + length is still rejected once it exceeds the configured maximum size. + tags: [Authorization] + security: + - clientSecretBasic: [] + - clientSecretPost: [] + requestBody: + required: true + content: + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/PushedAuthorizationRequest' + example: + client_id: web-app + redirect_uri: https://app.example.com/callback + response_type: code + scope: openid profile email + code_challenge: E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM + code_challenge_method: S256 + state: af0ifjsldkj + responses: + '201': + $ref: '#/components/responses/Pushed' + '400': + $ref: '#/components/responses/PushError' + '401': + $ref: '#/components/responses/InvalidClient' + '405': + $ref: '#/components/responses/MethodNotAllowed' + '413': + $ref: '#/components/responses/TooLarge' + +components: + securitySchemes: + clientSecretBasic: + type: http + scheme: basic + description: '`client_secret_basic` — `client_id` as the username, `client_secret` as the password.' + clientSecretPost: + type: apiKey + in: cookie + name: client_secret + description: | + `client_secret_post` — `client_id` and `client_secret` sent as form fields. Modelled + here as a scheme only because OpenAPI has no dedicated form-credential type. + + schemas: + PushedAuthorizationRequest: + type: object + description: | + Every `/authorize` parameter is accepted. `client_id` is required, `request_uri` is + forbidden. Credential fields (`client_id`, `client_secret`) are stripped before the + payload is stored. + required: [client_id, redirect_uri, response_type, scope, code_challenge] + properties: + client_id: + type: string + client_secret: + type: string + description: Confidential clients using `client_secret_post`. + redirect_uri: + type: string + format: uri + response_type: + type: string + enum: [code, code id_token] + scope: + type: string + code_challenge: + type: string + code_challenge_method: + type: string + enum: [S256, plain] + default: plain + state: + type: string + maxLength: 128 + nonce: + type: string + prompt: + type: string + max_age: + type: integer + format: int64 + acr_values: + type: string + ui_locales: + type: string + claims: + type: string + contentMediaType: application/json + login_hint: + type: string + id_token_hint: + type: string + resource: + type: array + items: + type: string + format: uri + description: Repeatable, or a single space-delimited value. + + PushedAuthorizationResponse: + type: object + required: [request_uri, expires_in] + properties: + request_uri: + type: string + description: | + Single-use reference in the `urn:ietf:params:oauth:request_uri:` namespace. Pass it + to `/authorize` together with `client_id`. + example: urn:ietf:params:oauth:request_uri:6esc_11ACC5bwc014ltc14eY22c + expires_in: + type: integer + format: int64 + description: Lifetime of the reference in seconds. + example: 90 + + ErrorResponse: + type: object + required: [error] + properties: + error: + type: string + error_description: + type: string + error_uri: + type: string + format: uri + + responses: + Pushed: + description: Request stored; the reference is valid once, until it expires. + headers: + Cache-Control: + schema: + type: string + const: no-store + content: + application/json: + schema: + $ref: '#/components/schemas/PushedAuthorizationResponse' + example: + request_uri: urn:ietf:params:oauth:request_uri:6esc_11ACC5bwc014ltc14eY22c + expires_in: 90 + + PushError: + description: | + `invalid_request` when `request_uri` was supplied, `client_id` is missing, or the body + is not a readable form. Otherwise the authorization-request validation error, reported + with the same `error` code `/authorize` would have redirected — for example + `unsupported_response_type`, `invalid_scope` or `invalid_target`. + headers: + Cache-Control: + schema: + type: string + const: no-store + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + example: + error: invalid_request + error_description: The request_uri parameter must not be provided to the pushed authorization request endpoint + error_uri: https://datatracker.ietf.org/doc/html/rfc9126#section-2.1 + + InvalidClient: + description: Client authentication failed. + headers: + Cache-Control: + schema: + type: string + const: no-store + WWW-Authenticate: + schema: + type: string + const: Basic + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + example: + error: invalid_client + error_description: Client not exists, credentials not provided or otherwise invalid + error_uri: https://datatracker.ietf.org/doc/html/rfc6749#section-2.3.1 + + MethodNotAllowed: + description: | + The endpoint accepts `POST` only. Any other method is answered here rather than by the + router's 404, so the response can advertise the supported method. + headers: + Allow: + schema: + type: string + const: POST + Cache-Control: + schema: + type: string + const: no-store + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + example: + error: invalid_request + error_description: The pushed authorization request endpoint only accepts POST + error_uri: https://datatracker.ietf.org/doc/html/rfc9126#section-2.3 + + TooLarge: + description: The body exceeds the configured maximum pushed-request size. + headers: + Cache-Control: + schema: + type: string + const: no-store + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + example: + error: invalid_request + error_description: The pushed authorization request exceeds the maximum allowed size + error_uri: https://datatracker.ietf.org/doc/html/rfc9126#section-2.3 diff --git a/auth/open-api/revoke.yaml b/auth/open-api/revoke.yaml new file mode 100644 index 00000000..1bb23294 --- /dev/null +++ b/auth/open-api/revoke.yaml @@ -0,0 +1,103 @@ +openapi: 3.1.0 +info: + title: Versola Auth — Token Revocation Endpoint + version: 1.0.0 + description: | + OAuth 2.0 Token Revocation (RFC 7009). Invalidates an access token or a refresh token + before it would otherwise expire. + + Both token formats are accepted and detected automatically. Revoking a refresh token also + invalidates the tokens derived from it. + + Per RFC 7009 §2.2 the endpoint answers `200` for a token that is unknown, already expired, + already revoked, or issued to a different client: the client's goal — that the token is no + longer usable — is satisfied either way, and distinguishing the cases would leak whether a + token exists. Only a client authentication failure is reported as an error. + license: + name: Business Source License 1.1 + url: https://github.com/versolauth/versola/blob/main/LICENCE.md + +servers: + - url: https://auth.example.com + description: Auth service issuer origin + +paths: + /revoke: + post: + operationId: revoke + summary: Revoke a token + tags: [Token] + security: + - clientSecretBasic: [] + - clientSecretPost: [] + requestBody: + required: true + content: + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/RevocationRequest' + example: + token: tGzv3JOkF0XG5Qx2TlKWIA + responses: + '200': + $ref: '#/components/responses/Revoked' + '401': + $ref: '#/components/responses/InvalidClient' + +components: + securitySchemes: + clientSecretBasic: + type: http + scheme: basic + description: '`client_secret_basic` — `client_id` as the username, `client_secret` as the password.' + clientSecretPost: + type: apiKey + in: cookie + name: client_secret + description: | + `client_secret_post` — `client_id` and `client_secret` sent as form fields. Modelled + here as a scheme only because OpenAPI has no dedicated form-credential type. + + schemas: + RevocationRequest: + type: object + required: [token] + properties: + token: + type: string + description: | + The token to revoke. A value in JWT form is treated as an access token, anything + else as an opaque refresh token. + client_id: + type: string + description: Required for `client_secret_post`. + client_secret: + type: string + description: Confidential clients using `client_secret_post`. + + ErrorResponse: + type: object + required: [error] + properties: + error: + type: string + enum: [invalid_client, unsupported_token_type] + errorDescription: + type: string + + responses: + Revoked: + description: | + The token is not usable. Returned both when it was revoked by this call and when it + was already unusable, with an empty body. + + InvalidClient: + description: | + Client authentication failed, or the `token` field is missing or malformed. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + example: + error: invalid_client + errorDescription: Client authentication failed diff --git a/auth/open-api/token.yaml b/auth/open-api/token.yaml new file mode 100644 index 00000000..0728b27f --- /dev/null +++ b/auth/open-api/token.yaml @@ -0,0 +1,287 @@ +openapi: 3.1.0 +info: + title: Versola Auth — Token Endpoint + version: 1.0.0 + description: | + OAuth 2.1 token endpoint. Issues access tokens, and — for the `openid` scope — ID tokens. + + Three grant types are supported: `authorization_code`, `refresh_token` and + `client_credentials`. The grant is selected by the `grant_type` field, which determines + which of the remaining fields are read. + license: + name: Business Source License 1.1 + url: https://github.com/versolauth/versola/blob/main/LICENCE.md + +servers: + - url: https://auth.example.com + description: Auth service issuer origin + +paths: + /token: + post: + operationId: token + summary: Exchange a grant for tokens + description: | + Clients authenticate with `client_secret_basic` (HTTP Basic) or `client_secret_post` + (`client_id` / `client_secret` form fields). Only one method may be used per request. + A public client sends `client_id` with no secret. + tags: [Token] + security: + - clientSecretBasic: [] + - clientSecretPost: [] + requestBody: + required: true + content: + application/x-www-form-urlencoded: + schema: + oneOf: + - $ref: '#/components/schemas/AuthorizationCodeGrant' + - $ref: '#/components/schemas/RefreshTokenGrant' + - $ref: '#/components/schemas/ClientCredentialsGrant' + discriminator: + propertyName: grant_type + mapping: + authorization_code: '#/components/schemas/AuthorizationCodeGrant' + refresh_token: '#/components/schemas/RefreshTokenGrant' + client_credentials: '#/components/schemas/ClientCredentialsGrant' + examples: + authorizationCode: + summary: Authorization code exchange + value: + grant_type: authorization_code + code: SplxlOBeZQQYbYS6WxSbIA + redirect_uri: https://app.example.com/callback + code_verifier: dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk + refreshToken: + summary: Refresh + value: + grant_type: refresh_token + refresh_token: tGzv3JOkF0XG5Qx2TlKWIA + scope: openid profile + clientCredentials: + summary: Machine-to-machine + value: + grant_type: client_credentials + scope: api:read + resource: [https://api.example.com] + responses: + '200': + $ref: '#/components/responses/Tokens' + '400': + $ref: '#/components/responses/TokenError' + '401': + $ref: '#/components/responses/InvalidClient' + +components: + securitySchemes: + clientSecretBasic: + type: http + scheme: basic + description: | + `client_secret_basic` — `client_id` as the username, `client_secret` as the password + (RFC 6749 §2.3.1). Takes precedence when both methods are present. + clientSecretPost: + type: apiKey + in: cookie + name: client_secret + description: | + `client_secret_post` — `client_id` and `client_secret` sent as form fields. Modelled + here as a scheme only because OpenAPI has no dedicated form-credential type; the + values belong in the request body. + + schemas: + AuthorizationCodeGrant: + type: object + title: authorization_code + description: Exchanges the code returned by `/authorize` for tokens. + required: [grant_type, code, redirect_uri, code_verifier] + properties: + grant_type: + type: string + const: authorization_code + code: + type: string + description: Authorization code from the `/authorize` redirect. Single-use and short-lived. + redirect_uri: + type: string + format: uri + description: Must be identical to the `redirect_uri` of the authorization request. + code_verifier: + type: string + description: PKCE verifier matching the `code_challenge` of the authorization request. + client_id: + type: string + description: Required for `client_secret_post` and for public clients. + client_secret: + type: string + description: Confidential clients using `client_secret_post`. + + RefreshTokenGrant: + type: object + title: refresh_token + description: | + Issues a fresh access token. Refresh tokens rotate: the presented token is + invalidated and a new one is returned. + required: [grant_type, refresh_token] + properties: + grant_type: + type: string + const: refresh_token + refresh_token: + type: string + scope: + type: string + description: | + Space-delimited subset of the originally granted scopes. Defaults to the full + granted set; requesting anything beyond it fails with `invalid_scope`. + resource: + type: array + items: + type: string + format: uri + description: RFC 8707 resource indicators narrowing the `aud` of the new access token. Repeatable. + client_id: + type: string + client_secret: + type: string + + ClientCredentialsGrant: + type: object + title: client_credentials + description: | + Machine-to-machine grant. There is no user, so the access token's `sub` is the + `client_id`, no refresh token is issued and no ID token is produced. + required: [grant_type] + properties: + grant_type: + type: string + const: client_credentials + scope: + type: string + resource: + type: array + items: + type: string + format: uri + description: RFC 8707 resource indicators. Repeatable. + client_id: + type: string + client_secret: + type: string + + TokenResponse: + type: object + required: [access_token, token_type, expires_in] + properties: + access_token: + type: string + description: | + Signed JWT. Beyond the registered claims it carries `client_id`, `scope`, `jti`, + `roles`, `tenant_id`, and — for user-bound tokens — `sid`, `amr`, `auth_time` + and `acr`. + token_type: + type: string + const: Bearer + expires_in: + type: integer + format: int64 + description: Access token lifetime in seconds. + refresh_token: + type: string + description: Present for `authorization_code` and `refresh_token` when the client is allowed to refresh. + scope: + type: string + description: Space-delimited granted scopes. Omitted when empty. + id_token: + type: string + description: | + Signed JWT, returned only when the grant is bound to a user and the granted scope + contains `openid`. Includes `at_hash`, `sid`, and the claims selected by scope and + by the `claims` request parameter. + + TokenErrorResponse: + type: object + required: [error] + properties: + error: + type: string + enum: + - invalid_request + - invalid_client + - invalid_grant + - unsupported_grant_type + - invalid_scope + - invalid_target + error_description: + type: string + error_uri: + type: string + format: uri + + responses: + Tokens: + description: Tokens issued. + headers: + Cache-Control: + schema: + type: string + const: no-store + content: + application/json: + schema: + $ref: '#/components/schemas/TokenResponse' + example: + access_token: eyJhbGciOiJSUzI1NiIsInR5cCI6ImF0K2p3dCJ9... + token_type: Bearer + expires_in: 3600 + refresh_token: tGzv3JOkF0XG5Qx2TlKWIA + scope: openid profile email + id_token: eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9... + + TokenError: + description: | + - `invalid_request` — a required field is missing or malformed. + - `invalid_grant` — the code or refresh token is unknown, expired, already used, bound + to a different client or redirect URI, or PKCE verification failed. + - `unsupported_grant_type` — `grant_type` is absent or not one of the three supported values. + - `invalid_scope` — the requested scope exceeds what was granted. + - `invalid_target` — a `resource` value is unknown for the client's tenant. + headers: + Cache-Control: + schema: + type: string + const: no-store + Pragma: + schema: + type: string + const: no-cache + content: + application/json: + schema: + $ref: '#/components/schemas/TokenErrorResponse' + example: + error: invalid_grant + error_description: Authorization code not found or expired, client_id or redirect_uri not match or PKCE validation failed + error_uri: https://datatracker.ietf.org/doc/html/rfc6749#section-5.2 + + InvalidClient: + description: | + `invalid_client` — the client is unknown, credentials were not supplied, more than one + authentication method was used, or the secret does not verify. + headers: + Cache-Control: + schema: + type: string + const: no-store + Pragma: + schema: + type: string + const: no-cache + content: + application/json: + schema: + $ref: '#/components/schemas/TokenErrorResponse' + example: + error: invalid_client + error_description: Client not exists, credentials not provided or otherwise invalid + error_uri: https://datatracker.ietf.org/doc/html/rfc6749#section-2.3.1 diff --git a/auth/open-api/userinfo.yaml b/auth/open-api/userinfo.yaml new file mode 100644 index 00000000..f6537995 --- /dev/null +++ b/auth/open-api/userinfo.yaml @@ -0,0 +1,156 @@ +openapi: 3.1.0 +info: + title: Versola Auth — UserInfo Endpoint + version: 1.0.0 + description: | + OpenID Connect UserInfo endpoint (OpenID Connect Core §5.3). Returns claims about the user + the presented access token was issued for. + + The access token must have been issued with the `openid` scope and must be bound to a + user — a `client_credentials` token has no subject and is rejected. + + Which claims appear is determined by the scopes granted at authorization time and by the + `claims` request parameter, both of which are carried inside the access token. + + `GET` and `POST` behave identically; `POST` exists for clients that would otherwise have to + put the token in a URL. + license: + name: Business Source License 1.1 + url: https://github.com/versolauth/versola/blob/main/LICENCE.md + +servers: + - url: https://auth.example.com + description: Auth service issuer origin + +paths: + /userinfo: + get: + operationId: userInfoGet + summary: Retrieve claims about the authenticated user + tags: [UserInfo] + security: + - bearerAuth: [] + parameters: + - $ref: '#/components/parameters/Accept' + responses: + '200': + $ref: '#/components/responses/UserInfo' + '401': + $ref: '#/components/responses/Unauthorized' + + post: + operationId: userInfoPost + summary: Retrieve claims about the authenticated user + tags: [UserInfo] + security: + - bearerAuth: [] + parameters: + - $ref: '#/components/parameters/Accept' + responses: + '200': + $ref: '#/components/responses/UserInfo' + '401': + $ref: '#/components/responses/Unauthorized' + +components: + securitySchemes: + bearerAuth: + type: http + scheme: bearer + bearerFormat: JWT + description: | + The access token from `/token`, sent as `Authorization: Bearer `. This is the + only accepted way to present the token. + + parameters: + Accept: + name: Accept + in: header + required: false + description: | + Send `application/jwt` to receive the claims as a signed JWT instead of a plain JSON + object. The signed form is issued for the requesting client and is valid for + 5 minutes. + schema: + type: string + enum: [application/json, application/jwt] + default: application/json + + schemas: + UserInfoResponse: + type: object + description: | + Claim set for the user. Only `sub` is always present; the rest depend on the granted + scopes (`profile`, `email`, `phone`, `address`) and on the requested claims. + required: [sub] + additionalProperties: true + properties: + sub: + type: string + description: Stable user identifier. + name: + type: string + given_name: + type: string + family_name: + type: string + preferred_username: + type: string + picture: + type: string + format: uri + locale: + type: string + email: + type: string + format: email + email_verified: + type: boolean + phone_number: + type: string + phone_number_verified: + type: boolean + updated_at: + type: integer + format: int64 + + responses: + UserInfo: + description: | + The user's claims, as a JSON object or — when `Accept: application/jwt` was sent — as a + signed JWT whose payload carries the same claims alongside `iss`, `aud`, `sub` and `exp`. + content: + application/json: + schema: + $ref: '#/components/schemas/UserInfoResponse' + example: + sub: 018f3c6e-5a2b-7c11-9d0e-4f8a2b6c1d33 + name: Jane Doe + given_name: Jane + family_name: Doe + email: jane@example.com + email_verified: true + application/jwt: + schema: + type: string + example: eyJhbGciOiJSUzI1NiIsImtpZCI6ImtleS0xIn0... + + Unauthorized: + description: | + The error is reported in the `WWW-Authenticate` header; the body is empty. + + - `invalid_request` — no `Authorization: Bearer` header was sent. + - `invalid_token` — the token failed verification, expired, or is not bound to a user. + - `insufficient_scope` — the token was not granted the `openid` scope. + headers: + WWW-Authenticate: + required: true + schema: + type: string + examples: + invalidToken: + value: Bearer realm="UserInfo", error="invalid_token", error_description="The access token is invalid or expired" + insufficientScope: + value: Bearer realm="UserInfo", error="insufficient_scope", error_description="The access token does not have sufficient scope" + invalidRequest: + value: Bearer realm="UserInfo", error="invalid_request", error_description="The request is missing a required parameter or is otherwise malformed"