From beb6f9c946c760db093e72eef85a12e873ab4e2e Mon Sep 17 00:00:00 2001 From: IanM Date: Sun, 1 Nov 2026 08:50:25 +0000 Subject: [PATCH 1/2] docs: document the OAuth Provider extension --- docs/extend/oauth-provider.md | 392 ++++++++++++++++++++++++++++++ docs/extensions/oauth-provider.md | 174 +++++++++++++ sidebars.js | 2 + 3 files changed, 568 insertions(+) create mode 100644 docs/extend/oauth-provider.md create mode 100644 docs/extensions/oauth-provider.md diff --git a/docs/extend/oauth-provider.md b/docs/extend/oauth-provider.md new file mode 100644 index 000000000..d91278fb5 --- /dev/null +++ b/docs/extend/oauth-provider.md @@ -0,0 +1,392 @@ +# OAuth Provider + +The [`flarum/oauth-provider` extension](../extensions/oauth-provider.md) turns a forum into an **OAuth 2.0 authorization server** and **OpenID Connect provider**. It is built on [`league/oauth2-server`](https://oauth2.thephpleague.com/). + +This page is for two audiences: + +- **Extension developers** who want to add scopes, or protect their own API with the forum's tokens. See [Adding scopes](#adding-scopes) and [Protecting your own API](#protecting-your-own-api). +- **Application developers** connecting an application to a forum. See [Connecting an application](#connecting-an-application) and the [endpoint reference](#endpoints). + +Managing applications in the admin panel, and what members see, is covered on the [extension's page](../extensions/oauth-provider.md). + +## Standards + +| Specification | Support | +| --- | --- | +| [RFC 6749](https://datatracker.ietf.org/doc/html/rfc6749): OAuth 2.0 | Authorization code and refresh token grants | +| [RFC 6750](https://datatracker.ietf.org/doc/html/rfc6750): Bearer tokens | `Authorization: Bearer …` | +| [RFC 7636](https://datatracker.ietf.org/doc/html/rfc7636): PKCE | `S256` only. Required for public clients. | +| [RFC 7009](https://datatracker.ietf.org/doc/html/rfc7009): Token revocation | `/oauth/revoke` | +| [RFC 7591](https://datatracker.ietf.org/doc/html/rfc7591): Dynamic client registration | `/oauth/register`, public clients only. Admins can switch it off. | +| [Client ID Metadata Documents](https://datatracker.ietf.org/doc/draft-ietf-oauth-client-id-metadata-document/) (draft) | A client ID that is an `https://` URL | +| [RFC 8414](https://datatracker.ietf.org/doc/html/rfc8414): Authorization server metadata | `/.well-known/oauth-authorization-server` | +| [RFC 8707](https://datatracker.ietf.org/doc/html/rfc8707): Resource indicators | The `resource` parameter, for resources extensions register | +| [RFC 9207](https://datatracker.ietf.org/doc/html/rfc9207): Issuer identification | `iss` on every redirect back to the client | +| [RFC 9068](https://datatracker.ietf.org/doc/html/rfc9068): JWT access tokens | Signed with RS256 | +| [RFC 7517](https://datatracker.ietf.org/doc/html/rfc7517): JSON Web Keys | `/.well-known/jwks.json` | +| [OpenID Connect Core 1.0](https://openid.net/specs/openid-connect-core-1_0.html) | Authorization code flow, ID tokens, UserInfo, `nonce`, `max_age`, `prompt` | +| [OpenID Connect Discovery 1.0](https://openid.net/specs/openid-connect-discovery-1_0.html) | `/.well-known/openid-configuration` | + +The client credentials, device authorization and implicit grants are not supported, and neither is token introspection. Access tokens and ID tokens are signed with RS256. `sub` is the member's Flarum user ID, as a string, for every client. + +Flarum's own REST API does not accept these tokens. They are for the UserInfo endpoint and for the [resources extensions register](#protecting-your-own-api). + +## Adding scopes + +The extension registers `openid`, `profile` and `email`. Register more with the `Scope` extender in your `extend.php`: + +```php +use Flarum\OAuthProvider\Extend\Scope; + +return [ + new Scope('invoices:read', 'Read your invoice history'), +]; +``` + +The description is shown to the member on the consent screen when a client asks for the scope. It is shown as written: it is not passed through the translator. + +Registered scopes appear in `scopes_supported` in the discovery documents. Admins can allow them on any client. A client with no allowed scopes set may ask for every registered scope. + +Because the extender's class comes from `flarum/oauth-provider`, add the extension to `require` in your `composer.json`. Alternatively, make it an [optional dependency](extending-extensions.md#optional-dependencies) and wrap your extenders in `(new Extend\Conditional())->whenExtensionEnabled('flarum-oauth-provider', fn () => [...])`. + +## Protecting your own API + +An extension with an API of its own, an MCP server for example, can accept the forum's tokens through [resource indicators](https://datatracker.ietf.org/doc/html/rfc8707). Register the API's URL as a resource, with the scopes that belong to it: + +```php +use Flarum\Http\UrlGenerator; +use Flarum\OAuthProvider\Extend\Resource; +use Flarum\OAuthProvider\Extend\Scope; + +return [ + new Scope('mcp:read', 'Read discussions and posts on your behalf'), + new Scope('mcp:write', 'Post on your behalf'), + + new Resource( + fn (UrlGenerator $url) => $url->to('forum')->route('mcp'), + scopes: ['mcp:read', 'mcp:write'], + ), +]; +``` + +- The URL is given as a callable because the forum's URL is only known at runtime. It must return the absolute URL that clients send as `resource`. +- Each scope must also be registered with `Scope`. `Resource` doesn't register them. + +A client asks for a token for the resource by sending `resource=` on the authorization request. It may also send it on the token request. Such a request: + +- may only ask for the resource's scopes, plus `openid`, `profile` and `email`; +- is refused with `invalid_target` for a resource that isn't registered; +- gets a token whose `aud` is the resource URL. The token is rejected by every other resource and by the UserInfo endpoint. + +The token stays bound to that resource when it is refreshed. + +### Validating tokens + +In the controller or middleware that serves the resource, validate the bearer token with `TokenValidator`: + +```php +use Flarum\Http\UrlGenerator; +use Flarum\OAuthProvider\ResourceServer\TokenValidator; +use Laminas\Diactoros\Response\JsonResponse; +use Psr\Http\Message\ResponseInterface; +use Psr\Http\Message\ServerRequestInterface; +use Psr\Http\Server\RequestHandlerInterface; + +class McpController implements RequestHandlerInterface +{ + public function __construct( + protected TokenValidator $tokens, + protected UrlGenerator $url, + ) { + } + + public function handle(ServerRequestInterface $request): ResponseInterface + { + $header = $request->getHeaderLine('Authorization'); + $jwt = str_starts_with($header, 'Bearer ') ? substr($header, 7) : ''; + + $token = $this->tokens->validate($jwt, $this->url->to('forum')->route('mcp')); + + if ($token === null) { + return new JsonResponse(['error' => 'invalid_token'], 401, ['WWW-Authenticate' => 'Bearer error="invalid_token"']); + } + + if (! in_array('mcp:read', $token->scopes, true)) { + return new JsonResponse(['error' => 'insufficient_scope'], 403, ['WWW-Authenticate' => 'Bearer error="insufficient_scope", scope="mcp:read"']); + } + + $actor = $token->user; + + // …answer the request as $actor + } +} +``` + +`validate()` returns `null` for any token that: + +- has a bad signature; +- has expired or been revoked; +- belongs to a revoked client; +- was issued for another resource; +- belongs to a user who no longer exists. + +Otherwise it returns a `ValidatedToken` with these properties: + +| Property | | +| --- | --- | +| `user` | The `Flarum\User\User` the token acts for | +| `clientId` | The client it was issued to | +| `scopes` | The granted scopes | +| `tokenId` | The token's ID (`jti`) | +| `resource` | The resource URL | + +Validation also records when the token was last used. Members see this under **Connected applications**. It is written at most once a minute per token. + +The token only says which member it acts for. Check that member's permissions as you would for any other request. + +The extension doesn't serve protected resource metadata ([RFC 9728](https://datatracker.ietf.org/doc/html/rfc9728)). If your protocol needs it, as MCP does, serve it from your extension and point `authorization_servers` at the forum's URL. + +## Connecting an application + +The issuer is the forum's URL, for example `https://forum.example.com`. Libraries that support OpenID Connect discovery (`openid-client` for Node, Authlib for Python, Spring Security, ASP.NET Core and others) only need that URL and a client ID. They read every endpoint and key from `/.well-known/openid-configuration`. + +Get a client ID in one of three ways: + +- **Ask the forum's admins to add your application.** Only an admin-added client can be confidential (with a secret) or use a native app scheme such as `com.example.app:/callback` as its redirect URI. +- **Register dynamically.** POST your client metadata to `/oauth/register`, if the forum allows it. See [`POST /oauth/register`](#post-oauthregister). +- **Use a metadata document.** Use the `https://` URL of a JSON document describing your client as its client ID. See [Client ID Metadata Documents](#client-id-metadata-documents). + +On the forum's consent screen, clients that registered themselves are marked as unverified. + +:::info league/oauth2-client `GenericProvider` + +With `GenericProvider`, set `'responseResourceOwnerId' => 'sub'`. The default is `id`, which UserInfo doesn't return, so `$owner->getId()` would be `null`. + +::: + +:::warning Verify the ID token + +Libraries that support OpenID Connect do this for you. On top of a plain OAuth 2 client, check before trusting any claim: + +- **Signature:** fetch `/.well-known/jwks.json`, find the key matching the token's `kid` header, and verify the RS256 signature. +- **`iss`:** equals the issuer. +- **`aud`:** contains your client ID. +- **`exp`:** is in the future. +- **`nonce`:** equals the one you sent, if you sent one. + +::: + +## Endpoints + +All endpoints are on the forum's URL. + +| Path | Method | | +| --- | --- | --- | +| `/oauth/authorize` | GET, POST | Authorization endpoint | +| `/oauth/token` | POST | Token endpoint | +| `/oauth/userinfo` | GET | OpenID Connect UserInfo | +| `/oauth/revoke` | POST | Token revocation | +| `/oauth/register` | POST | Dynamic client registration, when enabled | +| `/.well-known/openid-configuration` | GET | OpenID Connect discovery | +| `/.well-known/oauth-authorization-server` | GET | Authorization server metadata | +| `/.well-known/jwks.json` | GET | The public key that verifies tokens | + +Clients call the token, revocation and registration endpoints directly, so they need no CSRF token. + +### `GET /oauth/authorize` + +| Parameter | | +| --- | --- | +| `response_type` | `code`, the only type supported | +| `client_id` | Required | +| `redirect_uri` | Must match a registered URI exactly. For a public client, a loopback URI (`localhost`, `127.0.0.1`, `[::1]`) matches on any port. | +| `scope` | Space-separated. A client with allowed scopes is only granted those, whatever it asks for. | +| `state` | Recommended. Returned unchanged. | +| `code_challenge`, `code_challenge_method` | PKCE. The method must be `S256`. Required for public clients. | +| `nonce` | Copied into the ID token | +| `prompt` | Space-separated. `login` asks the member to sign in again. `consent` shows the consent screen even when the member already approved. `none` never shows a page and returns `login_required` or `interaction_required` instead. | +| `max_age` | Seconds. The member must sign in again if they signed in longer ago than this. `0` always asks. | +| `resource` | A [registered resource](#protecting-your-own-api) | + +A guest is sent to the forum's login form, then brought back to continue. The member then sees the consent screen, unless they already approved this client: + +- for the same or more scopes; +- with the same redirect URIs; +- and the request has no `prompt=consent`. + +Approving redirects to `redirect_uri` with `code`, `state` and `iss`. Declining redirects with `error=access_denied`. Approvals of the same client are merged into one, which the member can revoke from their settings. + +When the client or redirect URI is invalid, the forum answers with a JSON error and doesn't redirect. + +### `POST /oauth/token` + +Exchanging an authorization code: + +``` +grant_type=authorization_code +code= +redirect_uri= +client_id= +client_secret= # confidential clients +code_verifier= # when a code_challenge was sent +resource= # optional; must match the authorization request's, if it had one +``` + +Refreshing: + +``` +grant_type=refresh_token +refresh_token= +client_id= +client_secret= # confidential clients +scope= # optional; no more than were granted +``` + +Confidential clients may send their credentials with HTTP Basic authentication instead. The response: + +```json +{ + "token_type": "Bearer", + "expires_in": 3600, + "access_token": "", + "refresh_token": "", + "id_token": "" +} +``` + +| | Lifetime | +| --- | --- | +| Authorization code | 10 minutes, single use | +| Access token | 1 hour | +| Refresh token | 1 month. Replaced on every refresh. | + +**Replay detection.** Presenting a used authorization code, or a refresh token that was already exchanged, counts as theft. Every token that came from the same authorization is revoked, and the client has to start a new authorization. + +**ID token.** `id_token` is included when `openid` was granted. It is signed with the same key as the access token and contains: + +- `iss`, `sub`, `aud` (the client ID), `iat` and `exp`; +- `auth_time`: when the member signed in to the forum, not when they approved the client; +- `nonce`, if one was sent; +- `name` and `picture` with `profile`; +- `email` and `email_verified` with `email`. + +A refreshed ID token keeps the original `auth_time`. + +**Access token.** The access token is a JWT with: + +- `jti`, `sub` and `scopes`; +- `aud`: the client ID, or the resource URL for a resource-bound token. + +### `GET /oauth/userinfo` + +Send the access token as `Authorization: Bearer `. The token must have the `openid` scope, or the response is `403 insufficient_scope`. Tokens bound to a resource are refused. + +| Scope | Claims | +| --- | --- | +| `openid` | `sub` | +| `profile` | `name`, `picture`, and `picture_srcset` when the avatar has HiDPI versions | +| `email` | `email`, `email_verified` | + +`picture_srcset` is not a standard claim. It has the format of the HTML `srcset` attribute (`url 1x, url@2x 2x, url@3x 3x`). It is present when the [avatar driver](avatars.md) provides larger versions. + +### `POST /oauth/revoke` + +Revokes an access token or a refresh token. Send: + +- `token`; +- your client credentials, as on the token endpoint. A public client sends only `client_id`. + +Revoking either token also revokes the other one issued with it. + +The response is `200` for unknown tokens, and for tokens of other clients, so the endpoint can't be used to test whether a token exists. + +### `POST /oauth/register` + +Dynamic client registration ([RFC 7591](https://datatracker.ietf.org/doc/html/rfc7591)) for public clients. The endpoint exists only while the admin setting **Allow dynamic client registration** is on, which is the default. Otherwise it returns `404`, and `registration_endpoint` is left out of the discovery documents. + +Send a JSON object: + +| Field | | +| --- | --- | +| `redirect_uris` | Required. 1 to 10 `https://` URLs, or `http://` URLs on a loopback address. No fragments. | +| `client_name` | Shown on the consent screen. Defaults to the host of `client_uri` or of the first redirect URI. | +| `scope` | Space-separated. Every scope must be registered. Leave it out to allow all. | +| `client_uri`, `logo_uri` | Optional `https://` URLs | +| `token_endpoint_auth_method` | `none`, if sent | +| `grant_types` | A subset of `authorization_code` and `refresh_token`, if sent | +| `response_types` | `code`, if sent | + +The response is `201` with the client's metadata and its `client_id`. No registration access token is issued, so the client can't update or delete its registration. + +Registrations are limited to 10 an hour per IP address, or per `/64` for IPv6. Over the limit, the response is `429 too_many_requests`. + +### Client ID Metadata Documents + +A client can use an `https://` URL as its client ID instead of registering. This is described in the [Client ID Metadata Document draft](https://datatracker.ietf.org/doc/draft-ietf-oauth-client-id-metadata-document/). The forum fetches the document when the client first sends a member to `/oauth/authorize`, and again on the first authorization after the cached copy expires. + +The URL must: + +- have a path other than `/`; +- have no fragment or credentials; +- be at most 255 characters. + +The document must: + +- be served as `application/json`, with status `200`; +- be at most 64 KiB; +- be reachable within 5 seconds, in at most 5 redirects; +- be on a public IP address. Loopback is also allowed while the forum is in debug mode. + +It must contain: + +| Field | | +| --- | --- | +| `client_id` | Exactly the document's URL | +| `client_name` | Required | +| `redirect_uris` | As for [dynamic registration](#post-oauthregister) | +| `scope`, `client_uri`, `logo_uri` | Optional, as for dynamic registration | +| `token_endpoint_auth_method` | `none`, if present | + +It must not contain `client_secret` or `client_secret_expires_at`. + +The document is cached for its `Cache-Control: max-age`. This is one hour if not set, and never less than 5 minutes or more than a day. + +A document that can't be used gets a generic `invalid_client` error. The reason is written to the forum's log. The same URL is then refused for a minute without being fetched again. Fetches are limited to 20 an hour per IP address. + +If the redirect URIs in the document change, members are asked for consent again. + +### Discovery + +`/.well-known/oauth-authorization-server` and `/.well-known/openid-configuration` return: + +| Field | Value | +| --- | --- | +| `issuer` | The forum's URL | +| `authorization_endpoint`, `token_endpoint`, `revocation_endpoint`, `jwks_uri` | The endpoints above | +| `registration_endpoint` | When dynamic registration is enabled | +| `scopes_supported` | Every registered scope | +| `response_types_supported` | `["code"]` | +| `response_modes_supported` | `["query"]` | +| `grant_types_supported` | `["authorization_code", "refresh_token"]` | +| `token_endpoint_auth_methods_supported`, `revocation_endpoint_auth_methods_supported` | `["client_secret_basic", "client_secret_post", "none"]` | +| `code_challenge_methods_supported` | `["S256"]` | +| `authorization_response_iss_parameter_supported` | `true` | +| `client_id_metadata_document_supported` | `true` | + +`/.well-known/openid-configuration` adds: + +| Field | Value | +| --- | --- | +| `userinfo_endpoint` | `/oauth/userinfo` | +| `subject_types_supported` | `["public"]` | +| `id_token_signing_alg_values_supported` | `["RS256"]` | +| `claims_supported` | `sub`, `iss`, `aud`, `exp`, `iat`, `auth_time`, `nonce`, `name`, `picture`, `email`, `email_verified` | + +### `GET /.well-known/jwks.json` + +The RSA public key that verifies access tokens and ID tokens. It has: + +- `kty: RSA`, `use: sig` and `alg: RS256`; +- a `kid` that changes when the key changes. + +The forum creates the key on first use and stores it in `storage/oauth-provider/`. If the key is replaced, every token issued with the old one stops working. Clients that fetch the key set again when they see an unknown `kid` pick up the new key automatically. diff --git a/docs/extensions/oauth-provider.md b/docs/extensions/oauth-provider.md new file mode 100644 index 000000000..c0d229e41 --- /dev/null +++ b/docs/extensions/oauth-provider.md @@ -0,0 +1,174 @@ +# OAuth Provider + +The OAuth Provider extension (`flarum/oauth-provider`) turns your forum into an **OAuth 2.0 authorization server** and **OpenID Connect provider**. Other applications can then offer "Sign in with" your forum, and act on a member's behalf with the access the member agrees to. These might be another website, a mobile app, or an AI assistant such as Claude or ChatGPT connecting over MCP. + +It is a [bundled extension](../extensions.md), but it is not enabled on new installs. + +:::tip For developers + +Extensions can add their own scopes and protect their own APIs with tokens from this extension. The developer guide also lists every endpoint for anyone building an application that connects to a forum. See [OAuth Provider](../extend/oauth-provider.md). + +::: + +## Installation + +Enable the extension from the **Extensions** page of the admin panel. If it is not present in your install, require it like any other Flarum package: + +```bash +composer require flarum/oauth-provider +php flarum migrate +php flarum cache:clear +``` + +The extension needs PHP's OpenSSL extension to sign tokens. It also needs the cURL extension to accept applications that identify themselves with a [metadata document](#applications-that-register-themselves). + +## How signing in works + +1. An application sends the member to your forum. +2. If they aren't signed in, the forum shows its usual login form. Afterwards they continue where they left off. +3. The forum shows a consent screen. It names the application and lists what it is asking for, such as their profile or their email address. +4. When the member selects **Authorize**, they go back to the application, which can now act with the access they granted. If they select **Cancel**, the application is told they declined. + +The forum remembers each member's decision. The next time the same application asks for the same access, the member goes straight back without seeing the consent screen. The screen appears again when the application: + +- asks for more access; +- changes where it sends members back to; +- asks for the screen explicitly. + +The consent screen can't be shown inside another site's frame, so it can't be disguised as part of another page. + +## Managing applications + +Go to the extension's page in the admin panel. The **Clients** tab lists every application that can connect, with its client ID, its redirect URIs, and whether it is revoked. Applications that [registered themselves](#applications-that-register-themselves) are labelled **Self-registered** or **Metadata document**. + +Only administrators can see and manage applications. + +### Adding an application + +Select **Add client** and fill in: + +| Field | What it is | +| --- | --- | +| **Display name** | The name members see on the consent screen. | +| **Redirect URIs** | Where the forum may send members back to, one per line. The application must use one of them exactly. | +| **Allowed scopes** | The most the application may ask for, separated by spaces. The default is `openid profile email`. Leave the field empty to allow every scope, including those other extensions add. | +| **Confidential client** | On for an application with a server that can keep a secret. Off for an app that runs entirely on a device or in the browser, such as a mobile app or a single-page app. This can't be changed later. | + +When you save, the forum shows the **client ID** and, for a confidential client, the **client secret**. The secret is shown only once: copy it into the application straight away. + +Redirect URIs must be `https://` URLs. Two exceptions: + +- `http://` is allowed for addresses on the same computer (`localhost`, `127.0.0.1` or `[::1]`). For an application that isn't confidential, any port on such an address is accepted. +- Mobile and desktop apps can use their own scheme, such as `com.example.app:/callback`. Only administrators can register these. + +An application can have up to 10 redirect URIs. + +The scopes the forum offers are: + +| Scope | What the application gets | +| --- | --- | +| `openid` | An identifier for the member. Needed to sign members in. | +| `profile` | Their display name and avatar. | +| `email` | Their email address, and whether it is confirmed. | + +Other extensions can add more. + +### Revoking, rotating and deleting + +Use **Edit** to change an application's details. Switching on **Revoked** stops the application at once. Members can't authorize it, and the access it already has stops working. Switch it off again to restore the application. + +For a confidential client, **Rotate secret** replaces its secret and shows the new one once. Everything issued to the application stops working at once, and it must use the new secret to connect again. Use this when a secret may have leaked. + +**Delete** removes the application, everything issued to it, and members' approvals of it. + +Narrowing an application's allowed scopes applies to new sign-ins at once. Access the application already has loses the removed scopes the next time it is renewed. + +## Applications that register themselves + +Some applications, AI assistants in particular, connect to forums they have never seen. They can't wait for an administrator to add them, so the extension supports two standard ways for them to identify themselves: + +- **Dynamic Client Registration** (RFC 7591). The application registers itself at `/oauth/register`. +- **Client ID Metadata Documents.** The application's client ID is an `https://` URL that describes it. The forum fetches it when the application first connects and refreshes it after it expires: by default after an hour, and never sooner than 5 minutes or later than a day. + +Applications that identify themselves this way: + +- can't keep a secret, so they must prove each request the way mobile apps do (PKCE); +- can only use `https://` redirect URIs, or `http://` ones on the same computer; +- appear on the **Clients** tab, where you can revoke or delete them like any other. + +Because these applications choose their own name, members see them marked **unverified, self-registered** on the consent screen. The screen also shows which site the member will be sent back to, and for a metadata document, which site describes the application. + +To stop applications registering themselves, go to the **Settings** tab and switch off **Allow dynamic client registration**. It is on by default. Applications identified by a metadata document are still accepted. + +### Limits and clean-up + +Anyone can register an application without signing in, so the forum limits how often it happens from each IP address: + +- 10 registrations an hour; +- 20 metadata document fetches an hour. + +IPv6 addresses are counted per `/64` network. + +A self-registered application that no member ever authorized is deleted after 30 days. This runs daily through the [scheduler](../scheduler.md), or by hand with: + +```bash +php flarum oauth:prune-clients +``` + +## What members see + +Members find the applications they've approved under **Connected applications** on their **Settings** page. For each one it shows: + +- what it can access; +- when it was connected; +- when it last used that access. + +**Revoke** signs the application out at once. It must ask for permission again to come back. + +## How long access lasts + +| | Lifetime | +| --- | --- | +| Authorization code | 10 minutes, and usable once | +| Access token | 1 hour | +| Refresh token | 1 month, replaced each time it is used | + +An application renews its access with its refresh token, without asking the member again. If an authorization code or a refresh token is used a second time, the forum treats it as stolen. It revokes everything that came from the same sign-in, and the application has to send the member through sign-in again. + +## Keys and backups + +The extension signs tokens with keys it creates the first time it needs them. They are stored in `storage/oauth-provider/`, readable only by the web server's user. + +- **Back up that directory.** If its keys are lost, every token stops working, and applications have to send members through sign-in again. +- **Never serve it publicly.** +- **With several servers or containers, share it** between them and keep it across deployments. Otherwise each one creates its own keys and rejects the others' tokens. + +## Running behind a reverse proxy + +The [registration limits](#limits-and-clean-up) count requests per IP address. Flarum takes the address from the web server (`REMOTE_ADDR`) and doesn't read `X-Forwarded-For` itself. Behind a reverse proxy, load balancer or CDN, every request then seems to come from the proxy. All visitors share one limit, and one of them can use it up for everyone. + +Configure the web server to restore the visitor's address from the forwarding header, **trusting only your own proxies**: + +- **nginx** (`ngx_http_realip_module`): + + ```nginx + set_real_ip_from ; + real_ip_header X-Forwarded-For; + ``` + +- **Apache** (`mod_remoteip`): + + ```apache + RemoteIPHeader X-Forwarded-For + RemoteIPTrustedProxy + ``` + +Never trust the header from every address. Anyone could then choose their own address and escape the limits. + +## Permissions + +The extension adds no permissions. Only administrators can manage applications. Every signed-in member can approve applications and revoke their own approvals. + +## Deleting members + +Deleting a member deletes their approvals and everything issued for them. diff --git a/sidebars.js b/sidebars.js index 82d879e43..009869db5 100644 --- a/sidebars.js +++ b/sidebars.js @@ -74,6 +74,7 @@ module.exports = { 'extensions/markdown', 'extensions/messages', 'extensions/nicknames', + 'extensions/oauth-provider', 'extensions/realtime', 'extensions/statistics', 'extensions/sticky', @@ -169,6 +170,7 @@ module.exports = { 'extend/audit', 'extend/deck', 'extend/gdpr', + 'extend/oauth-provider', 'extend/statistics', 'extend/tags', 'extend/post-types', From b9c3ce40e531074907cce572899e8ef511f152bc Mon Sep 17 00:00:00 2001 From: IanM Date: Sun, 1 Nov 2026 16:34:20 +0000 Subject: [PATCH 2/2] docs(oauth-provider): tabs and switches per kind of application, the connect permission, the Security page, GDPR and Audit --- docs/extend/oauth-provider.md | 38 ++++++++++++++++------ docs/extensions/audit.md | 16 +++++++++ docs/extensions/gdpr.md | 2 ++ docs/extensions/oauth-provider.md | 54 +++++++++++++++++++++++++++---- 4 files changed, 94 insertions(+), 16 deletions(-) diff --git a/docs/extend/oauth-provider.md b/docs/extend/oauth-provider.md index d91278fb5..cdbd0f5f5 100644 --- a/docs/extend/oauth-provider.md +++ b/docs/extend/oauth-provider.md @@ -105,7 +105,11 @@ class McpController implements RequestHandlerInterface $header = $request->getHeaderLine('Authorization'); $jwt = str_starts_with($header, 'Bearer ') ? substr($header, 7) : ''; - $token = $this->tokens->validate($jwt, $this->url->to('forum')->route('mcp')); + $token = $this->tokens->validate( + $jwt, + $this->url->to('forum')->route('mcp'), + $request->getAttribute('ipAddress'), + ); if ($token === null) { return new JsonResponse(['error' => 'invalid_token'], 401, ['WWW-Authenticate' => 'Bearer error="invalid_token"']); @@ -126,9 +130,9 @@ class McpController implements RequestHandlerInterface - has a bad signature; - has expired or been revoked; -- belongs to a revoked client; +- belongs to a revoked client, or to a kind of client the admin has switched off; - was issued for another resource; -- belongs to a user who no longer exists. +- belongs to a user who no longer exists, or who doesn't have the **Connect applications to their account** permission (`oauthProvider.connect`). Suspended members don't, and nor do anonymised ones. Otherwise it returns a `ValidatedToken` with these properties: @@ -140,12 +144,28 @@ Otherwise it returns a `ValidatedToken` with these properties: | `tokenId` | The token's ID (`jti`) | | `resource` | The resource URL | -Validation also records when the token was last used. Members see this under **Connected applications**. It is written at most once a minute per token. +Validation also records when the token was last used and, when you pass the request's IP address as the third argument, where from. Members see both under **Connected applications** on their Security page. They are written at most once a minute per token. The token only says which member it acts for. Check that member's permissions as you would for any other request. The extension doesn't serve protected resource metadata ([RFC 9728](https://datatracker.ietf.org/doc/html/rfc9728)). If your protocol needs it, as MCP does, serve it from your extension and point `authorization_servers` at the forum's URL. +## Events + +The extension dispatches these events, in `Flarum\OAuthProvider\Event`, for what members and administrators do. The extension's own [Audit](../extensions/audit.md) integration listens to them. + +| Event | When | Properties | +| --- | --- | --- | +| `Authorized` | A member authorized an application. | `user`, `client`, `consented`: `true` when the member approved it just now, `false` when an earlier approval covered the request | +| `Disconnected` | A member disconnected an application. | `user`, `client` | +| `DisconnectedAll` | A member disconnected every application at once. Not dispatched when logging out everywhere disconnects them. | `user` | +| `ClientCreated` | An administrator added a client. | `client`, `actor` | +| `ClientUpdated` | An administrator edited a client. | `client`, `actor` | +| `ClientDeleted` | An administrator deleted a client. | `client`, `actor` | +| `ClientSecretRotated` | An administrator rotated a client's secret. | `client`, `actor` | + +`client` is a `Flarum\OAuthProvider\Models\Client`; `user` and `actor` are `Flarum\User\User`. + ## Connecting an application The issuer is the forum's URL, for example `https://forum.example.com`. Libraries that support OpenID Connect discovery (`openid-client` for Node, Authlib for Python, Spring Security, ASP.NET Core and others) only need that URL and a client ID. They read every endpoint and key from `/.well-known/openid-configuration`. @@ -214,7 +234,7 @@ A guest is sent to the forum's login form, then brought back to continue. The me - with the same redirect URIs; - and the request has no `prompt=consent`. -Approving redirects to `redirect_uri` with `code`, `state` and `iss`. Declining redirects with `error=access_denied`. Approvals of the same client are merged into one, which the member can revoke from their settings. +Approving redirects to `redirect_uri` with `code`, `state` and `iss`. Declining redirects with `error=access_denied`, as does a member without the **Connect applications to their account** permission, such as a suspended one. Approvals of the same client are merged into one, which the member can revoke from their Security page. When the client or redirect URI is invalid, the forum answers with a JSON error and doesn't redirect. @@ -302,7 +322,7 @@ The response is `200` for unknown tokens, and for tokens of other clients, so th ### `POST /oauth/register` -Dynamic client registration ([RFC 7591](https://datatracker.ietf.org/doc/html/rfc7591)) for public clients. The endpoint exists only while the admin setting **Allow dynamic client registration** is on, which is the default. Otherwise it returns `404`, and `registration_endpoint` is left out of the discovery documents. +Dynamic client registration ([RFC 7591](https://datatracker.ietf.org/doc/html/rfc7591)) for public clients. The endpoint exists only while the admin setting **Allow self-registered applications** is on, which is the default. Otherwise it returns `404`, and `registration_endpoint` is left out of the discovery documents. Send a JSON object: @@ -322,7 +342,7 @@ Registrations are limited to 10 an hour per IP address, or per `/64` for IPv6. O ### Client ID Metadata Documents -A client can use an `https://` URL as its client ID instead of registering. This is described in the [Client ID Metadata Document draft](https://datatracker.ietf.org/doc/draft-ietf-oauth-client-id-metadata-document/). The forum fetches the document when the client first sends a member to `/oauth/authorize`, and again on the first authorization after the cached copy expires. +A client can use an `https://` URL as its client ID instead of registering. This is described in the [Client ID Metadata Document draft](https://datatracker.ietf.org/doc/draft-ietf-oauth-client-id-metadata-document/). The forum fetches the document when the client first sends a member to `/oauth/authorize`, and again on the first authorization after the cached copy expires. Like dynamic registration, this works only while **Allow self-registered applications** is on. The URL must: @@ -363,7 +383,7 @@ If the redirect URIs in the document change, members are asked for consent again | --- | --- | | `issuer` | The forum's URL | | `authorization_endpoint`, `token_endpoint`, `revocation_endpoint`, `jwks_uri` | The endpoints above | -| `registration_endpoint` | When dynamic registration is enabled | +| `registration_endpoint` | When self-registered applications are allowed | | `scopes_supported` | Every registered scope | | `response_types_supported` | `["code"]` | | `response_modes_supported` | `["query"]` | @@ -371,7 +391,7 @@ If the redirect URIs in the document change, members are asked for consent again | `token_endpoint_auth_methods_supported`, `revocation_endpoint_auth_methods_supported` | `["client_secret_basic", "client_secret_post", "none"]` | | `code_challenge_methods_supported` | `["S256"]` | | `authorization_response_iss_parameter_supported` | `true` | -| `client_id_metadata_document_supported` | `true` | +| `client_id_metadata_document_supported` | `true`, when self-registered applications are allowed | `/.well-known/openid-configuration` adds: diff --git a/docs/extensions/audit.md b/docs/extensions/audit.md index 38dcc5d98..44e902b06 100644 --- a/docs/extensions/audit.md +++ b/docs/extensions/audit.md @@ -194,6 +194,7 @@ Some actions are deliberately not logged to avoid duplicates: - `user.logged_in` — a user logged in via the login form. - `user.logged_in_with_provider` — a user logged in via social login (payload: provider, identifier). - `user.logged_out` — a user logged out. +- `user.logged_out_everywhere` — a user logged out everywhere from their Security page (Global Logout), ending every session. - `user.password_changed` — a user password was changed. - `user.password_change_requested` — a password reset token was issued for a user. - `user.password_reset_attempted` — a password reset was requested via `/forgot` (payload: whether an account matched; the email is stored only when no account matched, otherwise the matched user is recorded). Captured at the HTTP layer, so it retains the requester's IP and records attempts for unknown emails. @@ -230,6 +231,21 @@ These actions cover the privacy operations in [`flarum/gdpr`](gdpr.md). Because - `user.nickname_changed` — a nickname was changed (payload: old nickname, new nickname). +### Flarum OAuth Provider + +These actions cover [`flarum/oauth-provider`](oauth-provider.md): applications that sign members in or act for them, such as AI assistants over MCP. Each entry keeps the application's client ID and its name, so it still reads after the application is deleted. + +- `user.oauth_connected` — a user approved an application (payload: user ID, client ID, client name). +- `user.oauth_signed_in` — a user signed in to an application again, covered by an approval they gave earlier (payload as above). +- `user.oauth_disconnected` — a user revoked an application's access (payload as above). +- `user.oauth_disconnected_all` — a user revoked every application's access at once (payload: user ID). Global Logout also disconnects every application, but is recorded only as `user.logged_out_everywhere`. +- `oauth_client.created` — an administrator added an application (payload: client ID, client name). +- `oauth_client.updated` — an administrator edited an application (payload as above). +- `oauth_client.deleted` — an administrator deleted an application (payload as above). +- `oauth_client.secret_rotated` — an administrator rotated an application's secret (payload as above). + +Declining an application isn't recorded, nor is an application registering itself. + ### Flarum Sticky - `discussion.stickied` — a discussion was stickied. diff --git a/docs/extensions/gdpr.md b/docs/extensions/gdpr.md index f99b00bf7..4ac76c06c 100644 --- a/docs/extensions/gdpr.md +++ b/docs/extensions/gdpr.md @@ -153,6 +153,8 @@ Check it before choosing your actions. Data stored by an extension is only cover The bundled [Messages](messages.md) extension registers private messages: a member's messages are included in their export, lose their IP addresses when the account is anonymized, and are deleted when it is deleted. Nobody can message an anonymized account. +The bundled [OAuth Provider](oauth-provider.md) extension registers the applications a member connected: they are listed in the export, and every one is disconnected when the account is anonymized or deleted. + ## Scheduled tasks These run daily through the [scheduler](../scheduler.md). You can also run them by hand. diff --git a/docs/extensions/oauth-provider.md b/docs/extensions/oauth-provider.md index c0d229e41..5c8b6b0f6 100644 --- a/docs/extensions/oauth-provider.md +++ b/docs/extensions/oauth-provider.md @@ -39,10 +39,19 @@ The consent screen can't be shown inside another site's frame, so it can't be di ## Managing applications -Go to the extension's page in the admin panel. The **Clients** tab lists every application that can connect, with its client ID, its redirect URIs, and whether it is revoked. Applications that [registered themselves](#applications-that-register-themselves) are labelled **Self-registered** or **Metadata document**. +Go to the extension's page in the admin panel. Applications are listed on two tabs: + +- **Applications**: the ones an administrator added, such as another site that lets members sign in with their account here. +- **Self-registered**: the ones that [registered themselves](#applications-that-register-themselves), such as AI assistants. They are labelled **Self-registered** or **Metadata document**. + +Each application shows its client ID, its redirect URIs, and whether it is revoked. It also shows how many members have connected it and when it was last used. Only administrators can see and manage applications. +### Who connected an application + +Select an application's member count to open the **Users** page filtered to the members who connected it. The same filter works in the Users page search: `connected:` finds those members, and `-connected:` the others. Only administrators can use it. + ### Adding an application Select **Add client** and fill in: @@ -94,11 +103,11 @@ Applications that identify themselves this way: - can't keep a secret, so they must prove each request the way mobile apps do (PKCE); - can only use `https://` redirect URIs, or `http://` ones on the same computer; -- appear on the **Clients** tab, where you can revoke or delete them like any other. +- appear on the **Self-registered** tab, where you can revoke or delete them like any other. Because these applications choose their own name, members see them marked **unverified, self-registered** on the consent screen. The screen also shows which site the member will be sent back to, and for a metadata document, which site describes the application. -To stop applications registering themselves, go to the **Settings** tab and switch off **Allow dynamic client registration**. It is on by default. Applications identified by a metadata document are still accepted. +To stop them, see [Choosing which applications may connect](#choosing-which-applications-may-connect). ### Limits and clean-up @@ -115,15 +124,28 @@ A self-registered application that no member ever authorized is deleted after 30 php flarum oauth:prune-clients ``` +## Choosing which applications may connect + +The **Settings** tab has a switch for each kind of application. Both are on by default. + +- **Allow self-registered applications.** Switched off, no application can register itself or identify itself with a metadata document, and the forum stops advertising either. +- **Allow applications added by an admin.** Switched off, **Add client** is hidden as well. + +While a kind is switched off, its applications can't sign members in, and the access they already have stops working. Nothing is deleted: switch it back on and they work again, with the access members gave them. + ## What members see -Members find the applications they've approved under **Connected applications** on their **Settings** page. For each one it shows: +Members find the applications they've approved under **Connected applications** on their **Security** page, listed like their browser sessions. For each one it shows: - what it can access; - when it was connected; -- when it last used that access. +- when it last used that access, and from which IP address. + +The address is where the application connected from. For an application running on the member's own computer, such as Claude Code, that is the member's address. For one run by a service, such as a connector on claude.ai, it is the service's. -**Revoke** signs the application out at once. It must ask for permission again to come back. +**Revoke** signs the application out at once. It must ask for permission again to come back. With more than one application connected, **Revoke all** does that for every one of them. + +**Global Logout**, further down the same page, also disconnects every application, as well as ending the member's sessions. ## How long access lasts @@ -167,8 +189,26 @@ Never trust the header from every address. Anyone could then choose their own ad ## Permissions -The extension adds no permissions. Only administrators can manage applications. Every signed-in member can approve applications and revoke their own approvals. +| Permission | Default | | +| --- | --- | --- | +| **Connect applications to their account** | Members | Approving applications, and using the ones already connected. | + +Members without it can't approve applications, and the applications they connected stop working until they have it again. This is how suspended members are kept out: while suspended they only have guests' permissions. Members whose email address isn't confirmed don't have it either. + +Only administrators can manage applications. Members can always revoke their own approvals. + +## Audit log + +With [Audit](audit.md) enabled, the log records: + +- a member connecting an application, and signing in to it again with an approval they gave earlier; +- a member disconnecting an application, or all of them; +- an administrator adding, editing or deleting a client, or rotating its secret. + +See [Audit](audit.md#flarum-oauth-provider) for the entries. ## Deleting members Deleting a member deletes their approvals and everything issued for them. + +With [GDPR](gdpr.md) enabled, a member's data export lists the applications they connected. Erasing a member, by deletion or anonymisation, disconnects every one of them. An anonymised member also loses the **Connect applications to their account** permission, as their email address is no longer confirmed.