OAuth 2.0 PKCE flow, token exchange, session management, DCR, resource indicators

Base URL: https://api.fast.io/current/ Request format: application/x-www-form-urlencoded Response format: JSON

Overview

Fastio implements OAuth 2.0 with PKCE (Proof Key for Code Exchange) for secure authorization without passing user credentials through the client application. This is the recommended auth method for desktop apps, mobile apps, and MCP-connected agents.

Key characteristics:

Endpoint Summary

MethodPathAuthDescription
GET/.well-known/oauth-authorization-server/NoneRFC 8414 authorization server metadata
GET/.well-known/oauth-protected-resource/NoneRFC 9728 protected resource metadata
POST/current/oauth/register/NoneRFC 7591 dynamic client registration
PUT/current/oauth/register/Registration access token (Bearer)RFC 7592 update client registration
GET/current/oauth/authorize/NoneInitiate auth flow — 302 redirect to login page (or JSON with response_format=json)
POST/current/oauth/authorize/Session (JWT)Complete authorization, issue code
GET/current/oauth/authorize/info/NoneGet client info for consent screen
POST/current/oauth/token/NoneExchange code for tokens, or refresh tokens
POST/current/oauth/revoke/NoneRevoke a refresh token
GET/current/oauth/sessions/BearerList all active sessions (optionally filtered to one workspace)
GET/current/oauth/sessions/{id}/BearerGet session details
PATCH/current/oauth/sessions/{id}/BearerUpdate session display names, or narrow its scopes
PUT/current/oauth/sessions/{id}/grant/BearerReplace the whole access grant (scopes + optional per-place tool restrictions) of a session
DELETE/current/oauth/sessions/{id}/BearerRevoke a specific session
DELETE/current/oauth/sessions/BearerRevoke all sessions
GET/current/auth/scopes/BearerToken scope introspection

Metadata Discovery

GET /.well-known/oauth-authorization-server/

RFC 8414 Authorization Server Metadata. Returns server configuration for automated client setup.

GET /.well-known/oauth-authorization-server/

Auth: None

curl -X GET "https://api.fast.io/.well-known/oauth-authorization-server/"

Response (200 OK):

{
  "issuer": "https://go.fast.io",
  "authorization_endpoint": "https://go.fast.io/api/current/oauth/authorize",
  "token_endpoint": "https://go.fast.io/api/current/oauth/token",
  "revocation_endpoint": "https://go.fast.io/api/current/oauth/revoke",
  "registration_endpoint": "https://go.fast.io/api/current/oauth/register",
  "response_types_supported": ["code"],
  "grant_types_supported": ["authorization_code", "refresh_token"],
  "code_challenge_methods_supported": ["S256"],
  "token_endpoint_auth_methods_supported": ["none"],
  "scopes_supported": ["user", "org", "workspace", "all_orgs", "all_workspaces", "all_shares", "all_sign_envelopes"],
  "access_modes_supported": ["r", "rw", "rwa"],
  "client_id_metadata_document_supported": true,
  "resource_indicators_supported": true,
  "service_documentation": "https://go.fast.io/api/current/llms/"
}

Response Fields:

FieldTypeDescription
issuerstringAuthorization server issuer identifier URL
authorization_endpointstringURL of the authorization endpoint. The four endpoint URLs are emitted without a trailing slash.
token_endpointstringURL of the token endpoint
revocation_endpointstringURL of the token revocation endpoint
registration_endpointstringURL of the dynamic client registration endpoint
response_types_supportedarraySupported response types (code only)
grant_types_supportedarraySupported grant types (authorization_code, refresh_token)
token_endpoint_auth_methods_supportedarraySupported auth methods (none — public clients only)
code_challenge_methods_supportedarraySupported PKCE methods (S256 only)
client_id_metadata_document_supportedbooleanClient ID Metadata Document support (true) — an HTTPS URL may be used as client_id (see Client ID Metadata Document below)
resource_indicators_supportedbooleanRFC 8707 support (true)
scopes_supportedarrayOAuth scope type selectors supported by the authorization server (see the scope-type values table under GET /current/oauth/authorize/)
access_modes_supportedarrayAccess modes the authorization server accepts on access_mode: ["r","rw","rwa"]
service_documentationstringURL to service documentation

Clients validating access_modes_supported against {r, rw} must accept rwa. The list now carries a third mode — read, write and administer — so a client that rejects unknown values will fail against it. scopes_supported is unchanged: userdetails is an entity type inside a credential's scope list, never a scope type, and is never advertised here.

Error Responses:

Reading the error tables: the four-digit 16xx/17xx values below are HTTP-status classes, not error.code. The error.code a client actually receives is assigned per endpoint, so use the HTTP status as the gate and a documented error.code — five or six digits, plus the 9661-9669 family — only as a refinement. A 16xx value identifies the status class — useful for telling which kind of failure occurred — but comparing one against error.code will never match. Five- and six-digit codes (and the 9661-9669 family) are real error.code values. If you widen a check from a specific code to a status, widen what you assert with it — a status covers failures the narrower code did not, so a message written for that one code becomes a confident falsehood on the rest.

ScenarioHTTP StatusError
Wrong HTTP method40510003

GET /.well-known/oauth-protected-resource/

RFC 9728 Protected Resource Metadata. Returns resource server configuration.

GET /.well-known/oauth-protected-resource/

Auth: None

curl -X GET "https://api.fast.io/.well-known/oauth-protected-resource/"

Response (200 OK):

{
  "result": true,
  "resource": "https://mcp.fast.io/mcp",
  "authorization_servers": ["https://go.fast.io"],
  "bearer_methods_supported": ["header"],
  "scopes_supported": ["user", "org", "workspace", "all_orgs", "all_workspaces", "all_shares", "all_sign_envelopes"]
}

Response Fields:

FieldTypeDescription
resultbooleantrue (this endpoint answers in the standard platform envelope)
resourcestringProtected resource identifier URL
authorization_serversarrayAuthorization server issuer URLs that can issue tokens for this resource
bearer_methods_supportedarrayMethods for presenting bearer tokens (header — Authorization header only)
scopes_supportedarrayOAuth scopes supported by this resource

Error Responses:

ScenarioHTTP StatusError
Wrong HTTP method40510003

Dynamic Client Registration

POST /current/oauth/register/

RFC 7591 Dynamic Client Registration. Register a new OAuth client programmatically.

POST /current/oauth/register/

Auth: None · Content-Type: application/json or application/x-www-form-urlencoded

Request Parameters:

ParameterTypeRequiredDefaultDescription
client_namestringNo"Unknown Client"Human-readable client name (max 128 bytes after trimming; reserved names are refused)
redirect_urisJSON arrayYes—Allowed redirect URIs. 1–10 URIs. HTTPS required (localhost/127.0.0.1 exempt). No fragment (#) components.
token_endpoint_auth_methodstringNo"none"Auth method (only public clients supported)
grant_typesJSON arrayNo["authorization_code", "refresh_token"]Requested grant types. Not validated: echoed in the response; the client can only ever use authorization_code and refresh_token
response_typesJSON arrayNo["code"]Requested response types. Not validated: echoed in the response; only code is supported
curl -X POST "https://api.fast.io/current/oauth/register/" \
  -H "Content-Type: application/json" \
  -d '{
    "client_name": "My MCP Client",
    "redirect_uris": ["http://localhost:3000/callback", "http://127.0.0.1:3000/callback"]
  }'

Response (200 OK):

{
  "result": true,
  "client_id": "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6",
  "client_name": "My MCP Client",
  "redirect_uris": [
    "http://localhost:3000/callback",
    "http://127.0.0.1:3000/callback"
  ],
  "token_endpoint_auth_method": "none",
  "grant_types": ["authorization_code", "refresh_token"],
  "response_types": ["code"],
  "registration_access_token": "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6",
  "registration_client_uri": "https://go.fast.io/api/current/oauth/register/"
}

Response Fields:

FieldTypeDescription
resultbooleantrue on success
client_idstringAssigned client identifier for OAuth flows (32-character lowercase-hex string, no prefix)
client_namestringRegistered client name
redirect_urisarrayRegistered redirect URIs
token_endpoint_auth_methodstringToken endpoint auth method ("none")
grant_typesarrayThe grant_types sent in the request (or the default)
response_typesarrayThe response_types sent in the request (or the default)
registration_access_tokenstringOne-time token for managing registration via PUT (64-character lowercase-hex string, no prefix). Shown once only — store securely. Server stores only a SHA-256 hash.
registration_client_uristringURI for managing this client registration

