Skip to main content

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). userId is the creator and organizationId is 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. userId and organizationId are 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 its client_id and client_secret and 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 accepts S256 as the only code-challenge method. It also matches any redirect_uri on the request against the client's redirectUris, 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 lists authorization_code alone 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 organizationIds holds 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 /mcp accepts an OAuth access token only. A browser session reaches the rest of the API and gets OAuth token required there.

Authorization server endpoints​

EndpointPurpose
GET /.well-known/oauth-authorization-serverDiscovery 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-resourceNames 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/registerDynamic 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/authorizeStarts the authorization-code flow. Redirects to login when the browser has no session, then to the consent screen.
POST /api/oauth/complete-consentRecords the user's decision and the organization they granted. The consent screen posts this, so an external client never calls it.
POST /api/oauth/tokenIssues 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.

EndpointPurpose
POST /api/v0/org/{orgId}/credentials/{id}/oauth/startReturns authorizationUrl, the provider consent URL to send the user to.
GET /api/v0/org/{orgId}/credentials/oauth/callbackWhere the provider redirects with the code. Fixed per organization. This URL is the credential's redirectUri.
POST /api/v0/org/{orgId}/credentials/{id}/oauth/disconnectDeletes 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 in error.
  • 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 connected and a later connect attempt fails, connectionStatus stays connected and the stored token survives, with connectionError set to the failure.

Inputs​

The fields the caller provides when creating or updating an OAuth client.

FieldTypeRequiredDescription
namestringRequiredClient name. The consent screen shows it to the user.
allowedGrantsarray<enum>OptionalThe grants this client may use, from client_credentials, authorization_code, and refresh_token. Defaults to client_credentials. See Grant types.
redirectUrisarray<string>OptionalThe 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​

FieldTypeDescription
secretstringThe 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.
userIdstringThe user who owns the client, prefixed USR_. Null for a dynamically registered client.
organizationIdstringThe organization a personal client's tokens are scoped to, prefixed ORG_. Null for a dynamically registered client, whose token organization comes from consent.
dataPlaneIdstringThe data plane the client identifies, prefixed DPL_. Null for a client that identifies a user.
logoUristringThe logo the consent screen shows next to the client's name. A dynamically registered client supplies it as logo_uri.
clientUristringThe client's home page, supplied at registration as client_uri.
tosUristringThe client's terms of service, supplied at registration as tos_uri.
policyUristringThe client's privacy policy, supplied at registration as policy_uri.
createdAttimestampWhen the client was created.
updatedAttimestampWhen 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.

  • 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.