OAuth
OAuth is how a program authenticates to Perpetual without a browser session, and how Perpetual obtains a token from a third-party provider on a user's behalf.
Perpetual runs its own OAuth 2.0 authorization server. An OAuth client is the
registration a script or an external agent presents to obtain an access token,
and that token authorizes API and MCP requests as the user who owns
the client, inside one organization. A user creates a client
for themselves, or an external client registers itself after reading the server's
discovery metadata. Perpetual is also an OAuth client. In that direction, an
OAuth2AuthorizationCode credential
sends a user through a provider's consent screen and stores the refresh token
that comes back, which is how a connector or
webhook calls Slack or Jira as that
user. The credential owns those fields.
Variants
An OAuth client comes in two variants, fixed at creation and determined by which endpoint creates it.
- Personal. A user creates it for themselves in an organization they belong
to (in the web app, on the organization's "OAuth Clients" settings page).
userIdis the creator andorganizationIdis that organization, so every token the client obtains acts as that user in that one organization. Listing clients returns only the ones you own. - Dynamically registered. An external client registers itself at the
registration endpoint, which takes no authentication. This is how an MCP client
onboards from the discovery metadata.
userIdandorganizationIdare null, and the organization comes from the user who later consents, so such a client can only obtain a token through the authorization-code flow.
Grant types
allowedGrants lists the OAuth grants a client may use, and a client can hold
more than one. The token endpoint rejects a grant the client does not list.
client_credentials. The client posts itsclient_idandclient_secretand receives an access token with no user interaction. The secret must verify. This grant returns no refresh token.authorization_code. The client sends the user to the authorization endpoint, which redirects through login and a consent screen, then exchanges the returned code for an access token. The authorization endpoint requires PKCE and acceptsS256as the only code-challenge method. It also matches anyredirect_urion the request against the client'sredirectUris, ignoring the port.refresh_token. The client exchanges a refresh token for a new access token. Each exchange returns a new refresh token and invalidates the one it consumed. The authorization-code exchange returns a refresh token whether or not the client lists this grant, so a client that listsauthorization_codealone receives a refresh token the token endpoint will not accept.
Access tokens
The token endpoint returns a signed JWT, sent on later requests as
Authorization: Bearer <token>.
- Scoped to one organization. The token's
organizationIdsholds exactly one organization. A personal client's tokens always name the organization the client belongs to. A dynamically registered client's tokens name the organization the user picked at consent. Reaching a second organization takes a second client or a second consent. - Acts as the owner. The token names the user in
sub. Perpetual resolves what the request may do from that user's current roles on each request, so a role change takes effect without reissuing the token. - Never superuser. Every OAuth access token carries
superuser: false. A superuser operation requires a browser session. - Expires in one hour. A refresh token is valid for 30 days.
- Checked by signature and expiry. Perpetual verifies nothing else about the token, and there is no revocation endpoint. Deleting the client leaves an access token it already obtained usable for the rest of that hour.
- Required by the MCP server. Perpetual's MCP server at
/mcpaccepts an OAuth access token only. A browser session reaches the rest of the API and getsOAuth token requiredthere.
Authorization server endpoints
| Endpoint | Purpose |
|---|---|
GET /.well-known/oauth-authorization-server | Discovery metadata: the issuer, the registration, authorization, and token endpoint URLs, the grants Perpetual supports, and S256 as the only code-challenge method. |
GET /.well-known/oauth-protected-resource | Names the authorization server for this resource server. Perpetual is both, so it returns its own URL. A 401 from /mcp points here in its WWW-Authenticate header. |
POST /api/oauth/register | Dynamic client registration. Takes client_name, grant_types, redirect_uris, and the optional logo_uri, client_uri, tos_uri, and policy_uri. Returns client_id and client_secret. Takes no authentication. |
GET /api/oauth/authorize | Starts the authorization-code flow. Redirects to login when the browser has no session, then to the consent screen. |
POST /api/oauth/complete-consent | Records the user's decision and the organization they granted. The consent screen posts this, so an external client never calls it. |
POST /api/oauth/token | Issues an access token for any grant the client lists. Perpetual rate limits it per IP. |
The consent screen shows the client's name and logoUri, and asks the user to
pick one of the organizations they are a member of. The granted scope is
organization:<id>, which becomes the single organizationIds entry on a
dynamically registered client's token.
Third-party OAuth connections
A credential whose credentialType is OAuth2AuthorizationCode holds a token
Perpetual obtained from a third-party provider.
Credentials documents the
credential's fields, including connectionStatus, redirectUri, connectedAt,
and connectionError. Three endpoints drive the connection.
| Endpoint | Purpose |
|---|---|
POST /api/v0/org/{orgId}/credentials/{id}/oauth/start | Returns authorizationUrl, the provider consent URL to send the user to. |
GET /api/v0/org/{orgId}/credentials/oauth/callback | Where the provider redirects with the code. Fixed per organization. This URL is the credential's redirectUri. |
POST /api/v0/org/{orgId}/credentials/{id}/oauth/disconnect | Deletes the stored refresh token and the cached access token, and resets connectionStatus to pending. |
- The same user must finish the flow. The connect state binds the credential, the organization, and the user who started it. Perpetual rejects a callback that a different signed-in user completes, and the state expires 10 minutes after the start call.
- The provider must return a
refresh_token. Perpetual refreshes the access token from it and has no other way to keep the connection alive. A provider that returns only an access token leaves the credential inerror. - The access token is cached separately. Perpetual holds one encrypted access token per credential and refreshes it from the refresh token once it expires. A refresh token the provider rotates replaces the stored one.
- A failed reconnect leaves a working connection alone. When a credential is
already
connectedand a later connect attempt fails,connectionStatusstaysconnectedand the stored token survives, withconnectionErrorset to the failure.
Inputs
The fields the caller provides when creating or updating an OAuth client.
| Field | Type | Required | Description |
|---|---|---|---|
name | string | Required | Client name. The consent screen shows it to the user. |
allowedGrants | array<enum> | Optional | The grants this client may use, from client_credentials, authorization_code, and refresh_token. Defaults to client_credentials. See Grant types. |
redirectUris | array<string> | Optional | The URLs the authorization endpoint may redirect to. A redirect_uri on an authorization request must match one of these. |
The API accepts an update to all three fields. The web app only renames an existing client (its dialog states that grants and redirect URIs are fixed at creation).
Outputs
| Field | Type | Description |
|---|---|---|
secret | string | The generated client secret. Perpetual returns it once, in the create or register response, and never again, because it stores only a hash. There is no rotation, so replacing a lost secret means deleting the client and creating another. |
userId | string | The user who owns the client, prefixed USR_. Null for a dynamically registered client. |
organizationId | string | The organization a personal client's tokens are scoped to, prefixed ORG_. Null for a dynamically registered client, whose token organization comes from consent. |
dataPlaneId | string | The data plane the client identifies, prefixed DPL_. Null for a client that identifies a user. |
logoUri | string | The logo the consent screen shows next to the client's name. A dynamically registered client supplies it as logo_uri. |
clientUri | string | The client's home page, supplied at registration as client_uri. |
tosUri | string | The client's terms of service, supplied at registration as tos_uri. |
policyUri | string | The client's privacy policy, supplied at registration as policy_uri. |
createdAt | timestamp | When the client was created. |
updatedAt | timestamp | When the client was last modified. |
An OAuth client is not a versioned resource. Its id is the OAuth
client_id, a 32-character hex string with no resource prefix, and beyond id,
name, createdAt, and updatedAt it carries none of the common resource
fields.
Related
- Credentials, which hold the third-party OAuth connection and its fields.
- User, the identity a personal client's tokens act as.
- Organizations, the single tenant a token is scoped to.
- Roles, which decide what a token may do.
- Resource, the common field envelope an OAuth client does not carry.