OAuth server
Issue access tokens to third-party apps with OAuth 2.1 and OpenID Connect: authorization code with PKCE, client credentials, device code and refresh grants, scopes mapped to permissions, consent and personal access tokens.
On this page
Introduction
Featureoauth Your API already signs in your own front end with Navree auth. navree-oauth lets other software act on your users' behalf: a mobile app, a Zapier integration, a partner's dashboard, a command-line tool. It turns your app into an OAuth 2.1 authorization server and OpenID Connect provider, and it issues personal access tokens for scripts.
What you get:
- Grants: authorization code with PKCE (S256 required), client credentials, the device authorization grant for TVs and CLIs, and refresh tokens with rotation and reuse detection.
- Scopes that map to permissions. A scope such as
deals:readgrants a set of your permission strings. A token's abilities are the user's abilities narrowed to its scopes, so every#[can],#[permissions]andAccessiblecheck you already have applies to OAuth tokens unchanged. - Consent, shown by your own front end, remembered per user and client.
- OpenID Connect:
openid,profileandemailscopes, ID tokens signed with Ed25519,/oauth/userinfo, discovery and a JWKS endpoint. - Personal access tokens that users create in their settings.
- Revocation and introspection (RFC 7009 and RFC 7662), so other services can check a token, and optional dynamic client registration (RFC 7591).
Installation
Turn on the oauth feature of the navree facade. It is opt-in: the default full feature doesn't include it.
[dependencies]
navree = { git = "https://github.com/NavreeHQ/navree", features = ["oauth"] }Add OAuthServerModule after AuthModule. It needs the database and the crypto module, and AccessModule to turn scopes into abilities:
Navree::app()
.module(ConfigModule::for_root(sources))
.module(CryptoModule::from_config())
.module(HttpModule::from_config())
.module(DatabaseModule::from_config())
.module(AuthModule::from_config())
.module(AccessModule::for_root(access_options()))
.module(OAuthServerModule::from_config()) // section `oauth`
// ...The module contributes the migration set oauth (after auth), which creates oauth_clients, oauth_authorizations, oauth_authorization_requests, oauth_codes, oauth_access_tokens, oauth_refresh_tokens, oauth_device_codes and personal_access_tokens. It also registers an authentication strategy, oauth, that runs after the JWT strategy. OAuthServerModule and OAuthToken are in the prelude with the feature; everything else is under navree::oauth.
Configuration
Define your scopes and say where your front end shows the consent and device pages:
[oauth]
issuer = "https://api.acme.test" # defaults to app.url
consent_url = "https://app.acme.test/oauth/consent"
device_url = "https://app.acme.test/device"
[oauth.scopes."deals:read"]
description = "Read your deals"
permissions = ["deals.read"]
[oauth.scopes."deals:write"]
description = "Create, update and move your deals"
permissions = ["deals.read", "deals.create", "deals.update", "deals.move"]
[oauth.scopes."contacts:read"]
description = "Read your contacts and companies"
permissions = ["contacts.read", "companies.read"]openid, profile, email and offline_access are built in, grant no permissions and can't be redefined. A scope's permissions use the same strings as roles and permissions: deals.read, deals.*, or * for everything the user may do.
Every key comes from the environment with the APP_ prefix: APP_OAUTH__CONSENT_URL, APP_OAUTH__ACCESS_TOKEN_TTL_SECS=1800. OAuthServerModule::for_root(OAuthOptions { .. }) takes the options in code, with .scope(name, ScopeOptions::new(description, permissions)) to add scopes. A scope without a description, a permission string that doesn't parse, a consent_url that isn't absolute or a signing key that doesn't parse fails the boot. Without oauth.issuer and app.url, the issuer is http://localhost and the boot logs a warning.
Signing keys
Access and refresh tokens are opaque strings, stored as SHA-256 digests. Only ID tokens are signed, with Ed25519 (EdDSA), so any OpenID Connect client can verify them with the public key at /oauth/jwks. By default the key is derived from the application key (APP_KEY), like navree-auth's JWT key. To manage it on its own, generate one:
navree oauth:keysAPP_OAUTH__SIGNING_KEY=ed25519:MC4CAQAwBQYDK2VwBCIEIGq4m...Put the line in .env. The value is a base64 PKCS#8 Ed25519 key (a raw 32-byte seed after ed25519: works too). When you rotate it, move the old value to oauth.previous_signing_keys for a day, so ID tokens issued before the rotation still verify. Each key's kid is its RFC 7638 thumbprint.
Clients
A client is an app that asks for tokens. Create one with navree oauth:client:
navree oauth:client "Zapier" \
--redirect-uri https://zapier.com/dashboard/auth/oauth/return/AcmeCRMCLIAPI/ \
--scopes deals:read,deals:write,contacts:readCreated confidential client "Zapier".
client_id: 0199c1d2-6f7e-7a31-b5d4-2f0c8e9a1b23
client_secret: nvcs_Kq3Zt0x8B2mWcR5yP1hD7fJ9sL4vN6gE0aU2iO8
Store the secret now: it is shown once.| Option | Meaning |
|---|---|
--redirect-uri <uri> | An allowed redirect URI; repeat it for several. https, http on 127.0.0.1, [::1] or localhost, or a reverse-domain scheme for native apps (com.acme.crm:/callback). Matched exactly, except the port of http://127.0.0.1 and http://[::1]. |
--scopes a,b | The scopes the client may ask for |
--public | A public client (mobile, single-page or desktop app): no secret, PKCE only |
--grant client_credentials | A machine-to-machine client; needs --owner and isn't public |
--owner <email> | The user a client credentials client acts as |
--device | Allows the device authorization grant |
--trusted | A first-party client: no consent screen |
--workspace <slug> | Limits the client's tokens to one workspace |
--introspect | Lets the client call POST /oauth/introspect, for your own resource servers |
--logo-url <url>, --website <url> | Shown on the consent page |
A client with redirect URIs uses the authorization code grant; --grant, --device and --introspect alone make clients that don't (a client credentials client, a CLI that only uses the device grant, a resource server that only introspects).
oauth:clients lists clients, oauth:client:secret <id> rotates a secret (the old one stops working at once) and oauth:client:revoke <id> revokes a client and every token it holds. The OAuthClients service does the same from code (create, find, list, owned_by, rotate_secret, revoke), which is how you build a developer portal where users register their own apps:
use navree::oauth::{NewClient, OAuthClients};
let created = clients
.create(
&uow,
NewClient::confidential("Ada's reporting script")
.owner(user.id)
.redirect_uri("https://reports.lovelace.io/callback")
.scopes(["deals:read"]),
)
.await?;
// created.client.id, and created.secret (shown once)NewClient::public(name) makes a public client; .client_credentials(), .device(), .trusted(), .introspect(), .workspace(id), .logo_url(..) and .website(..) match the command's options. An invalid client is an OAuthError::Invalid naming the field, which answers 400 with errors.<field> when returned from a handler.
The authorization code flow
This is the flow for any app that acts for a signed-in user. Navree is API-first, so the consent screen is a page of your front end, and the API does the protocol:
- The client sends the browser to
GET /oauth/authorizewithresponse_type=code,client_id,redirect_uri,scope,stateand a PKCEcode_challenge(code_challenge_method=S256), and withnoncefor OpenID Connect. - Navree checks the request and redirects the browser to
oauth.consent_url?request=<id>. A request that can't be checked (unknown client, aredirect_urithat isn't registered) gets a 400 page and never redirects. - Your consent page signs the user in if needed, then reads the request with the user's token:
GET /api/oauth/requests/{id}. - The user approves or denies. Your page calls
POST /api/oauth/requests/{id}/approve(or/deny) and sends the browser to theredirect_toit gets back. - The client exchanges the code at
POST /oauth/tokenwith itscode_verifier, and gets an access token, a refresh token (withoffline_access) and, withopenid, an ID token.
Without scope, the request asks for every scope the client may use; without redirect_uri, the client must have exactly one. The consent page needs two calls. The request:
GET /api/oauth/requests/2f6e1c9a-8d4b-4f7e-9a3c-5b1d2e4f6a70 HTTP/1.1
Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9...{
"id": "2f6e1c9a-8d4b-4f7e-9a3c-5b1d2e4f6a70",
"client": { "id": "0199c1d2-...", "name": "Zapier", "logo_url": null, "website": "https://zapier.com" },
"scopes": [
{ "name": "deals:read", "description": "Read your deals" },
{ "name": "contacts:read", "description": "Read your contacts and companies" }
],
"already_approved": false,
"expires_at": "2026-10-26T09:14:00Z"
}And the decision:
POST /api/oauth/requests/2f6e1c9a-8d4b-4f7e-9a3c-5b1d2e4f6a70/approve HTTP/1.1
Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9...{ "redirect_to": "https://zapier.com/dashboard/auth/oauth/return/AcmeCRMCLIAPI/?code=nvc_8fQ2...&state=af0ifjsldkj&iss=https%3A%2F%2Fapi.acme.test" }The redirect carries iss (RFC 9207), so clients can tell authorization servers apart. A denial redirects with error=access_denied and the state. An unknown or already decided request is a 404, an expired one a 410. These routes need a first-party sign-in: a request made with an OAuth token or a personal access token gets a 403, so a token can never approve more access for itself.
An approval is remembered in oauth_authorizations. When the same client later asks for scopes the user already approved, already_approved is true and your page can approve at once. Trusted clients (--trusted) skip the page: GET /oauth/authorize sends the browser straight to the consent page with already_approved: true, which approves without showing anything. Authorization requests expire after oauth.request_ttl_secs (10 minutes), codes after 60 seconds, and a code works once: presenting it again revokes every token issued from it.
Exchanging the code
POST /oauth/token HTTP/1.1
Content-Type: application/x-www-form-urlencoded
Authorization: Basic MDE5OWMxZDItNmY3ZS03YTMxLWI1ZDQtMmYwYzhlOWExYjIzOm52Y3NfS3EzWnQweDhCMm1XY1I1eVAxaEQ3Zko5c0w0dk42Z0UwYVUyaU84
grant_type=authorization_code&code=nvc_8fQ2...&redirect_uri=https%3A%2F%2Fzapier.com%2Fdashboard%2Fauth%2Foauth%2Freturn%2FAcmeCRMCLIAPI%2F&code_verifier=dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk{
"access_token": "nva_3tY8pW1qZ6xC9vB2nM5kL0jH7gF4dS1aQ8wE3rT6",
"token_type": "Bearer",
"expires_in": 3600,
"refresh_token": "nvr_Hn2Kd8Lw4Qp0Zx6Cv1Bm9Nj3Fg7Ts5Ry2Ue8Io4P",
"scope": "deals:read contacts:read offline_access"
}Confidential clients authenticate with HTTP Basic (client_secret_basic) or client_id and client_secret in the body (client_secret_post), not both. Public clients send only client_id; PKCE is what protects them. Token responses carry Cache-Control: no-store.
Calling your API with an OAuth token
The client sends the access token as a bearer token. Navree's oauth strategy recognizes it by its nva_ prefix (the JWT strategy leaves it alone), looks up its digest and authenticates the request as the user who approved it:
CurrentUserandCurrentPrincipalgive that user, and the unit of work's actor and audit entries name them.Principal::token_abilitiesholds the permissions of the token's scopes, so the user's abilities are narrowed to them. A token withdeals:readfrom a sales rep reads what that rep can read, and a#[can(Update, Deal)]route answers 403. A scope never adds to what the user may do.X-Workspaceand the other workspace resolvers work as for a first-party request. A client created with--workspaceis pinned to that workspace: a request that names no workspace gets that one, and a request that names another gets a 403.
When a handler needs to know more about the token, take OAuthToken:
use navree::oauth::OAuthToken;
#[post("/export")]
#[can(Export, Deal)]
async fn export(&self, token: Option<OAuthToken>, CurrentUser(user): CurrentUser) -> Result<NoContent> {
if let Some(token) = &token {
token.require_scope("deals:export")?; // 403 insufficient_scope
tracing::info!(client = %token.client_name, "export requested through OAuth");
}
// ...
Ok(NoContent)
}OAuthToken has token_id, client_id (None for a personal access token), client_name (the personal access token's name for those), scopes, grant (a Grant: AuthorizationCode, ClientCredentials, DeviceCode, RefreshToken or PersonalAccessToken), user_id, workspace_id and expires_at, and the methods has_scope, require_scope and is_personal. Option<OAuthToken> is None for requests that didn't use an OAuth token or a personal access token; a plain OAuthToken argument answers 401 for them.
| Request | Response |
|---|---|
| An unknown, expired or revoked token | 401 Invalid token, with WWW-Authenticate: Bearer error="invalid_token" |
| A valid token without the needed permission | 403 Forbidden, with WWW-Authenticate: Bearer error="insufficient_scope", scope="deals:write" |
The scope of that challenge lists the scopes that would allow the route. When the token's scopes allow it and the user's own permissions don't, the 403 has no challenge: another token wouldn't help.
Token lookups are cached for oauth.token_cache_secs (10 seconds) on each instance, so a revoked token stops working everywhere within that time, and at once on the instance that revoked it. 0 turns the cache off.
The other grants
Client credentials
A machine-to-machine client, such as a nightly sync from your data warehouse, has no user to ask. It authenticates with its own secret:
POST /oauth/token HTTP/1.1
Content-Type: application/x-www-form-urlencoded
Authorization: Basic MDE5OWMxZTUtMmQ0YS03YjEwLThlNmYtM2M1YTdiOWQxZTIwOm52Y3NfV2Q1SHEwTHg3Qm4yTWM5UnY0VGsxUHo4
grant_type=client_credentials&scope=deals:readThe token acts as the client's owner (--owner when you create it), narrowed to the requested scopes (all the client's scopes when scope is absent); OAuthToken::grant is Grant::ClientCredentials. Give integrations a dedicated user with only the roles they need, and they show up under that name in the audit log. Client credentials tokens have no refresh token and no ID token: ask for a new one when it expires.
Device authorization
For a CLI or a TV that can't open a browser on the same device:
POST /oauth/device_authorization HTTP/1.1
Content-Type: application/x-www-form-urlencoded
client_id=0199c1e9-...&scope=deals:read offline_access{
"device_code": "nvd_Gm4Rt8Yx2Wq6...",
"user_code": "WDJB-MJHT",
"verification_uri": "https://app.acme.test/device",
"verification_uri_complete": "https://app.acme.test/device?user_code=WDJB-MJHT",
"expires_in": 600,
"interval": 5
}The device shows the code; the user opens verification_uri on their phone, signs in, and your page calls GET /api/oauth/device/{user_code} (the client and scopes, for the consent) and POST /api/oauth/device/{user_code}/approve (or /deny), which answer 204. User codes are case-insensitive and the dash is optional; these routes allow 20 calls a minute per user. Meanwhile the device polls POST /oauth/token with grant_type=urn:ietf:params:oauth:grant-type:device_code and gets authorization_pending until the user approves, slow_down if it polls faster than interval (which then grows by 5 seconds), access_denied after a denial, expired_token once the code expires, and then the tokens, once. Without oauth.device_url the endpoint answers 503 temporarily_unavailable.
Refresh tokens
Ask for offline_access to get a refresh token. It works like navree-auth's: valid for oauth.refresh_token_ttl_secs (30 days, sliding), single use, and rotated on every refresh. A refresh token that comes back after it was used revokes its whole family, with the access tokens issued from it, and writes an oauth.refresh_reuse_detected audit entry.
POST /oauth/token HTTP/1.1
Content-Type: application/x-www-form-urlencoded
grant_type=refresh_token&refresh_token=nvr_Hn2Kd8Lw4Qp0...&client_id=0199c1d2-...A refresh may ask for fewer scopes than the original grant, never more. The new refresh token keeps the original grant, so a later refresh can ask for the other scopes again.
OpenID Connect
With the openid scope, the token response includes an id_token, an EdDSA-signed JWT with iss, sub (the user's id), aud (the client id), exp (when the access token expires), iat, nonce when the request sent one, and the claims of the other scopes: name with profile, email and email_verified with email. GET /oauth/userinfo (or POST) returns the same claims for an access token with openid; without it the answer is 403 insufficient_scope.
Discovery documents let client libraries configure themselves from your issuer URL:
| Endpoint | |
|---|---|
GET /.well-known/openid-configuration | OpenID Connect discovery |
GET /.well-known/oauth-authorization-server | OAuth 2.0 authorization server metadata (RFC 8414) |
GET /oauth/jwks | The public keys that verify ID tokens |
Both documents carry the same metadata: every endpoint, the supported scopes, grants (authorization_code, client_credentials, refresh_token, the device code grant), code_challenge_methods_supported: ["S256"], the client authentication methods and id_token_signing_alg_values_supported: ["EdDSA"].
Personal access tokens
Personal access tokens let users call the API from scripts without an OAuth client. A user creates one in their settings, picks its scopes and copies it once:
POST /api/oauth/personal-access-tokens HTTP/1.1
Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9...
Content-Type: application/json
{ "name": "Weekly pipeline report", "scopes": ["deals:read"], "expires_in_days": 90 }{
"id": "0199c1f0-3b2a-7d41-9c8e-1a2b3c4d5e6f",
"name": "Weekly pipeline report",
"scopes": ["deals:read"],
"token": "nvp_Vx7Lq2Ws9Ek4Rd1Tf6Yg3Uh8Ij5Ok0Pl2Az7Sx4C",
"expires_at": "2027-01-24T08:00:00Z",
"last_used_at": null,
"created_at": "2026-10-26T08:00:00Z"
}token appears in this response only. GET /api/oauth/personal-access-tokens lists the user's tokens without it ({ "data": [...] }, with last_used_at updated at most once a minute), and DELETE /api/oauth/personal-access-tokens/{id} revokes one. A personal access token authenticates exactly like an OAuth access token, as its user, narrowed to its scopes; its scopes are the app's (oauth.scopes), not the built-in OpenID ones. oauth.personal_access_tokens.max_days caps the lifetime (default 365; 0 allows tokens that never expire), and a token created without expires_in_days gets the longest lifetime allowed. Like the consent routes, these routes need a first-party sign-in; a user may create 10 tokens a minute, and oauth.personal_access_tokens.enabled = false unmounts the routes.
From code, PersonalAccessTokens creates tokens for seeders, CLIs and tests (and has list and revoke):
use navree::oauth::PersonalAccessTokens;
let thirty_days = Duration::from_secs(30 * 86_400);
let created = pats
.create(&uow, user.id, "CI deploy check", ["deals:read"], Some(thirty_days))
.await?;
println!("{}", created.token); // nvp_...Managing authorizations
Users see the apps they authorized at GET /api/oauth/authorizations ({ "data": [{ "client", "scopes", "created_at", "updated_at" }] }) and revoke one with DELETE /api/oauth/authorizations/{client_id}, which forgets the approval and revokes every token that client holds for them. Clients revoke their own tokens at POST /oauth/revoke (RFC 7009; revoking a refresh token also revokes the access tokens of its grant, and the answer is 200 even for unknown tokens), and resource servers, such as your other microservices, check a token at POST /oauth/introspect (RFC 7662), authenticating as a confidential client created with --introspect. Introspection answers { "active": false } for anything that isn't a live token, and otherwise active, scope, sub, username (the email), client_id, aud, iss, iat, exp and token_type, for access tokens, refresh tokens and personal access tokens.
Dynamic client registration
With oauth.dynamic_registration = true, clients register themselves at POST /oauth/register (RFC 7591), and the discovery documents list registration_endpoint. MCP clients rely on it.
POST /oauth/register HTTP/1.1
Content-Type: application/json
{ "client_name": "Claude", "redirect_uris": ["https://claude.ai/api/mcp/auth_callback"], "token_endpoint_auth_method": "none" }The answer is 201 with client_id (and client_secret with client_secret_expires_at: 0 unless token_endpoint_auth_method is none), client_name, redirect_uris, grant_types, token_endpoint_auth_method and scope. A registered client may ask for the scopes it names in scope (every scope when absent), uses the authorization code grant and may add the device grant; it is never trusted, never introspects and never uses client credentials. Invalid metadata answers 400 invalid_client_metadata or invalid_redirect_uri. Registration is off by default; turn it on only when you want any app to be able to ask your users for access.
Errors
The protocol endpoints (/oauth/token, /oauth/revoke, /oauth/introspect, /oauth/device_authorization and /oauth/register) answer errors in the OAuth format, because OAuth client libraries read it:
{ "error": "invalid_grant", "error_description": "The authorization code has expired" }Client authentication failures are 401 invalid_client (with WWW-Authenticate: Basic when the client used HTTP Basic), a missing flow page is 503 temporarily_unavailable, and everything else is a 400. GET /oauth/authorize reports errors to the client by redirecting to its redirect_uri with error, error_description, state and iss when the client and redirect URI are valid. Every /api/oauth/* route uses Navree's own error shape, { "statusCode", "message", "error", "code" }, and OAuthError returned from your own handlers renders in it too.
Audit entries
| Action | When |
|---|---|
oauth.client_created, oauth.client_revoked | A client was created or revoked |
oauth.authorized, oauth.denied | A user approved or denied a client; metadata.scopes, metadata.client_id, metadata.grant |
oauth.revoked | A user revoked a client's access |
oauth.refresh_reuse_detected | A used refresh token came back; its family was revoked |
oauth.pat_created, oauth.pat_revoked | A personal access token was created or revoked |
The entries go to the dyn AuditSink (AuditModule) when the app has one. Requests made with OAuth tokens are audited like any other request, under the token's user.
Testing
Personal access tokens are the shortest way to call a route with a scoped token:
use navree::db::Db;
use navree::oauth::PersonalAccessTokens;
use navree::testing::TestApp;
use serde_json::json;
#[tokio::test]
async fn a_read_only_token_cannot_change_deals() {
let app = TestApp::from_builder(my_app::app(test_sources())).await;
let rep = seeded_rep(&app).await; // a user with deals.read and deals.create
let db = app.get::<Db>().await;
let uow = db.uow(None, None).await;
let created = app
.get::<PersonalAccessTokens>()
.await
.create(&uow, rep, "test", ["deals:read"], None)
.await
.unwrap();
uow.commit().await.unwrap();
app.client()
.get("/api/v1/deals")
.bearer(&created.token)
.send()
.await
.assert_status(200);
app.client()
.post("/api/v1/deals")
.bearer(&created.token)
.json(&json!({ "title": "Lovelace renewal" }))
.send()
.await
.assert_error(403, None);
}To test the whole authorization code flow, navree::oauth::testing has pkce_pair(), which returns a verifier and its S256 challenge, and authorize(&client, client_id, user_token, scopes), which walks the authorize, approve and token steps with a TestClient for a client with one redirect URI and returns the TokenResponse. AuthorizeFlow::new(client_id) runs the same flow with options: .secret(..) for a confidential client, .redirect_uri(..), .nonce(..), and .oauth_path(..) and .api_prefix(..) for apps that moved them. Override dyn Clock with a FixedClock to test expiry.
Reference
| Key | Default | Meaning |
|---|---|---|
oauth.issuer | app.url | The iss of ID tokens and the base of discovery URLs |
oauth.path | /oauth | Where the protocol endpoints are mounted (outside http.api_prefix) |
oauth.consent_url | none | Your front end's consent page; required for the authorization code flow |
oauth.device_url | none | Your front end's device page; required for the device grant |
oauth.scopes.<name>.description | none | Shown on the consent page |
oauth.scopes.<name>.permissions | [] | The permissions the scope grants |
oauth.access_token_ttl_secs | 3600 | Access token (and ID token) lifetime |
oauth.refresh_token_ttl_secs | 2592000 | Refresh token lifetime (30 days, sliding) |
oauth.request_ttl_secs | 600 | Authorization request and device code lifetime |
oauth.code_ttl_secs | 60 | Authorization code lifetime |
oauth.token_cache_secs | 10 | Token lookup cache per instance; 0 = off |
oauth.signing_key | derived from app.key | The Ed25519 key of ID tokens |
oauth.previous_signing_keys | [] | Older keys still published in the JWKS |
oauth.personal_access_tokens.enabled | true | Mounts the personal access token routes |
oauth.personal_access_tokens.max_days | 365 | Longest lifetime; 0 = no limit |
oauth.dynamic_registration | false | Mounts POST {oauth.path}/register (RFC 7591) |
| Command | What it does |
|---|---|
oauth:client <name> [options] | Creates a client and prints its id and secret |
oauth:clients | Lists clients |
oauth:client:secret <id> | Rotates a confidential client's secret |
oauth:client:revoke <id> | Revokes a client and its tokens |
oauth:keys | Generates an Ed25519 signing key |
oauth:prune [--days N] | Deletes expired requests, codes and device codes, and expired or revoked tokens, older than N days (default 7) |
| Route | |
|---|---|
GET /oauth/authorize | Starts the authorization code flow |
POST /oauth/token | The token endpoint |
POST /oauth/device_authorization | Starts the device flow |
POST /oauth/revoke, POST /oauth/introspect | Revocation and introspection |
GET, POST /oauth/userinfo | OpenID Connect claims |
GET /oauth/jwks | The signing keys |
POST /oauth/register | Dynamic client registration (when on) |
GET /api/oauth/requests/{id}, POST .../approve, POST .../deny | The consent page |
GET /api/oauth/device/{user_code}, POST .../approve, POST .../deny | The device page |
GET, POST /api/oauth/personal-access-tokens, DELETE .../{id} | Personal access tokens |
GET /api/oauth/authorizations, DELETE .../{client_id} | Authorized apps |