Error Responses (RFC 7591 format — bare JSON, no platform envelope):

Error CodeHTTP StatusDescription
invalid_client_metadata400client_name exceeds 128 bytes
invalid_client_metadata400client_name is a reserved name
invalid_client_metadata400redirect_uris is not a valid JSON array
invalid_client_metadata400token_endpoint_auth_method is not "none"
invalid_redirect_uri400redirect_uris must contain 1–10 entries
invalid_redirect_uri400A redirect_uris entry is not a non-empty string
invalid_redirect_uri400Redirect URI is not a valid URL
invalid_redirect_uri400Redirect URI contains a fragment (#)
invalid_redirect_uri400Redirect URI must use HTTPS (except localhost)
invalid_request400redirect_uris is missing
invalid_request400JSON request body is not a JSON object, or is larger than 64 KB
server_error500Failed to register client

PUT /current/oauth/register/

RFC 7592 Dynamic Client Registration Management. Update an existing client registration. Requires the registration_access_token from the POST registration response. Only clients whose redirect URIs all point to localhost, 127.0.0.1, [::1], or ::1 may self-update.

PUT /current/oauth/register/

Auth: Registration access token (Bearer) · Content-Type: application/json or application/x-www-form-urlencoded

Request Parameters:

ParameterTypeRequiredDescription
client_idstringYesMust match a registered client
client_namestringNoUpdated client name (max 128 characters)
redirect_urisJSON arrayNoUpdated redirect URIs (full replacement, same validation as POST)

At least one of client_name or redirect_uris must be provided.

curl -X PUT "https://api.fast.io/current/oauth/register/" \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6" \
  -d '{
    "client_id": "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6",
    "client_name": "My Updated MCP Client",
    "redirect_uris": ["http://localhost:8080/callback"]
  }'

Response (200 OK):

{
  "result": true,
  "client_id": "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6",
  "client_name": "My Updated MCP Client",
  "redirect_uris": ["http://localhost:8080/callback"],
  "token_endpoint_auth_method": "none",
  "grant_types": ["authorization_code", "refresh_token"],
  "response_types": ["code"]
}

Response Fields:

FieldTypeDescription
resultbooleantrue on success
client_idstringClient identifier (unchanged)
client_namestringUpdated client name
redirect_urisarrayUpdated redirect URIs
token_endpoint_auth_methodstringAuth method (unchanged, always "none")
grant_typesarrayGrant types (unchanged)
response_typesarrayResponse types (unchanged)

Error Responses (RFC 7591 format — bare JSON):

Error CodeHTTP StatusDescription
invalid_request400client_id missing
invalid_client_metadata400client_name is a reserved name
invalid_request400Neither client_name nor redirect_uris provided
invalid_request400Only localhost clients can self-update
invalid_request401Invalid or missing registration access token
invalid_client404Client not found or disabled
invalid_client_metadata400Invalid client metadata
invalid_redirect_uri400Invalid redirect URIs (same rules as POST)
server_error500Failed to update registration

The grant_types, response_types, and token_endpoint_auth_method fields cannot be changed via self-update. If redirect_uris is provided, it fully replaces the existing set. If only client_name is provided, existing redirect URIs are preserved.

Client ID Metadata Document (CIMD)

CIMD allows OAuth clients to use an HTTPS URL as their client_id instead of pre-registering or using Dynamic Client Registration. The authorization server fetches metadata from the URL to get client information on-the-fly. This is the MCP specification's preferred client registration method.

How It Works

  1. Detection: If the client_id parameter starts with https://, the server treats it as a CIMD URL.
  2. Fetch: The server fetches the JSON metadata document from the CIMD URL.
  3. Validation: The document must contain:
    • client_id matching the fetched URL exactly
    • redirect_uris array containing the requested redirect URI, with 1–10 entries that each pass the same redirect-URI rules as dynamic registration
    • grant_types including authorization_code
    • response_types including code
    • token_endpoint_auth_method set to none, or none listed in token_endpoint_auth_methods_supported (either way the client is treated as a public client: no token-endpoint client authentication, PKCE S256 required for the code exchange)
  4. Caching: Validated documents are cached for 1 hour to avoid repeated fetches.
  5. Flow: The CIMD URL is used as the client_id throughout the authorization flow (authorize, token exchange, refresh).

CIMD Document Format

The metadata document is a JSON file served at an HTTPS URL with Content-Type: application/json:

{
  "client_id": "https://example.com/oauth/client-metadata",
  "client_name": "Example App",
  "redirect_uris": ["http://localhost:8080/callback"],
  "grant_types": ["authorization_code"],
  "response_types": ["code"],
  "token_endpoint_auth_method": "none"
}

Optional fields: client_uri (URL to the client's home page), logo_uri (URL to the client's logo image).

Security Constraints

ConstraintValue
ProtocolHTTPS only (HTTP URLs rejected)
Fetch timeout5 seconds
Max document size10 KB
Cross-domain redirectsRejected
Cache duration1 hour

Using CIMD in the Authorization Flow

Use the CIMD URL directly as the client_id parameter:

GET /current/oauth/authorize/?response_type=code&client_id=https://example.com/oauth/client-metadata&redirect_uri=http://localhost:8080/callback&code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM&code_challenge_method=S256&state=xyz123

The token endpoint also uses the CIMD URL as client_id — it matches by string comparison against the value stored during authorization.

Error Scenarios

ScenarioError
CIMD URL is not HTTPS1605 (Invalid Input)
Cannot fetch the document (timeout, DNS failure, non-200 response)1605 (Invalid Input)
Document is not valid JSON1605 (Invalid Input)
client_id in document does not match the URL1605 (Invalid Input)
Required fields missing or invalid1605 (Invalid Input)
redirect_uri not found in document's redirect_uris1605 (Invalid Input)

Complete PKCE Flow

Step 0: Discover Server Configuration (Optional)

Fetch server metadata for automated setup:

1. CLIENT -> API: Discover authorization server
   GET /.well-known/oauth-authorization-server/
   -> Returns endpoints, supported grant types, PKCE methods

2. CLIENT -> API: Discover protected resource (if needed)
   GET /.well-known/oauth-protected-resource/
   -> Returns resource URL and authorization servers

Step 0b: Register Client (If Needed)

If the client does not have a registered client_id, there are two options:

Option A: Use a CIMD URL as client_id — If the client publishes a metadata document at an HTTPS URL, use that URL directly as the client_id. No registration step needed.

Option B: Use Dynamic Client Registration:

CLIENT -> API: Register client
POST /current/oauth/register/
Content-Type: application/json

{"client_name": "My Agent", "redirect_uris": ["http://localhost:8080/callback"]}

-> Returns client_id, redirect_uris, registration_access_token, etc.

Step 1: Generate PKCE Parameters (Client-Side)

Before initiating the flow, generate the PKCE code verifier and challenge:

code_verifier = random_string(43-128 characters, URL-safe: [A-Za-z0-9-._~])
code_challenge = base64url_encode(sha256(code_verifier))
code_challenge_method = "S256"

The code_challenge will always be exactly 43 characters.

Step 2: Initiate Authorization

GET /current/oauth/authorize/

Initiates the authorization flow. By default, issues a 302 Found redirect to the login/consent page. This is the browser-facing entry point that MCP clients open in the user's browser.

GET /current/oauth/authorize/

Auth: None

JSON mode: Add response_format=json to receive a JSON response instead of a redirect (for programmatic callers).

Query Parameters:

