Authentication
OAuth 2.0 Authorization Code + PKCE integration guide for internal services and frontends.
Play Money uses OAuth 2.0 Authorization Code with PKCE for all authentication. The implementation is powered by Doorkeeper and runs on a dedicated auth host — a separate subdomain that serves only OAuth and session endpoints.
| Environment | Auth Host | API Host |
|---|---|---|
| Production | auth.letsplaymoney.com | app.letsplaymoney.com |
| Development | auth.wasim.io | app.wasim.io |
All OAuth routes (/oauth/authorize, /oauth/token) and the login UI (/auth/*) are only available on the auth host. API endpoints live on the API host.
Architecture Overview
┌──────────┐ ┌──────────────┐ ┌──────────────┐
│ Client │────▶│ Auth Host │────▶│ API Host │
│ (SPA) │ │ OAuth + │ │ /api/v1/* │
│ │◀────│ Login UI │◀────│ │
└──────────┘ └──────────────┘ └──────────────┘
│ ▲
│ Authorization Code │
│ + PKCE exchange │
└───────── Access Token ────────────────┘
-
Client redirects the user to the auth host's
/oauth/authorizewith PKCE challenge -
User authenticates (email/password, social, or 2FA) on the auth host
-
Auth host redirects back to client with an authorization code
-
Client exchanges the code + PKCE verifier for an access token at
/oauth/token -
Client uses the access token as a Bearer token against the API host
Configuration
These values are fixed — you don't need to negotiate them:
| Parameter | Value |
|---|---|
| Grant types | authorization_code, client_credentials |
| PKCE | Required (all authorization code grants) |
| Default scope | user |
| Optional scope | service |
| Access token TTL | 15 minutes |
| Client credentials TTL | 1 hour |
| Refresh tokens | Enabled |
| TLS on redirect URIs | Required in production, relaxed in development |
PKCE is enforced server-side via force_pkce. Authorization requests without code_challenge and code_challenge_method will be rejected.
Authorization Code + PKCE Flow
Step 1 — Generate PKCE Pair
Before redirecting, generate a code_verifier (random 43-128 character string) and derive the code_challenge from it:
function generateCodeVerifier(): string {
const array = new Uint8Array(32);
crypto.getRandomValues(array);
return btoa(String.fromCharCode(...array))
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=+$/, "");
}
async function generateCodeChallenge(verifier: string): Promise<string> {
const encoder = new TextEncoder();
const data = encoder.encode(verifier);
const digest = await crypto.subtle.digest("SHA-256", data);
return btoa(String.fromCharCode(...new Uint8Array(digest)))
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=+$/, "");
}
Store code_verifier securely on the client (memory or session storage — never in a URL or localStorage).
Step 2 — Redirect to Authorize
Redirect the user's browser to the auth host:
GET https://{AUTH_HOST}/oauth/authorize
?client_id={CLIENT_ID}
&redirect_uri={REDIRECT_URI}
&response_type=code
&scope=user
&state={RANDOM_STATE}
&code_challenge={CODE_CHALLENGE}
&code_challenge_method=S256
| Parameter | Required | Description |
|---|---|---|
client_id | Yes | Your Doorkeeper application UID |
redirect_uri | Yes | Must match a registered redirect URI exactly |
response_type | Yes | Always code |
scope | No | Defaults to user. Use service for service-level access |
state | Yes | CSRF protection — random string, verify on callback |
code_challenge | Yes | Base64url-encoded SHA-256 of your code_verifier |
code_challenge_method | Yes | Always S256 |
Step 3 — User Authenticates
The auth host serves an SPA at /auth/* that handles:
-
Email/password login —
POST /auth/sessionwith{ email, password } -
Social login — Google (
/auth/google/callback) and Apple (/auth/apple/callback) via OmniAuth -
Two-factor authentication — if the user has 2FA enabled, the login response returns
{ requiresTwoFactor: true, challengeToken: "..." }. The client then verifies viaPOST /auth/session/two_factorwith{ challengeToken, code } -
Registration —
POST /auth/registerwith{ firstName, lastName, email, password }
Once authenticated, the auth host either:
-
Non-confidential apps (most internal clients): skips consent and redirects immediately with the authorization code
-
Confidential apps: shows a consent screen and the user approves/denies
Step 4 — Handle the Callback
After authentication and (optional) consent, the auth host redirects to your redirect_uri:
GET {REDIRECT_URI}?code={AUTHORIZATION_CODE}&state={STATE}
Always verify that the returned state matches what you sent in Step 2.
Step 5 — Exchange Code for Tokens
POST https://{AUTH_HOST}/oauth/token
Content-Type: application/x-www-form-urlencoded
grant_type=authorization_code
&code={AUTHORIZATION_CODE}
&redirect_uri={REDIRECT_URI}
&client_id={CLIENT_ID}
&code_verifier={CODE_VERIFIER}
Response:
{
"access_token": "eyJ...",
"token_type": "Bearer",
"expires_in": 900,
"refresh_token": "abc123...",
"scope": "user",
"created_at": 1711324800
}
Step 6 — Use the Access Token
Include the access token as a Bearer token in API requests:
GET https://{API_HOST}/api/v1/me
Authorization: Bearer {ACCESS_TOKEN}
Step 7 — Refresh When Expired
Access tokens expire after 15 minutes. Use the refresh token to get a new pair without re-authenticating:
POST https://{AUTH_HOST}/oauth/token
Content-Type: application/x-www-form-urlencoded
grant_type=refresh_token
&refresh_token={REFRESH_TOKEN}
&client_id={CLIENT_ID}
Response contains a new access_token and refresh_token. The old refresh token is invalidated.
Client Credentials Flow
For server-to-server communication where no user is involved (service accounts):
POST https://{AUTH_HOST}/oauth/token
Content-Type: application/x-www-form-urlencoded
grant_type=client_credentials
&client_id={CLIENT_ID}
&client_secret={CLIENT_SECRET}
&scope=service
Client credentials tokens expire after 1 hour and are associated with a ServiceAccount rather than a Person. The API layer resolves the actor from the token's application_id.
How Token Validation Works
Every authenticated API request goes through the Authenticatable concern:
-
Doorkeeper token check — the
Authorization: Bearervalue is validated as a Doorkeeper access token. If valid and not expired:-
If the token has a
resource_owner_id→ resolves to aPerson(user token) -
If the token has only an
application_id→ resolves to aServiceAccount(client credentials token)
-
-
Legacy fallback — if Doorkeeper doesn't match, the Bearer value is checked against the
people.api_tokencolumn (legacy tokens from the old auth system)
Legacy api_token auth still works during the transition period. New integrations should always use OAuth tokens.
Auth Host Session Endpoints
These JSON endpoints are consumed by the auth UI SPA. They're useful context for understanding the flow, but you shouldn't need to call them directly — the OAuth authorize redirect handles session establishment automatically.
| Method | Path | Description |
|---|---|---|
POST | /auth/session | Email/password login. Returns { success: true } or { requiresTwoFactor, challengeToken } |
POST | /auth/session/two_factor | Verify 2FA code. Params: { challengeToken, code } |
POST | /auth/register | Create account. Params: { firstName, lastName, email, password } |
POST | /auth/password/reset | Send password reset email |
GET | /auth/google/callback | Google OAuth callback (OmniAuth) |
GET | /auth/apple/callback | Apple OAuth callback (OmniAuth) |
Error Handling
When a token is invalid or expired, the API returns:
{
"errors": [{
"code": "InvalidToken",
"detail": "The provided API token is invalid or expired. Please log in again.",
"requiresLogin": true
}]
}
Status code: 401 Unauthorized
When you receive requiresLogin: true, redirect the user back through the authorization flow (Step 2).
Integration Checklist
-
Register your Doorkeeper application and note the
client_id(andclient_secretif confidential) -
Register your exact
redirect_uri(s) on the application -
Implement PKCE generation and storage
-
Build the authorize redirect URL with all required params
-
Handle the callback — verify
state, exchangecodefor tokens -
Store tokens securely (access token in memory, refresh token in httpOnly cookie or secure storage)
-
Add Bearer token to all API requests
-
Implement token refresh before/on expiry
-
Handle 401 responses by redirecting through the auth flow
Last updated Mar 26, 2026
Built with Documentation.AI