Skip to content

OAuth applications

Every GuestCraft instance is an OAuth 2.1 authorization server. A third-party application, or an MCP client such as an AI assistant, registers itself, sends a member of a workspace through the authorization code flow with PKCE, and receives tokens that act for that member in the one workspace they chose, with the permissions and properties they allowed. This guide is the contract of that flow for application developers and operators. API keys, organization tokens and client sessions, the credentials of integrations’ own servers, are described in API keys and webhooks.

Status: authorization code with PKCE, dynamic client registration, consent, refresh, revocation, introspection and the workspace’s list of authorized applications work on every instance. OpenID Connect (ID tokens, openid, UserInfo), the client credentials and device grants, Client ID Metadata Documents, private_key_jwt and DPoP are not offered.

Both documents are served at the instance’s public origin, to any origin (CORS *, without credentials), and may be cached for five minutes. The examples use https://hotel.example.com.

Document Address
Authorization server metadata (RFC 8414) /.well-known/oauth-authorization-server/api/auth
The API as a protected resource (RFC 9728) /.well-known/oauth-protected-resource/api/v1
The MCP server as a protected resource (RFC 9728) /.well-known/oauth-protected-resource/api/mcp

The issuer is https://hotel.example.com/api/auth, and authorization responses carry it as iss (RFC 9207). The metadata names the real endpoints below, S256 as the only PKCE method, the authorization_code and refresh_token grants, and none, client_secret_basic and client_secret_post client authentication.

Endpoint Path
Authorization GET /api/auth/oauth2/authorize
Token POST /api/auth/oauth2/token
Registration (RFC 7591) POST /api/auth/oauth2/register
Revocation (RFC 7009) POST /api/auth/oauth2/revoke
Introspection (RFC 7662) POST /api/auth/oauth2/introspect

The token, registration, revocation and introspection endpoints answer CORS preflights and can be read from any origin, errors included, for browser-based applications; they never accept cookie credentials across origins.

An instance has two protected resources, named from its configured GUESTCRAFT_BASE_URL, never from a request’s Host or forwarded headers:

  • https://hotel.example.com/api/v1, the HTTP API;
  • https://hotel.example.com/api/mcp, the MCP server built into the instance.

Every registered application may ask for both. Send the resource you will call as resource (RFC 8707) on the authorization request and on the token request. A token is accepted only at the resource it was issued for: an API token at paths below /api/v1, an MCP token at /api/mcp. A token issued without a resource is accepted nowhere. A refused token answers 401 and is audited as oauth_application.denied with the reason resource.

Access tokens (gc_oat_…) are opaque, valid for an hour, and looked up on every request, so a revocation or a member’s lost access applies to the very next request on every instance. Send them as Authorization: Bearer gc_oat_…; a request with one is never authenticated by a cookie and never reaches a browser-only operation (team, keys, webhooks, Channex administration, authorized applications). Requests are admitted like an API key’s (see the apiKey security scheme of the OpenAPI document), in a share of the instance’s remembered keys of their own: at most 100 access tokens of one workspace used within a minute, apart from its API keys, so that its members’ applications never take the places of its integrations, and within the organization’s 1,000 places. Refresh tokens (gc_ort_…) are issued only with the offline_access scope and last 30 days. The instance stores only the SHA-256 of every token.

The scopes are offline_access and every permission an API key may hold, such as reservations.read or folios.post (the list is scopes_supported in the metadata). Browser-only permissions are never scopes. An authorization request without scope asks for every scope.

What a token may do on a request is the intersection of:

  1. the permissions the member allowed on the consent page (the grant),
  2. the token’s own scopes (a refresh may ask for fewer, never more),
  3. what the member holds in that workspace at that moment, and the properties they reach.

A member who no longer reaches the workspace, or who holds nothing of the grant any more, makes the token answer 401 (audited as oauth_application.denied, reason creator_access); a permission the grant lacks answers 403. A grant limited to some properties whose properties the member no longer reaches reaches nothing, never every property. To add permissions, send the member through the authorization again: a new consent replaces the grant’s permissions and properties, and tokens issued before keep their own, narrower scopes.

