Skip to main content

Authentication

The REST API authenticates every request with an OAuth2 / OIDC access token (a Bearer JWT) issued by our identity provider (Keycloak). There is no separate API-key mechanism — server-to-server integrations use the OAuth2 Client Credentials flow to obtain the same kind of token.

Using an access token​

Include the token in the Authorization header on every request:

curl -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
https://api.tourfold.com/api/v2/areas

Requests without a valid token receive 401 Unauthorized — see Error responses for the payload and the retry rules.

Obtaining access tokens​

Tokens are issued by our Keycloak realm. The realm's endpoints are discoverable at:

https://auth.tourfold.com/auth/realms/tourfold/.well-known/openid-configuration
  1. Redirect the user to the authorization endpoint:
https://auth.tourfold.com/auth/realms/tourfold/protocol/openid-connect/auth
?client_id=YOUR_CLIENT_ID
&response_type=code
&redirect_uri=YOUR_REDIRECT_URI
&scope=openid profile email
&state=YOUR_STATE_VALUE
  1. Exchange the authorization code for tokens:
curl -X POST https://auth.tourfold.com/auth/realms/tourfold/protocol/openid-connect/token \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "grant_type=authorization_code" \
-d "client_id=YOUR_CLIENT_ID" \
-d "client_secret=YOUR_CLIENT_SECRET" \
-d "code=AUTHORIZATION_CODE" \
-d "redirect_uri=YOUR_REDIRECT_URI"

Response:

{
"access_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...",
"token_type": "Bearer",
"expires_in": 300,
"refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"id_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9..."
}

Client Credentials Flow (server-to-server)​

For background jobs and automated systems, use a confidential client and the client-credentials grant to obtain a token directly — no user interaction:

curl -X POST https://auth.tourfold.com/auth/realms/tourfold/protocol/openid-connect/token \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "grant_type=client_credentials" \
-d "client_id=YOUR_CLIENT_ID" \
-d "client_secret=YOUR_CLIENT_SECRET"

Scopes and authorization​

Request the standard OIDC scopes when authenticating:

  • openid — required for OIDC compliance
  • profile — access to basic profile claims
  • email — access to the email claim

Access to individual resources is governed by the caller's workspace roles and grants, not by OAuth scopes. The token identifies who you are and which workspace you act in; what you may read or write is decided by that identity's role assignments. A token that is valid but lacks the required grant receives 403 Forbidden, with the missing grant named in data.required_grants where it is statically known.

Token management​

Refreshing an access token​

Access tokens are short-lived. Use the refresh token to obtain a new one:

curl -X POST https://auth.tourfold.com/auth/realms/tourfold/protocol/openid-connect/token \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "grant_type=refresh_token" \
-d "client_id=YOUR_CLIENT_ID" \
-d "client_secret=YOUR_CLIENT_SECRET" \
-d "refresh_token=YOUR_REFRESH_TOKEN"

Revoking a session​

curl -X POST https://auth.tourfold.com/auth/realms/tourfold/protocol/openid-connect/logout \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "client_id=YOUR_CLIENT_ID" \
-d "client_secret=YOUR_CLIENT_SECRET" \
-d "refresh_token=YOUR_REFRESH_TOKEN"

Security best practices​

  • Store tokens and client secrets securely — environment variables or a secrets manager, never in version control.
  • Handle expiry — implement refresh-token logic for long-running applications.
  • Use HTTPS only — never send tokens over unencrypted connections.
  • Request only the scopes you need.
  • Use a distinct client per environment — separate clients/credentials for development, staging, and production.

Enterprise: dedicated realm​

By default all workspaces authenticate against the shared tourfold realm. Enterprise workspaces can be provisioned with their own dedicated Keycloak realm — for example to bring their own identity provider / SSO, custom password and session policies, or isolated user federation. When a dedicated realm is in use, the tourfold segment in the endpoints above is replaced by the workspace's realm name. Contact support to arrange a dedicated realm.

Error responses​

Authentication and authorization failures are RFC 9457 problem documents served as application/problem+json, exactly like every other API error. Two statuses are relevant:

  • 401 Unauthorized — the request carried no usable identity. Obtaining or refreshing credentials is what fixes it.
  • 403 Forbidden — the request is authenticated, but that identity is not permitted to perform the action. Different credentials will not help; different grants will.

Both carry a machine-readable data.reason so a client can decide what to do next without parsing human-readable detail prose.

The authorization vocabulary below applies to access-denied and narrower authorization-denial types. A type-specific 403 may define its own reason value; the existing tenant-disabled problem is documented below as the explicit example.

note

