logo
AuthenticationOAuth 2.0 Integration

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.

EnvironmentAuth HostAPI Host
Productionauth.letsplaymoney.comapp.letsplaymoney.com
Developmentauth.wasim.ioapp.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 ────────────────┘
  1. Client redirects the user to the auth host's /oauth/authorize with PKCE challenge

  2. User authenticates (email/password, social, or 2FA) on the auth host

  3. Auth host redirects back to client with an authorization code

  4. Client exchanges the code + PKCE verifier for an access token at /oauth/token

  5. 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:

ParameterValue
Grant typesauthorization_code, client_credentials
PKCERequired (all authorization code grants)
Default scopeuser
Optional scopeservice
Access token TTL15 minutes
Client credentials TTL1 hour
Refresh tokensEnabled
TLS on redirect URIsRequired 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
ParameterRequiredDescription
client_idYesYour Doorkeeper application UID
redirect_uriYesMust match a registered redirect URI exactly
response_typeYesAlways code
scopeNoDefaults to user. Use service for service-level access
stateYesCSRF protection — random string, verify on callback
code_challengeYesBase64url-encoded SHA-256 of your code_verifier
code_challenge_methodYesAlways S256

Step 3 — User Authenticates

The auth host serves an SPA at /auth/* that handles:

  • Email/password loginPOST /auth/session with { 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 via POST /auth/session/two_factor with { challengeToken, code }

  • RegistrationPOST /auth/register with { 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:

  1. Doorkeeper token check — the Authorization: Bearer value is validated as a Doorkeeper access token. If valid and not expired:

    • If the token has a resource_owner_id → resolves to a Person (user token)

    • If the token has only an application_id → resolves to a ServiceAccount (client credentials token)

  2. Legacy fallback — if Doorkeeper doesn't match, the Bearer value is checked against the people.api_token column (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.

MethodPathDescription
POST/auth/sessionEmail/password login. Returns { success: true } or { requiresTwoFactor, challengeToken }
POST/auth/session/two_factorVerify 2FA code. Params: { challengeToken, code }
POST/auth/registerCreate account. Params: { firstName, lastName, email, password }
POST/auth/password/resetSend password reset email
GET/auth/google/callbackGoogle OAuth callback (OmniAuth)
GET/auth/apple/callbackApple 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 (and client_secret if 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, exchange code for 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