The OpenAPI document names the permissions of each operation a token may call in x-guestcraft-permissions; a call needs one of them at least, and the operation’s description says what a particular call needs besides. The built-in MCP server checks them before a tool runs: a token whose scopes and grant name none answers 403 with WWW-Authenticate: Bearer error="insufficient_scope", scope="…", the scopes to ask for in the next authorization (MCP). The REST API answers that case, like every other permission it refuses, with 403 auth.forbidden.

  1. Register (once). For a native or desktop client with a loopback callback:

    POST /api/auth/oauth2/register
    Content-Type: application/json
    {
    "client_name": "Channel Assistant",
    "redirect_uris": ["http://127.0.0.1:8123/callback"],
    "token_endpoint_auth_method": "none",
    "grant_types": ["authorization_code", "refresh_token"],
    "response_types": ["code"]
    }

    The answer (201) holds the client_id, and a one-time client_secret for a confidential client (client_secret_basic or client_secret_post).

  2. Authorize. Create a random code_verifier and send the member’s browser to:

    https://hotel.example.com/api/auth/oauth2/authorize?response_type=code
    &client_id=…&redirect_uri=http%3A%2F%2F127.0.0.1%3A8123%2Fcallback
    &scope=reservations.read%20properties.read%20offline_access
    &state=…&code_challenge=…&code_challenge_method=S256
    &resource=https%3A%2F%2Fhotel.example.com%2Fapi%2Fv1

    PKCE with S256 is required for every client. The redirect URI must be one the client registered, exactly; only the port of a registered http loopback URI (127.0.0.1, [::1], localhost) may differ. A request without PKCE is answered at the redirect URI with invalid_request; an unregistered redirect URI is never redirected to.

  3. Sign-in and consent. A member without a session signs in first, with a password or passkey or with their organization’s single sign-on (enterprise identity). Single sign-on leaves for the identity provider, so the instance verifies the authorization request’s signature before it starts and returns the browser to the request at /api/auth/oauth2/authorize afterwards, verifying it again; an address it returns to is built on the server from the verified request only. A request with prompt=login continues only with a session that signed in, with every factor the person’s organizations require, after the request was signed; with an older session, or none, the instance asks for a sign-in again. A request whose signature expired meanwhile sends the browser to the sign-in page with error=OAUTH_REQUEST_EXPIRED, which says to start again from the application. Then the consent page shows the name the application registered (as unverified text: anyone may register any name), its client ID, and where the browser goes back to. The member chooses one workspace they reach, all their properties or some of them, and the permissions to allow among those the application asked for that they hold there, all ticked at first. Allow returns the browser to the redirect URI with code, state and iss; Deny returns error=access_denied. Every authorization shows the consent page again: no earlier consent is reused silently.

  4. Exchange the code (within ten minutes, once):

    POST /api/auth/oauth2/token
    Content-Type: application/x-www-form-urlencoded
    grant_type=authorization_code&code=…&code_verifier=…&client_id=…
    &redirect_uri=http%3A%2F%2F127.0.0.1%3A8123%2Fcallback
    &resource=https%3A%2F%2Fhotel.example.com%2Fapi%2Fv1

    The answer holds access_token, token_type Bearer, expires_in, scope and, with offline_access, a refresh_token.

  5. Call the API with Authorization: Bearer gc_oat_…, in the workspace the member chose.

  • Refresh with grant_type=refresh_token. Each refresh rotates the refresh token: the old one stops working. Presenting a rotated refresh token again ends the whole family of that application’s refresh tokens for that member, without issuing anything (RFC 9700, section 4.14). A refresh under a revoked authorization, or one whose member holds nothing of it any more, answers invalid_grant. Token requests are limited to 20 a minute per client address (429 beyond), before the instance looks any refresh token up.
  • Revoke a token at the revocation endpoint with token and the client’s authentication (just client_id for a public client). Revoking an access token ends it at once; revoking a refresh token also ends the access tokens it issued.
  • Introspect with a confidential client’s credentials; a client introspects only its own tokens. A token whose authorization was revoked, or whose member holds nothing of it any more, answers { "active": false }.

Settings → Authorized applications lists the applications every member authorized in the workspace, with the registered name and home page as unverified text, who authorized it, what it may do, when it was authorized and last used, and how much of it the member still holds. Revoking one (after a confirmation) ends its access and refresh tokens in this workspace from their next request, on every instance, and deletes its consent; the application stays registered, and the member’s authorizations in other workspaces, and other members’ authorizations, stay. The member can authorize it again later.

The same operations exist in the API, browser-only, for members holding oauth_applications.manage (administrators have it; never members limited to selected properties): GET /v1/authorized_applications, GET /v1/authorized_applications/{id} and POST /v1/authorized_applications/{id}/revoke with the application’s ETag in If-Match. Consents are audited as oauth_application.authorized, revocations as oauth_application.revoked, and actions an application takes with the actor type oauth_application, naming its authorization and the member it acts for.

Open registration (RFC 7591 without an initial access token) is on by default, because MCP clients register themselves. Registration metadata is self-asserted and treated as untrusted text: GuestCraft never fetches a logo or any other URL it names.

  • Better Auth limits registrations to 5 a minute per client address, and an instance accepts 30 a minute from all addresses together.
  • An instance keeps at most 10,000 applications that never completed an authorization; past that, registration answers 429 (temporarily_unavailable) until older ones expire.
  • An application that completes no authorization within a day is deleted, and one unused for 90 days is deleted with its authorizations. The worker runs a bounded pass of these rules every hour (on Vercel, at each daily drain), and every registration runs a small batch first. An application with an authorization in an archived workspace is kept while the workspace is archived.
  • A registration has at most 30 fields, 16 KiB and 10 redirect URIs; names, URIs and lists have bounded lengths. It may not set jwks, jwks_uri, software_statement, logout URIs, dpop_bound_access_tokens, subject_type, skip_consent, require_pkce or server-assigned values; token_endpoint_auth_method is none, client_secret_basic or client_secret_post. The client credentials grant is refused. A client that omits application_type is a native client when it registers only loopback or private-use redirect URIs, otherwise a web client, whose redirect URIs must use HTTPS.

GUESTCRAFT_OAUTH_OPEN_REGISTRATION=false turns open registration off: a registration without a session then answers 401, and only an owner or administrator of the organization active in their session registers an application, signed in to the instance; a member who neither owns nor administers it is refused 401 too. No screen sends that registration yet: send it from the browser where you are signed in, for example from the developer console of a page of the instance, so that it carries your session cookie and the instance’s Origin, which the cookie origin policy requires:

const answer = await fetch("/api/auth/oauth2/register", {
method: "POST",
credentials: "same-origin",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
client_name: "Channel Assistant",
redirect_uris: ["http://127.0.0.1:8123/callback"],
token_endpoint_auth_method: "none",
grant_types: ["authorization_code", "refresh_token"],
response_types: ["code"],
}),
});
console.log(answer.status, await answer.json());

The same metadata rules apply. A 201 answer is the application’s registration (RFC 7591): give its client_id, and its client_secret for a confidential client, to the application, which then runs the authorization code flow above like any other. See the installation guide.