Treat data as optional — never dereference data.reason without a presence check. data.reason is always present on 401. On 403 it is present whenever the cause is known, and data.required_grants only when the denial is grant-based and the required grants are statically known (a check implemented as a custom rule rather than a grant cannot name one).

401 — not authenticated​

Returned when the Authorization header is absent, or the token is expired, malformed, or issued by an untrusted issuer:

{
"type": "https://problems.tourfold.com/authentication-required",
"title": "Authentication required",
"status": 401,
"detail": "Valid authentication credentials are required",
"data": {
"reason": "token_expired"
}
}

The problem type stays authentication-required for all three causes — the cause is carried in data.reason, not in a separate type URI:

data.reasonCauseWhat the client should do
token_missingNo credentials were presented at all.Authenticate using one of the flows above. Retrying the request unchanged fails identically.
token_expiredA well-formed token that is past its expiry.Refresh the access token and replay the request once. This is the only reason that warrants an automatic retry.
token_invalidMalformed token, bad signature, or an untrusted issuer.Do not blind-retry — the same token keeps failing. Discard it and authenticate again.

Every 401 also carries a WWW-Authenticate header:

  • A token was presented but rejected → the challenge includes the RFC 6750 error parameters, for example WWW-Authenticate: Bearer error="invalid_token", error_description="...".
  • No token was sent → a bare WWW-Authenticate: Bearer.

One other problem type maps to 401: https://problems.tourfold.com/login-failed ("Login failed"). It covers a rejected credential-based login, not an absent or unusable bearer token.

Implementing token refresh​

The reason vocabulary maps directly onto a refresh loop:

  1. 401 with data.reason = token_expired → exchange the refresh token for a new access token, then replay the original request once.
  2. 401 with data.reason = token_missing → no credentials reached the API. Fix the client and authenticate.
  3. 401 with data.reason = token_invalid → stop. Drop the cached token and start a fresh authentication; retrying is guaranteed to fail and only consumes rate-limit budget.
  4. The refresh call itself fails → the refresh token is expired or revoked; re-run the full authorization flow.

403 — authenticated but not permitted​

Returned when the caller is known but is not allowed to perform the action:

{
"type": "https://problems.tourfold.com/access-denied",
"title": "Access denied",
"status": 403,
"detail": "You don't have permission to perform this action",
"data": {
"reason": "missing_grant",
"required_grants": ["users:update"]
}
}

For access-denied and narrower authorization-denial types, data.reason is one of:

data.reasonMeaning
missing_grantThe caller lacks one of the grants the operation requires.
not_ownerThe caller is not the owner of the target resource.
not_authorThe caller did not author the target resource.
not_memberThe caller is not a member of the group the resource belongs to.
plan_restrictedThe workspace's plan or feature flags do not include this capability.
tenant_scopeThe resource belongs to a different workspace than the caller's.
resource_lockedThe resource is in a state that forbids the action for everyone.

A deactivated workspace is reported as the type-specific https://problems.tourfold.com/tenant-disabled problem with data.reason = TENANT_INACTIVE. Branch on type before interpreting type-specific data; do not treat TENANT_INACTIVE as an additional value in the closed authorization-denial list.

data.required_grants lists the grants that would have satisfied the check. It is present only when the denial is grant-based and the required grants are statically known, so clients must treat it as optional. Grant values are colon-delimited — for example users:read, users:update, tenant:ownership:update, custom-objects:definitions:update, webhooks:create.

Retrying a 403 never helps on its own: either the caller needs a role that carries the missing grant, or the action is unavailable for that resource.

The internal admin control plane (admin-v1) is intentionally thinner. It authenticates callers and returns 401 in exactly the shape above, but it has no per-grant authorization model, so it does not produce per-grant 403s.

Most workspace-scoped operations also pass through tenant-state guards. A deactivated workspace returns 403 tenant-disabled; a billing-locked workspace returns 402 tenant-billing-locked. Billing portal, payment-method, and billing recheck operations remain available so the workspace can recover. These are workspace-state failures rather than token-authentication failures.

Branch on type and status, not on detail​

A feature may return a more specific namespaced problem type in place of the generic access-denied — for example https://problems.tourfold.com/comments/cannot-edit-others. The status is still 403 and data.reason still carries the coarse machine-readable cause, so code written against status plus data.reason keeps working while a client that needs the precise case can match the narrower type.

type URIs and HTTP statuses are stable contract. title and detail are human-readable and may be reworded or localized at any time — never branch on them.

See Errors for the full problem+json format and the error types reference for the complete type catalog.

Rate limiting​

Authentication is subject to rate limiting. See Rate limiting for details.

Next steps​