ParameterTypeRequiredDefaultDescription
client_idstringYes—Registered OAuth client ID, or an HTTPS URL pointing to a CIMD
redirect_uristringYes—Must match a registered URI for the client
response_typestringNo—Send "code" (the only supported type); the value is not currently checked
code_challengestringYes—Exactly 43 characters (BASE64URL-encoded SHA-256)
code_challenge_methodstringYes—Must be "S256"
statestringYes—Opaque value for CSRF protection, returned unchanged in callback
scopestringNo"user"Scope type selector (see scope values table)
access_modestringNo— (consent then treats the ceiling as rw)"r", "rw" or "rwa". The ceiling for the whole authorization — consent may narrow it, never widen it. An empty value is treated as omitted; any other non-empty value returns 406 180242
account_settingsstringNo"0""1" to allow account-settings access (userdetails:*:rw) to be granted at consent, "0", an empty value, or omission for off. Any other value — including "true" and "false" — returns 406 117053
resourcestringNo—RFC 8707 resource indicator (e.g., https://mcp.fast.io/mcp)
agent_namestringNo—Display name of the requesting agent (truncated to 128 characters; reserved names are refused with 406)
response_formatstringNo—Set to "json" for JSON response instead of 302 redirect
display_codestringNofalseCopy-paste mode for clients that cannot receive a redirect (headless CLIs, remote agents): "true" or "false" ("1" / "0" also accepted; anything else is refused with 406). After approval the browser shows the authorization code for the user to paste back into the client instead of redirecting to redirect_uri. redirect_uri is still required, must still be registered, and must be sent unchanged to the token endpoint. Recorded with the request: GET /current/oauth/authorize/info/ and the consent response then report redirect_mode: "display_code", and the returned login_url carries the mode

Scope type values (scope parameter):

ValueBehavior
userFull access (default, backward compatible with v1.0 JWT)
orgUser picks specific organizations
workspaceUser picks specific workspaces
all_orgsWildcard access to all user's organizations
all_workspacesWildcard access to all user's workspaces
all_sharesAll shares the user is a member of
all_sign_envelopesWildcard access to all sign envelopes the user can reach

These seven are the complete offered set — they match the scopes_supported array in the authorization-server metadata (GET /.well-known/oauth-authorization-server). Request one of these named values, or omit scope for the default user. Values are case-sensitive. Words outside this set are ignored only if they are the OpenID Connect words openid, profile, email or offline_access; a request made only of those is treated like an omitted scope (the default user type at the consented access mode). A scope that names none of the seven otherwise is refused with a 406 input error (invalid_scope); the one exception is the retired all_workflows value, which is accepted here but never grants anything (consent fails). If several recognised values are sent space-separated, the broadest one is used. Always read scopes in the token response for what was actually granted.

access_mode and account_settings are ceilings recorded with the authorization request. They are set here, on the initiate request, and the consent step (POST /current/oauth/authorize/) can only narrow them. access_mode bounds the access mode of every granted scope; account_settings decides whether account-settings access may be granted at all. A client that never sends them cannot obtain administration (rwa) or account settings through OAuth — administration is opt-in on a request the client made explicitly, and the consent screen never offers it unprompted.

Retired scope — all_workflows: no longer offered. The Workflows feature has been removed, so all_workflows is deliberately omitted from scopes_supported and must not be requested. The authorization server retains it only as a fail-closed tombstone, so a pre-existing token that still carries it resolves to nothing rather than erroring — it is not a capability you can request. (This is why the offered set is the seven above, not eight.)

# Standard browser flow (302 redirect)
curl -v "https://api.fast.io/current/oauth/authorize/?client_id=my-app&redirect_uri=http%3A%2F%2Flocalhost%3A8080%2Fcallback&response_type=code&code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM&code_challenge_method=S256&state=abc123xyz"

# JSON mode (programmatic)
curl "https://api.fast.io/current/oauth/authorize/?client_id=my-app&redirect_uri=http%3A%2F%2Flocalhost%3A8080%2Fcallback&response_type=code&code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM&code_challenge_method=S256&state=abc123xyz&response_format=json"

Response (default — 302 Found):

Location: https://go.fast.io/connect?auth_request_id={auth_request_id}

Response (response_format=json — 200 OK):

{
  "result": true,
  "auth_request_id": "{auth_request_id}",
  "client_name": "My App",
  "scope": "user",
  "login_url": "https://go.fast.io/connect?auth_request_id={auth_request_id}"
}

Response Fields (JSON mode):

FieldTypeDescription
resultbooleantrue on success
auth_request_idstring64-character hex identifier for this authorization request (valid for 10 minutes)
client_namestringHuman-readable name of the OAuth client application
scopestringThe requested scope type as resolved at initiate: the broadest recognised value, or user when scope was omitted (a value made only of ignored OpenID Connect words is echoed as sent). This is the request, not the grant — read scopes in the token response
agent_namestringAgent display name (present if agent_name was provided)
login_urlstringThe URL to open in the user's browser for this authorization — the same page the default 302 redirects to. Open it verbatim; do not build it yourself from auth_request_id. The user signs in there (on the Fastio sign-in page when they use email and password) and approves access

Error Responses (standard platform envelope):

ScenarioError CodeHTTP Status
client_id missing1605 (Invalid Input)406
redirect_uri missing1605 (Invalid Input)406
code_challenge missing1605 (Invalid Input)406
code_challenge_method missing1605 (Invalid Input)406
code_challenge_method not "S256"1605 (Invalid Input)406
code_challenge not 43 characters1605 (Invalid Input)406
state missing1605 (Invalid Input)406
client_id not found1605 (Invalid Input)406
Client is disabled1605 (Invalid Input)406
redirect_uri mismatch1605 (Invalid Input)406
Invalid resource indicator1605 (Invalid Input)406
scope names none of the seven scope types and is not made only of ignored OpenID Connect words (invalid_scope)138232406
agent_name is a reserved name125566406
display_code not "true", "false", "1" or "0"109495406
CIMD client_id document could not be validated106954406
access_mode non-empty and not "r", "rw" or "rwa"180242406
account_settings non-empty and not "1" or "0"117053406
Internal storage failure1654 (Internal Error)500

POST /current/oauth/authorize/

Complete authorization after user login. Issues the authorization code. Called by the web application (not the client directly) with the user's active session.

POST /current/oauth/authorize/

Auth: Required (JWT — website session)

Request Parameters:

ParameterTypeRequiredDefaultDescription
auth_request_idstringYes—64-character hex authorization request ID from the GET request
scopesstringConditional—JSON array of entity IDs or scope strings (e.g., "[3814271023567182934, 3829104758203948571]" or '["org:3814271023567182934:rw"]'). Must be a non-empty JSON list when supplied, and is required for scope=org and scope=workspace
access_modestringNothe value stored at initiate"r", "rw" or "rwa" — may only narrow the initiate value, never widen it
account_settingsstringNo"0""1" appends userdetails:*:rw server-side, and only when the authorization was initiated with account_settings=1
agent_namestringNo—Override the agent display name from the GET request (max 128 characters)

Consent rules — scopes, access mode and account settings:

curl -X POST "https://api.fast.io/current/oauth/authorize/" \
  -H "Authorization: Bearer {jwt_token}" \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -d "auth_request_id={auth_request_id}"

Response (200 OK):

{
  "result": true,
  "redirect_uri": "http://localhost:8080/callback?code=abc123def456abc123def456abc123def456abc123def456abc123def456abc123de&state=abc123xyz",
  "redirect_mode": "redirect"
}

Response Fields:

FieldTypeDescription
resultbooleantrue on success
redirect_uristringFull redirect URI with code (64 hex chars, valid 5 min, single-use) and state query parameters
redirect_modestring"redirect" for HTTP/HTTPS redirect URIs, "button" for custom scheme URIs, "display_code" when the authorization was initiated with display_code=true (show the code from redirect_uri for the user to copy instead of navigating)
button_labelstring or nullCustom button label for "button" redirect mode (if configured)

Error Responses:

ScenarioError CodeHTTP Status
User not authenticated1650 (Authentication Invalid)401
auth_request_id missing1605 (Invalid Input)406
Request expired, not found, or already consented to1609 (Not Found)404
agent_name override is a reserved name108127406
Invalid access_mode1605 (Invalid Input)406
Scope validation failed1605 (Invalid Input)406
Unrecognised access_mode value161579406
access_mode broader than the initiate value, a client-supplied userdetails scope, or account_settings=1 without it at initiate10768 (access_mode_exceeds_initiate)403
scopes not a non-empty JSON list168998 / 128552406
scopes omitted for scope=org or scope=workspace121987406
Consent made with a narrowed (non-account-level) credential10175403
Granted scopes exceed the consenting credential10768 (scope_exceeds_issuer)403
More than 20 scopes after the server-side append157349406
Granted scope exceeds the governing org's credential_policy (error.params.reason credential_policy_mode or credential_policy_scope)116292403
The governing org's stored credential_policy is corrupt (error.params.reason credential_policy_unreadable)116292403, permanent
A granted scope names an entity whose owning org no longer exists (error.params.reason credential_policy_org)116292403, permanent
The governing org, or its credential_policy, could not be READ (error.params.reason credential_policy_unavailable)116292503, retry
Internal processing error1654 (Internal Error)500

GET /current/oauth/authorize/info/

Validate an authorization request and return client information (app name, requested scope). Used by the web frontend to display a consent screen before the user confirms.

GET /current/oauth/authorize/info/

Auth: None

Query Parameters:

ParameterTypeRequiredDescription
auth_request_idstringYesThe authorization request ID to validate
curl "https://api.fast.io/current/oauth/authorize/info/?auth_request_id={auth_request_id}"

Response (valid request — 200 OK):

{
  "result": true,
  "valid": true,
  "client_name": "My App",
  "scope": "user",
  "agent_name": "My MCP Agent",
  "redirect_mode": "redirect",
  "redirect_uri": "http://localhost:8080/callback",
  "resource": "https://mcp.fast.io/mcp",
  "access_mode": "rw",
  "account_settings": false
}

resource is the RFC 8707 resource indicator the client supplied at initiate (or null), access_mode is the requested access mode ("r", "rw" or "rwa", or null when the client did not supply one), and account_settings is a boolean that is always present. All three are returned server-authoritatively from the stored authorization request, so consent screens can render them without trusting URL parameters.

Show the account-settings toggle only when account_settings is true, and default it to off. When it is false the authorization was not initiated for account settings, and consent cannot grant them.

Response (invalid or expired — 200 OK):

{
  "result": true,
  "valid": false
}

Response Fields:

FieldTypeDescription
resultbooleanAlways true
validbooleantrue if the auth request is valid and client is active
client_namestringClient name (only when valid is true)
scopestringRequested scope (only when valid is true)
agent_namestringAgent display name (only when set and valid is true)
redirect_modestring"redirect", "button", or "display_code" when the authorization was initiated with display_code=true (only when valid is true)
button_labelstringCustom button label (only when configured and valid is true)
redirect_uristringClient redirect URI (only when valid is true)
resourcestring or nullThe RFC 8707 resource indicator recorded at initiate, or null when the client supplied none. Always present when valid is true
access_modestring or nullRequested access mode — "r" (read-only), "rw" (read-write) or "rwa" (read-write-administer) — or null when the client supplied none. It is the ceiling for consent. Always present when valid is true
account_settingsbooleanWhether the authorization was initiated with account_settings=1, i.e. whether account-settings access (userdetails:*:rw) may be granted at consent. Always present when valid is true; render the toggle only when it is true, defaulted to off

This endpoint never returns an error for invalid auth_request_id. It returns valid: false instead, preventing information leakage about authorization request existence.

Step 3: User Approves in Browser

The user opens the authorization URL (the login_url from JSON mode, or the 302 target), signs in (supports SSO; email-and-password users sign in on the Fastio sign-in page, which also handles 2FA), and approves access. The browser either:

Loopback redirect URIs (native apps, RFC 8252). A client registered with an http loopback redirect URI such as http://127.0.0.1:8080/callback may send the same URI with any port at authorize time, so a CLI or desktop app can listen on an ephemeral port. Only the port may differ — scheme, host, path and query must match the registered URI exactly, and localhost and 127.0.0.1 are different hosts. The token request must send exactly the redirect_uri used at authorize, including the port.

Step 4: Exchange Code for Tokens

POST /current/oauth/token/

Exchange an authorization code for access and refresh tokens, or refresh an existing access token.

POST /current/oauth/token/

Auth: None · Content-Type: application/x-www-form-urlencoded

Important: This endpoint returns bare JSON responses (RFC 6749 format, no platform envelope) for compatibility with standard OAuth clients, including MCP hosts.

Authorization Code Exchange

Request Parameters:

ParameterTypeRequiredDescription
grant_typestringYesMust be "authorization_code"
codestringYes64-character hex authorization code from the callback
code_verifierstringYesOriginal PKCE code verifier (43–128 characters, [A-Za-z0-9-._~])
client_idstringYesMust match original request
redirect_uristringYesMust match original request
resourcestringNoRFC 8707 resource indicator URL (must match authorize request)
device_namestringNoHuman-readable device name for session tracking
device_typestringNoDevice category for session tracking
curl -X POST "https://api.fast.io/current/oauth/token/" \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -d "grant_type=authorization_code&code=abc123def456abc123def456abc123def456abc123def456abc123def456abc123de&code_verifier=dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk&client_id=my-app&redirect_uri=http://localhost:8080/callback"

Response (200 OK — bare JSON, no envelope):

{
  "access_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...",
  "token_type": "Bearer",
  "expires_in": 3600,
  "refresh_token": "{refresh_token}",
  "scope": "org",
  "scopes": "[\"org:3814271023567182934:rw\",\"org:3829104758203948571:rw\"]",
  "agent_name": "My MCP Agent"
}

Response Fields:

FieldTypeDescription
access_tokenstringJWT for API requests. Use as Authorization: Bearer {access_token}. Expires in 1 hour.
token_typestringAlways "Bearer"
expires_inintegerToken lifetime in seconds (3600 = 1 hour)
refresh_tokenstringLong-lived opaque token (64-character hex string) for obtaining new access tokens. Store securely. Returned unchanged on refresh (no rotation).
scopestringThe scope type of the grant, derived from the granted scopes (not echoed from the request): user, all_orgs, all_workspaces, all_shares, all_sign_envelopes, org or workspace. A grant spanning several types reports the broadest; a grant on specific shares or sign envelopes reports all_shares / all_sign_envelopes. On refresh it reflects any narrowing. Not a scope list — persist scopes.
scopesstringJSON-encoded array of scope strings in entity_type:entity_id:access_mode format. Emitted for every grant — a plain scope=user grant is stored explicitly as ["user:*:rw"] and reports it here. Persist this, not scope
agent_namestringAgent display name (v2.0 only, present when set during authorization)

scope versus scopes. scope (singular) is a single scope-type word summarising the grant; scopes (plural) is a JSON-encoded string listing the granted entity scopes. Persist scopes, not scope.

scopes is now returned for every grant, including a plain scope=user grant that previously carried no list — such a grant is stored explicitly as ["user:*:rw"]. Clients must not treat a missing scopes as “this is a full session”, and must not branch on auth_type === "jwt_v1": those grants now report jwt_v2 at introspection.

This response carries no admin and no legacy field — derive administrative capability from GET /current/auth/scopes/.

Refresh Token Exchange

ParameterTypeRequiredDescription
grant_typestringYesMust be "refresh_token"
refresh_tokenstringYesCurrent valid refresh token
client_idstringYesMust match the original token request
device_namestringNoUpdated device name for session tracking
device_typestringNoUpdated device type for session tracking
curl -X POST "https://api.fast.io/current/oauth/token/" \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -d "grant_type=refresh_token&refresh_token={refresh_token}&client_id=my-app"

Response (200 OK — bare JSON): Same format as authorization code exchange. Returns a new access_token. The refresh_token is long-lived and is returned unchanged (no per-refresh rotation).

Refresh narrows a demoted administrative grant. A grant naming a concrete entity at rwa whose human is no longer an administrator of it comes back as rw, and the response's scopes echoes the narrowed set — which is then what the session holds. Wildcard rwa grants are never narrowed this way; they are capped live on every request against the human's role. Losing access to an entity entirely still revokes, as before.

Error Responses (RFC 6749 SS5.2 format — bare JSON):

{
  "error": "invalid_grant",
  "error_description": "The authorization code is invalid or has expired."
}
ScenarioRFC 6749 ErrorHTTP Status
Missing/invalid parametersinvalid_request400
Invalid grant_type valueunsupported_grant_type400
code_verifier invalid length (not 43–128 chars)invalid_request400
Unrecognized resource indicatorinvalid_request400
Invalid, expired or already-used authorization code (codes are single-use: a repeated or concurrent exchange of the same code is refused)invalid_grant400
PKCE code_verifier mismatchinvalid_grant400
client_id mismatchinvalid_grant400
redirect_uri mismatchinvalid_grant400
Resource indicator mismatch (RFC 8707)invalid_grant400
Invalid/expired/revoked refresh tokeninvalid_grant400
client_id mismatch on refreshinvalid_grant400
Inactive user accountinvalid_grant400
Per-user active-session cap reached on authorization-code exchange (error_description begins "You have reached the maximum number of active connections") — revoke an existing session via DELETE /current/oauth/sessions/{session_id}/ and retryinvalid_grant400
Internal server failureserver_error500

Resource Indicator Enforcement: The resource value is stored in the authorization code during the authorize step. At token exchange, the value is strictly compared. If they do not match — including if one is null and the other is not — the exchange fails. This prevents downgrade attacks where a client omits the resource to obtain an unrestricted token.

Step 5: Use the Access Token

Include the access token in all API requests:

Authorization: Bearer {access_token}

When the access token expires (after 1 hour), use the refresh token to get a new one without requiring user interaction. Refresh proactively before expiration (5-minute buffer recommended) to avoid interruptions.

Token Revocation

POST /current/oauth/revoke/

Revoke a refresh token (logout). Implements RFC 7009 (OAuth 2.0 Token Revocation). Always returns success to prevent token enumeration attacks.

POST /current/oauth/revoke/

Auth: None · Content-Type: application/x-www-form-urlencoded

Request Parameters:

ParameterTypeRequiredDescription
tokenstringYesThe refresh token to revoke
curl -X POST "https://api.fast.io/current/oauth/revoke/" \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -d "token={refresh_token}"

Response (200 OK):

{
  "result": true
}

Error Responses (RFC 6749 format — bare JSON):

ScenarioErrorHTTP Status
token parameter missinginvalid_request400

Per RFC 7009, this endpoint always returns success regardless of whether the token was found, was already revoked, or never existed. Always call this endpoint on user logout and clear local token storage regardless of the response.

What Happens to API Calls After Revocation

A connection does not merely stop working silently — its next use after revocation is a distinguishable, documented response:

Every response made using an OAuth connection's token also carries an x-ve-grant-revision response header, set to the grant_revision the request was evaluated under. A client can compare this against the last value it saw to detect that the grant changed (narrowed, widened, or had its tools restricted) without polling GET /current/oauth/sessions/{session_id}/.

Session Management

OAuth sessions represent active token grants. Each authorization code exchange creates a session. Sessions have a stable session_id (32-character hex string) that persists across refreshes.

GET /current/oauth/sessions/

List all active (non-expired, non-revoked) OAuth sessions for the authenticated user.

GET /current/oauth/sessions/

Auth: Required (Bearer JWT)

Query Parameters:

ParameterTypeRequiredDescription
workspace_idstringNoNumeric workspace ID. Return only connections whose grant reaches this workspace — directly, via a wildcard, via the owning organization, or via a legacy whole-account grant
curl -X GET "https://api.fast.io/current/oauth/sessions/" \
  -H "Authorization: Bearer {jwt_token}"

# Filtered to one workspace
curl -X GET "https://api.fast.io/current/oauth/sessions/?workspace_id=1234567890123456789" \
  -H "Authorization: Bearer {jwt_token}"

Response (200 OK):

{
  "result": true,
  "sessions": [
    {
      "session_id": "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4",
      "client_id": "my-app",
      "client_name": "My App",
      "scopes": "[\"workspace:1234567890123456789:rw\"]",
      "scopes_detail": [
        {
          "entity_type": "workspace",
          "entity_id": "1234567890123456789",
          "access_mode": "rw",
          "admin": false,
          "tools": ["read", "write", "comment", "delete", "share"],
          "can_grant": "rw"
        }
      ],
      "agent_name": "My MCP Agent",
      "device_name": "Chrome on macOS",
      "device_type": "desktop",
      "ip_address": "203.0.113.42",
      "last_used": "2026-01-22 14:00:00 UTC",
      "created": "2026-01-21 14:00:00 UTC",
      "expires": "2036-01-21 14:00:00 UTC",
      "admin": false,
      "legacy": false,
      "country": "US",
      "last_ip": "203.0.113.42",
      "last_country": "US",
      "mcp": false,
      "grant_revision": 3,
      "live_grant": true,
      "tools": {"workspace:1234567890123456789": ["read", "write", "comment", "delete", "share"]},
      "tool_policy": null,
      "grant_edit": "replace",
      "tool_enforcement": true,
      "reach": "places",
      "via": "workspace",
      "partial": false
    }
  ],
  "count": 1,
  "filtered_by": {"workspace_id": "1234567890123456789"}
}

Response Fields:

FieldTypeDescription
resultbooleantrue on success
sessionsarrayList of active session objects
sessions[].session_idstring32-character hex session identifier
sessions[].client_idstringOAuth client that created the session
sessions[].client_namestring or nullDisplay name of the client: for a registered client, its registered name; for a client identified by a client metadata document URL, the client_name from that document as seen during a recent authorization (cached, so a very old session may show a stale or missing value); null when unknown. Fall back to agent_name / client_id when null.
sessions[].scopesstring or nullJSON-encoded array of granted scope strings in entity_type:entity_id:access_mode format — the same encoding as scopes in the token response, so parse it. A scope=user grant is stored explicitly as "[\"user:*:rw\"]"; null means a legacy session that declares no scopes claim at all.
sessions[].scopes_detailarrayHydrated scope objects, one per entry in scopes, in the same shape as scopes_detail from GET /current/auth/scopes/ (see Auth reference) plus two additions: tools (the effective tool list this entry currently has at this place) and can_grant ("rwa", "rw" or null — the widest access mode the signed-in user could grant here right now)
sessions[].agent_namestring or nullAgent display name if set during authorization, otherwise null
sessions[].device_namestring or nullHuman-readable device description (the device_name the client sent, or set via PATCH), otherwise null
sessions[].device_typestring or nullDevice category as sent by the client in device_type at the token endpoint (free-form, e.g. desktop), otherwise null
sessions[].ip_addressstring or nullIP address from the most recent refresh
sessions[].last_usedstring or nullDatetime of last token refresh (YYYY-MM-DD HH:MM:SS UTC), null if never refreshed
sessions[].createdstringDatetime when the session was created (YYYY-MM-DD HH:MM:SS UTC)
sessions[].expiresstringDatetime when the session expires (YYYY-MM-DD HH:MM:SS UTC)
sessions[].adminbooleanWhether the session carries at least one rwa scope, i.e. whether it can perform administrative operations (still capped by the human's live role)
sessions[].legacybooleanWhether the session declares no scopes claim at all. A legacy OAuth session behaves as user:*:rw: whole-account read and write, no administration, no account settings. legacy never implies admin, and admin never implies legacy
sessions[].countrystring or nullISO-3166 alpha-2 country of the IP that created the session. Set once, never updated. null for a session minted before this field existed.
sessions[].last_ipstring or nullClient IP of the most recent refresh. Written periodically, not on every refresh. null if never refreshed since tracking began.
sessions[].last_countrystring or nullISO-3166 alpha-2 country of last_ip. Same write cadence.
sessions[].mcpbooleantrue when this grant's OAuth resource (token audience) is the Fastio MCP server; false for a grant whose resource is an ordinary API audience, and false when no resource was recorded on the grant at all. Matches the mcp field on org credential-inventory rows (see Compliance & Audit in Organizations).
sessions[].grant_revisionintegerIncrements by 1 every time this connection's grant changes (consent, narrowing via PATCH, or a PUT .../grant/ replacement). Pass the current value back as expected_revision on PUT /current/oauth/sessions/{session_id}/grant/
sessions[].live_grantbooleantrue: the access tokens Fastio issues for this connection carry the live-grant binding, so a change to its grant (or a revoke) applies on the agent's next API call, with no token refresh. Access tokens issued before this capability was released keep working until they expire (at most one hour after the release); for those, a change applies at the connection's next token refresh.
sessions[].toolsobjectEffective tool list per granted place, keyed the same way as the tools request parameter on PUT .../grant/ (e.g. "workspace:1234567890123456789"). Reflects the access mode's default tool set even when no explicit restriction was set
sessions[].tool_policyobject or nullThe connection's SAVED tool limits exactly as stored, keyed like tools — including limits on places inside a granted entry (for example one workspace under a granted organization) and ancestor limits: a limit on a place that is not itself granted but that a granted place inherits from (for example a workspace limit kept for a granted share inside that workspace). null when the connection has no tool limits. This is what to edit and send back as tools on PUT .../grant/; never send the resolved tools map back, which would turn every default into a saved limit
sessions[].grant_editstringCapability flag, always "replace" — advertises that this connection's whole grant can be replaced with PUT /current/oauth/sessions/{session_id}/grant/
sessions[].tool_enforcementbooleanCapability flag, always true on every connection: tool limits can be set on this connection (tools on PUT .../grant/) and are enforced for it. It does NOT mean the connection is restricted — whether it is shows in tools (and in tool_enforcement on GET /current/auth/scopes/, which is true only for a restricted connection).
sessions[].reachstring"account" when the grant is account-wide (a user:* scope); "places" when it is limited to specific orgs, workspaces, shares, sign envelopes, etc
sessions[].viastringOnly present when workspace_id was supplied. Which part of the grant reaches the filtered workspace: "workspace" (named directly), "wildcard" (a workspace:* or user:* scope), "org" (the owning organization), or "legacy" (a pre-scopes whole-account grant)
sessions[].partialbooleanOnly present when workspace_id was supplied. true when the connection only reaches the workspace through a share, file, or sign envelope inside it — not the workspace itself
filtered_byobjectOnly present when a filter was applied. Echoes the query parameters that produced this result, e.g. {"workspace_id": "1234567890123456789"}
countintegerTotal number of active sessions (after filtering, when workspace_id was supplied)

Error Responses:

ScenarioError CodeHTTP Status
Not authenticated1650 (Authentication Invalid)401
workspace_id is not a valid numeric ID1605 (Invalid Input)406
workspace_id refers to a workspace the caller cannot see1609 (Not Found)404
Internal error1654 (Internal Error)500

GET /current/oauth/sessions/{session_id}/

Get details of a specific OAuth session. The session must belong to the authenticated user.

GET /current/oauth/sessions/{session_id}/

Auth: Required (Bearer JWT)

Path Parameters:

ParameterTypeRequiredDescription
session_idstringYes32 hexadecimal characters
curl -X GET "https://api.fast.io/current/oauth/sessions/a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4/" \
  -H "Authorization: Bearer {jwt_token}"

Response (200 OK):

{
  "result": true,
  "session": {
    "session_id": "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4",
    "client_id": "my-app",
    "client_name": "My App",
    "scopes": "[\"org:3814271023567182934:rw\"]",
    "agent_name": "My MCP Agent",
    "device_name": "Chrome on macOS",
    "device_type": "desktop",
    "ip_address": "203.0.113.42",
    "last_used": "2026-01-22 14:00:00 UTC",
    "created": "2026-01-21 14:00:00 UTC",
    "expires": "2036-01-21 14:00:00 UTC",
    "admin": false,
    "legacy": false,
    "country": "US",
    "last_ip": "203.0.113.42",
    "last_country": "US",
    "mcp": false
  }
}

Field meanings are the same as the list response above (country = creation IP, never updated; last_ip/last_country = last refresh, written periodically, not on every refresh; mcp = whether this grant's audience is the Fastio MCP server), including scopes_detail, grant_revision, live_grant, tools, tool_policy, grant_edit, tool_enforcement and reach. via and partial are omitted here — they only appear when listing sessions filtered by workspace_id.

Error Responses:

ScenarioError CodeHTTP Status
Not authenticated1650 (Authentication Invalid)401
session_id missing1605 (Invalid Input)406
session_id not 32 hex chars1605 (Invalid Input)406
Session not found or wrong user1609 (Not Found)404
Internal error1654 (Internal Error)500

PATCH /current/oauth/sessions/{session_id}/

Update the device_name, agent_name and/or scopes of a specific OAuth session. The session must belong to the authenticated user and must not be revoked.

PATCH /current/oauth/sessions/{session_id}/

Auth: Required (Bearer JWT)

Path Parameters:

ParameterTypeRequiredDescription
session_idstringYes32 hexadecimal characters

Body Parameters:

ParameterTypeRequiredDescription
device_namestringNoNew device name (max 128 characters; empty string clears to null)
agent_namestringNoNew agent name (max 128 characters; empty string clears to null)
scopesstringNoJSON-encoded list of scope strings, the same shape as an API key's. Must be narrower than or equal to both what the session already holds and what the calling credential holds

At least one of device_name, agent_name or scopes must be provided, so a scopes-only request is valid.

Narrowing only — PATCH cannot widen. To widen a connection, the signed-in user replaces its grant with PUT /current/oauth/sessions/{session_id}/grant/. Submitted scopes are checked against the session's current scopes and against the credential making the request; a broader set is refused with 403 and 10768 (error.params.reason: scope_exceeds_issuer). An empty list ([]) — or a value that does not parse as a list of scope strings — is refused with 406 136396 — to end a session entirely use DELETE /current/oauth/sessions/{session_id}/. A narrowing applies on the connection's next request; an access token issued before this capability was released picks it up at its next refresh instead.

curl -X PATCH "https://api.fast.io/current/oauth/sessions/a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4/" \
  -H "Authorization: Bearer {jwt_token}" \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -d "device_name=My%20Work%20Laptop"

Response (200 OK):

{
  "result": true,
  "session": {
    "session_id": "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4",
    "client_id": "my-app",
    "client_name": "My App",
    "scopes": "[\"org:3814271023567182934:rw\"]",
    "agent_name": "My MCP Agent",
    "device_name": "My Work Laptop",
    "device_type": "desktop",
    "ip_address": "203.0.113.42",
    "last_used": "2026-01-22 14:00:00 UTC",
    "created": "2026-01-21 14:00:00 UTC",
    "expires": "2036-01-21 14:00:00 UTC",
    "admin": false,
    "legacy": false,
    "country": "US",
    "last_ip": "203.0.113.42",
    "last_country": "US",
    "mcp": false
  }
}

The response carries the same additional fields as the list response above (scopes_detail, grant_revision, live_grant, tools, tool_policy, grant_edit, tool_enforcement, reach). Narrowing scopes here increments grant_revision, the same as a PUT .../grant/ replacement. A narrowing also tidies the connection's saved tool limits, in the same write: a limit that no longer applies anywhere the narrower grant reaches is removed, and nothing else changes, so the tools in force at a place can only shrink with its access mode. A limit on a place that left the grant is KEPT as an ancestor limit while a place the grant still reaches inherits it (for example a workspace limit, after the workspace is removed but a share inside it stays granted), so the share keeps its limit. A PATCH is never refused because of tool limits. When anything was removed or cut back, the response also carries tool_policy_pruned: an array of the affected place keys (for example ["workspace:1234567890123456789"]); it is absent otherwise.

Error Responses:

ScenarioError CodeHTTP Status
Not authenticated1650 (Authentication Invalid)401
session_id missing1605 (Invalid Input)406
session_id not 32 hex chars1605 (Invalid Input)406
None of device_name, agent_name or scopes provided1605 (Invalid Input)406
scopes broader than the session or than the calling credential10768 (scope_exceeds_issuer)403
scopes submitted as an empty list ([]) or unparseable136396406
scopes no longer grantable to this user176886406
The session's scopes changed while the request was in flight — re-read the session and retry172160409
device_name exceeds 128 chars1605 (Invalid Input)406
agent_name exceeds 128 chars1605 (Invalid Input)406
agent_name is a reserved name121806406
Session is revoked1605 (Invalid Input)406
Session not found or wrong user1609 (Not Found)404
Internal error1654 (Internal Error)500

PUT /current/oauth/sessions/{session_id}/grant/

Replace the whole access grant of a specific OAuth connection — the granted scopes and, optionally, a per-place tools restriction. Unlike PATCH above, which can only narrow, this endpoint can widen OR narrow, bounded by what the signed-in user holds right now. The session must belong to the authenticated user and must not be revoked.

PUT /current/oauth/sessions/{session_id}/grant/

Auth: Required (Bearer JWT — the connection owner's own signed-in session only; a call made with an OAuth or API-key token, even the connection's own, is refused)

Path Parameters:

ParameterTypeRequiredDescription
session_idstringYes32 hexadecimal characters

Body Parameters:

ParameterTypeRequiredDescription
scopesJSON arrayYesNon-empty, max 20 entries, entity_type:entity_id:access_mode strings. Full replacement set of granted scopes. Empty or missing is refused with 406 — to remove all access entirely, revoke the connection instead (DELETE /current/oauth/sessions/{session_id}/)
toolsJSON objectNoKeyed by place (workspace:{id}, share:{id}, workspace:*, share:*, or user:*); each value a JSON array drawn from read, write, comment, delete, share, admin. Omitted: the connection's saved limits (tool_policy on the session) are kept unchanged — but on a connection that has tool limits, omitting tools is only allowed when the new scopes are a pure narrowing of the current ones (every new entry is already covered by a current one at the same or a higher access mode — the same test PATCH applies). A narrowing cannot give any place more tools. Any other change — a raised access mode, a new place, a swapped place — is refused with 406 (tools_required): send tools with the change. Limits that no longer apply anywhere are removed and listed in tool_policy_pruned. A connection without tool limits can change its scopes freely without tools. Also refused with 406 (tool_policy_orphaned) when the saved limits cannot be read. Present: replaces the saved limits; {} clears them, so every place gets its access mode's full default tool set (see below). Send the edited tool_policy, not the resolved tools map
expected_revisionintegerYesMust equal the connection's current grant_revision — a compare-and-set guard so two concurrent edits to the same connection cannot silently clobber each other

Default tool set by access mode (used for any place with no tools entry):

Access modeDefault tools
rread
rwread, write, comment, delete, share
rwaread, write, comment, delete, share, admin

tools constraints:

Actions that need particular tools:

What replacing a grant may and may not do:

curl -X PUT "https://api.fast.io/current/oauth/sessions/a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4/grant/" \
  -H "Authorization: Bearer {jwt_token}" \
  -H "Content-Type: application/json" \
  -d '{
    "scopes": ["workspace:1234567890123456789:rw"],
    "tools": {"workspace:1234567890123456789": ["read", "write", "comment"]},
    "expected_revision": 2
  }'

Response (200 OK):

{
  "result": true,
  "session": {
    "session_id": "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4",
    "client_id": "my-app",
    "client_name": "My App",
    "scopes": "[\"workspace:1234567890123456789:rw\"]",
    "scopes_detail": [
      {
        "entity_type": "workspace",
        "entity_id": "1234567890123456789",
        "access_mode": "rw",
        "admin": false,
        "tools": ["read", "write", "comment"],
        "can_grant": "rw"
      }
    ],
    "agent_name": "My MCP Agent",
    "device_name": "Chrome on macOS",
    "device_type": "desktop",
    "ip_address": "203.0.113.42",
    "last_used": "2026-01-22 14:00:00 UTC",
    "created": "2026-01-21 14:00:00 UTC",
    "expires": "2036-01-21 14:00:00 UTC",
    "admin": false,
    "legacy": false,
    "country": "US",
    "last_ip": "203.0.113.42",
    "last_country": "US",
    "mcp": false,
    "grant_revision": 3,
    "live_grant": true,
    "tools": {"workspace:1234567890123456789": ["read", "write", "comment"]},
    "tool_policy": {"workspace:1234567890123456789": ["read", "write", "comment"]},
    "grant_edit": "replace",
    "tool_enforcement": true,
    "reach": "places"
  }
}

Field meanings are the same as the list response under GET /current/oauth/sessions/ above.

Error Responses:

ScenarioError CodeHTTP Status
Not authenticated1650 (Authentication Invalid)401
Request method other than PUT155628405
session_id not 32 hex chars160589406
Called with a credential other than the owner's own signed-in session (error.params.reason is login_session_required)123472403
Connection not found, or it belongs to a different user151502, 195750404
Connection already revoked127401406
scopes missing, empty, or not a valid JSON array103595406
expected_revision missing or not a non-negative integer186385406
More than 20 entries in scopes132198406
Account-settings access requested but not already held by the connection (error.params.reason is scope_exceeds_issuer)10768403
Requested access the user cannot currently grant (error.params.reason is scope_not_grantable or scope_admin_required)188458406
The organization's credential policy refuses the requested scopes (error.params.reason is a credential_policy_* value; 503 when the policy could not be read, retryable)101463403 or 503
tools invalid (error.params.reason is tools_invalid, with a tools_reason sub-code naming which constraint failed)189043406
tools would put back in force a tool that a place's own saved limit still lists but its access mode had switched off, because this change raises that place's access mode through another entry (its workspace or organization). error.params.tools_reason is descendant_widened; key names the place, tool the first such tool, and via the entry that raises it. Remove the tool from that place's limit, or raise the place's own entry if you mean to give it189043406
tools omitted and the connection's saved tool limits cannot be read (error.params.reason is tool_policy_orphaned). Send tools with the limits to keep, or {} to clear them101676406
tools omitted on a connection that has tool limits, and the new scopes are not a pure narrowing of the current ones (error.params.reason is tools_required). Send tools with the change177320406
expected_revision does not match the connection's current grant_revision (error.params.reason is grant_revision_conflict; error.params includes the current grant_revision and the current session object — re-render and retry)106560409
Connection revoked while the replacement was being saved189275404
Grant saved but not yet fully propagated everywhere (error.params.reason is grant_saved_pending; error.params includes the session object; the grant IS saved — re-read the session shortly; enforcement catches up within a minute)153477503
Internal error111080, 102649, 161955500

DELETE /current/oauth/sessions/{session_id}/

Revoke a specific OAuth session. The session must belong to the authenticated user. This operation is idempotent — if the session is already revoked, success is still returned.

DELETE /current/oauth/sessions/{session_id}/

Auth: Required (Bearer JWT)

Path Parameters:

ParameterTypeRequiredDescription
session_idstringYes32 hexadecimal characters
curl -X DELETE "https://api.fast.io/current/oauth/sessions/a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4/" \
  -H "Authorization: Bearer {jwt_token}"

Response (200 OK):

{
  "result": true,
  "message": "Session has been revoked."
}

Error Responses:

ScenarioError CodeHTTP Status
Not authenticated1650 (Authentication Invalid)401
session_id missing1605 (Invalid Input)406
session_id not 32 hex chars1605 (Invalid Input)406
Session not found or wrong user1609 (Not Found)404
Internal error1654 (Internal Error)500

DELETE /current/oauth/sessions/

Revoke all OAuth sessions (logout everywhere). Optionally exclude the current session for "log out everywhere else" functionality.

DELETE /current/oauth/sessions/

Auth: Required (Bearer JWT)

Query Parameters:

ParameterTypeRequiredDefaultDescription
exclude_currentstringNo—Set to "true" or "1" to keep the current session active
current_session_idstringNo—Session ID to preserve when exclude_current is set. If omitted (or exclude_current is unset), all sessions are revoked.
# Revoke ALL sessions
curl -X DELETE "https://api.fast.io/current/oauth/sessions/" \
  -H "Authorization: Bearer {jwt_token}"

# Revoke all EXCEPT current session
curl -X DELETE "https://api.fast.io/current/oauth/sessions/?exclude_current=true&current_session_id=a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4" \
  -H "Authorization: Bearer {jwt_token}"

Response (200 OK):

{
  "result": true,
  "message": "All sessions have been revoked."
}

Or when exclude_current is used:

{
  "result": true,
  "message": "All other sessions have been revoked."
}

Error Responses:

ScenarioError CodeHTTP Status
Not authenticated1650 (Authentication Invalid)401
Internal error1654 (Internal Error)500

When exclude_current is "true" but current_session_id is not provided, all sessions are revoked.

Restricted Connections

A restricted connection is a connection that has per-place tools set (its tools are narrower than the access mode's default tool set somewhere; GET /current/auth/scopes/ then reports tool_enforcement: true). When a client calls the API using a restricted connection's own token:

Each of these refusals carries error.params = {"reason": "tool_disabled", "tool": "admin", "place": {"type": "user", "id": "*", "name": null}}.

All of these are refused for the same reason: a tool-restricted connection cannot mint or widen a credential, change who can sign in, administer the account's other connections, or otherwise use itself to gain broader standing than it was granted. A credential it minted would carry no tool limits and would keep working after the connection is revoked. A plain, unrestricted connection is unaffected by any of this.

AI chats started through a connection follow that connection's live limits on every request. When an agent using a connection's token starts or continues an AI chat, every action the AI agent takes for that chat is bounded by the connection's CURRENT access: its tool limits, its access modes, the places it still covers (a place the connection no longer covers is refused), and its revocation (once the connection is revoked, the AI agent's next action is refused). A limit set or tightened while a chat is in progress applies to the agent's next action. For a connection without tool limits, the AI agent behaves as before, within the connection's access.

More generally, any API call made with a tool-restricted connection is refused with 403 when the specific tool it needs is not in that connection's allowed list for the relevant place. The error has error.code 116782 and error.params = {"reason": "tool_disabled", "tool": "<tool>", "place": {"type": "<entity type>", "id": "<entity id>", "name": "<display name or null>"}}, e.g. {"reason": "tool_disabled", "tool": "delete", "place": {"type": "workspace", "id": "1234567890123456789", "name": "Marketing"}}. Branch on error.params.reason, not on the code.

Token Scope Introspection

GET /current/auth/scopes/

Returns the current token's scope information, auth type, and agent status. Enables clients to discover their token's capabilities without decoding the JWT.

GET /current/auth/scopes/

Auth: Required (Bearer JWT or API Key)

curl -X GET "https://api.fast.io/current/auth/scopes/" \
  -H "Authorization: Bearer {jwt_token}"

Response (200 OK):

{
  "result": true,
  "auth_type": "jwt_v2",
  "scopes": ["org:3814271023567182934:rw", "org:3829104758203948571:rw"],
  "scopes_detail": [
    {
      "entity_type": "org",
      "entity_id": "3814271023567182934",
      "access_mode": "rw",
      "admin": false,
      "name": "Acme Corp",
      "domain": "acme"
    },
    {
      "entity_type": "org",
      "entity_id": "3829104758203948571",
      "access_mode": "rw",
      "admin": false,
      "name": "Beta Inc",
      "domain": "beta"
    }
  ],
  "is_agent": true,
  "agent_name": "My MCP Agent",
  "full_access": false,
  "admin": false,
  "legacy": false
}

Response (200 OK — API-key auth with scopes and expiration):

{
  "result": true,
  "auth_type": "api_key_scoped",
  "scopes": ["org:3814271023567182934:rw"],
  "scopes_detail": [
    {
      "entity_type": "org",
      "entity_id": "3814271023567182934",
      "access_mode": "rw",
      "admin": false,
      "name": "Acme Corp",
      "domain": "acme"
    }
  ],
  "is_agent": true,
  "agent_name": "CI Pipeline",
  "full_access": false,
  "admin": false,
  "legacy": false,
  "expires": "2026-12-31 23:59:59 UTC"
}

Response Fields:

FieldTypeDescription
resultbooleantrue on success
auth_typestring"jwt_v2" (scoped JWT, or a sign-in session that declared agent_name — still unscoped, so legacy stays true), "jwt_v1" (legacy JWT: a browser login session without a declared agent), "api_key" (legacy API key that declares no scopes claim), or "api_key_scoped" (API key whose stored token record has a non-empty scopes claim)
scopesarrayScope strings in entity_type:entity_id:access_mode format. Empty only for a legacy credential that declares no scopes claim at all (a browser login session — including one that declared agent_name, which is unscoped — or a pre-scopes API key).
scopes_detailarrayHydrated scope objects with entity names and metadata, one per entry in scopes. Empty when scopes is empty.
is_agentbooleanWhether the token represents an agent. true for a sign-in session that declared agent_name at GET /current/user/auth/ (the name is reported in agent_name).
agent_namestring or nullAgent display name if set, otherwise null
full_accessbooleanWhether the credential is account-wide and may write — user:*:rw or user:*:rwa. true for legacy (unscoped) API keys and browser login sessions; false for user:*:r and for entity-scoped credentials. It is not an administration flag — read admin for that
adminbooleanWhether the credential can perform administrative operations: true for a browser login session and for any credential holding an rwa scope (still capped by the human's live role). Always present
legacybooleanWhether the credential declares no scopes claim at all — a pre-scopes API key, a pre-scopes OAuth session, and every browser login session. Always present. legacy never implies admin, and admin never implies legacy
expiresstringAPI-key auth only: token expiration timestamp in canonical YYYY-MM-DD HH:MM:SS UTC format. Omitted entirely when the token has no expiration set or when auth is JWT-based.

Scope Detail Fields:

Each entry in scopes_detail contains entity-specific fields:

FieldTypePresent ForDescription
entity_typestringAlluser, org, workspace, share, sign_envelope, fileshare, memory, or userdetails
entity_idstringAllNumeric ID or * for wildcard
access_modestringAllr (read), rw (read/write) or rwa (read/write/administer)
adminbooleanAlltrue when this entry's access mode is rwa, so a client need not re-parse the scope string
labelstringFull access / wildcardHuman-readable label (e.g., "Full Access", "All Organizations")
namestringOrg, WorkspaceEntity display name
domainstringOrgOrganization subdomain
folder_namestringWorkspaceWorkspace URL slug
org_idstringWorkspace, Share, Sign envelope, File shareParent organization ID
org_namestringWorkspace, Share, Sign envelope, File shareParent organization name
org_domainstringWorkspace, Share, Sign envelope, File shareParent organization subdomain
titlestringShareShare display title
share_typestringSharesend, receive, or exchange
envelope_statusstringSign envelopeThe envelope's current status
workspace_idstringShare, Sign envelope, File shareParent workspace ID
workspace_namestringShare, Sign envelope, File shareParent workspace name
load_errorstringOn failure"Entity not found" if the referenced entity could not be loaded

Labels follow the access mode: user:*:rw renders as "Full Access", user:*:rwa as "Full Access (Admin)", user:*:r as "Entire account (Read Only)" and userdetails:*:rw as "Account settings"; other wildcards render in the same style, e.g. "All Organizations (Read/Write/Admin)".

How the flags combine:

Credentialfull_accessadminlegacyauth_type
user:*:rwatruetruefalsejwt_v2 / api_key_scoped
user:*:rwtruefalsefalsejwt_v2 / api_key_scoped
user:*:rfalsefalsefalsejwt_v2 / api_key_scoped
Scoped, e.g. org:123:rwafalsetruefalsejwt_v2 / api_key_scoped
Legacy key with no scopestruefalsetrueapi_key
Browser login sessiontruetruetruejwt_v1
Sign-in session that declared agent_nametruetruetruejwt_v2

Error Responses:

ScenarioError CodeHTTP Status
Not authenticated1650 (Authentication Invalid)401
Wrong HTTP method109463405

Complete PKCE Example Flow

Here is a complete example of the PKCE authorization flow:

0. CLIENT -> API: Discover server configuration (optional)
   GET /.well-known/oauth-authorization-server/
   -> Returns endpoints, grant types, PKCE methods, resource_indicators_supported

0b. CLIENT -> API: Register client dynamically (if no client_id)
   POST /current/oauth/register/
   Content-Type: application/json
   {"client_name": "My Agent", "redirect_uris": ["http://localhost:8080/callback"]}
   -> Returns client_id, registration_access_token

   OR use a CIMD URL as client_id (no registration needed)

1. CLIENT: Generate PKCE parameters
   code_verifier  = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"
   code_challenge = base64url(sha256(code_verifier))
                  = "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"

2. CLIENT -> BROWSER: Open authorization URL in user's browser
   GET /current/oauth/authorize/
   ?client_id=my-app
   &redirect_uri=http://localhost:8080/callback
   &response_type=code
   &code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM
   &code_challenge_method=S256
   &state=xyz123
   &resource=https://mcp.fast.io/mcp

3. API -> BROWSER: 302 redirect to login/consent page
   (programmatic clients add &response_format=json and open the returned login_url verbatim)
   -> Browser lands on login page, user signs in, approves access

4. BROWSER -> CLIENT: Authorization code returned
   http://localhost:8080/callback?code=AUTH_CODE_HERE&state=xyz123
   (or, with display_code=true, displayed on screen for the user to copy)

5. CLIENT -> API: Exchange code for tokens
   POST /current/oauth/token/
   Content-Type: application/x-www-form-urlencoded

   grant_type=authorization_code
   &code=AUTH_CODE_HERE
   &code_verifier=dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk
   &client_id=my-app
   &redirect_uri=http://localhost:8080/callback
   &resource=https://mcp.fast.io/mcp

6. API -> CLIENT: Tokens returned (bare JSON)
   {
     "access_token": "eyJ...",
     "token_type": "Bearer",
     "expires_in": 3600,
     "refresh_token": "4f9a2c...",
     "scope": "user"
   }

7. CLIENT -> API: Use access token for requests
   GET /current/user/details/
   Authorization: Bearer eyJ...

8. CLIENT -> API: Refresh when access token expires
   POST /current/oauth/token/
   Content-Type: application/x-www-form-urlencoded

   grant_type=refresh_token
   &refresh_token=4f9a2c...
   &client_id=my-app

   -> Returns a new access_token; the same refresh_token is returned unchanged

9. CLIENT -> API: Revoke on logout
   POST /current/oauth/revoke/
   Content-Type: application/x-www-form-urlencoded

   token=4f9a2c...

Error Handling

Token and Revoke Endpoints (RFC 6749 SS5.2)

The token (POST /current/oauth/token/) and revoke (POST /current/oauth/revoke/) endpoints return RFC 6749 compliant error responses — bare JSON, not the standard platform envelope:

{
  "error": "invalid_grant",
  "error_description": "The authorization code is invalid or has expired."
}

Registration Endpoint (RFC 7591)

The registration endpoint (POST /current/oauth/register/ and PUT /current/oauth/register/) also returns bare JSON error responses with RFC 7591 error codes:

{
  "error": "invalid_client_metadata",
  "error_description": "The client_name must be between 1 and 128 characters."
}

Other OAuth Endpoints

All other OAuth endpoints (authorize, authorize/info, sessions) use the standard platform error envelope:

{
  "result": false,
  "error": {
    "code": 116775,
    "text": "The client_id is invalid or not found.",
    "documentation_url": "https://api.fast.io/llms.txt",
    "resource": "GET /current/oauth/authorize/"
  }
}
ScenarioError CodeHTTP Status
Rate limited1671 (Rate Limited)429
↑ Back to top