Skip to main content

Authentication

Overview

Upmatches uses OAuth 2.0 with PKCE for authentication, supporting two identity providers: Singpass (FAPI 2.0 with Pushed Authorisation Requests and DPoP) and Auth0 (standard OAuth 2.0 with connection selection). After authentication, the server issues a short-lived access token and a long-lived refresh token.

Tokens are accepted from either the Authorization: Bearer header (mobile / server-to-server) or the access_token HTTP cookie (web BFF) — the header is checked first.

Token Expiry

TokenExpiryStorage
Access token15 minutesJWT (ES256 signed)
Refresh token7 daysOpaque token, stored as SHA-256 hash in Redis

Auth Providers

ProviderUse CaseID Token Format
SingpassSingapore government identityJWE encrypted, ES256 signed
Auth0Social / email loginRS256 signed

API Contract

All auth endpoints are under /api/v1/auth.

GET /api/v1/auth/login/singpass

Initiates Singpass OAuth login.

cURL

curl -X GET "http://localhost:8080/api/v1/auth/login/singpass?platform=mobile"

Response 200 OK (mobile)

{
"authorizationUrl": "https://stg-id.singpass.gov.sg/auth?..."
}

For web, returns a 302 redirect to the authorization URL.

GET /api/v1/auth/login/auth0

Initiates Auth0 OAuth login.

cURL

curl -X GET "http://localhost:8080/api/v1/auth/login/auth0?platform=mobile&provider=google-oauth2"

Response 200 OK (mobile)

{
"authorizationUrl": "https://your-tenant.auth0.com/authorize?..."
}

For web, returns a 302 redirect to the authorization URL.

POST /api/v1/auth/refresh

Refreshes the access token. Accepts the refresh token from either a cookie (web) or request body (mobile). The old refresh token is invalidated and a new one is issued (token rotation).

cURL (mobile)

curl -X POST http://localhost:8080/api/v1/auth/refresh \
-H "Content-Type: application/json" \
-d '{
"refreshToken": "<REFRESH_TOKEN>"
}'

Request body (mobile)

{
"refreshToken": "<REFRESH_TOKEN>"
}

Response 200 OK (mobile)

{
"accessToken": "eyJ...",
"refreshToken": "new-base64-encoded-token",
"expiresAt": 1712234400
}

Response 200 OK (web)

{
"message": "Token refreshed successfully."
}

Tokens are set in HttpOnly cookies for web clients.

GET /callback/singpass and GET /callback/auth0

Internal endpoints invoked by the identity provider at the end of the OAuth flow. They exchange the authorisation code for tokens, create or update the local user, and redirect the browser to FRONTEND_CALLBACK_URL (web) or a custom-scheme deep link using MOBILE_CALLBACK_SCHEME (mobile).

For new users the redirect includes ?isNewUser=true so the client can route into onboarding.

GET /.well-known/jwks.json

Publishes the ES256 public keys used to verify access tokens issued by this service. Cache-friendly; no authentication.

Response 200 OK

{
"keys": [
{
"kty": "EC",
"crv": "P-256",
"kid": "upmatches-2026-04",
"x": "...",
"y": "...",
"use": "sig",
"alg": "ES256"
}
]
}

POST /api/v1/auth/logout

Logs out the user and revokes all refresh tokens.

cURL

curl -X POST http://localhost:8080/api/v1/auth/logout \
-H "Authorization: Bearer <TOKEN>"

Response 200 OK

{
"message": "Logged out successfully."
}

POST /api/v1/auth/dev/tokens

Development-only. Mints access and refresh tokens for an arbitrary userId without going through the OAuth flow. Intended for local testing, Bruno collections, and integration tests.

Gated by:

  1. Active Spring profile is not prod.
  2. app.auth.dev.mint-endpoint-enabled=true.
  3. Request originates from localhost / 127.0.0.1 / ::1 (Host header is checked).

Requests from any other host return 401 Unauthorized.

cURL

curl -X POST http://localhost:8080/api/v1/auth/dev/tokens \
-H "Content-Type: application/json" \
-d '{
"userId": "d290f1ee-6c54-4b01-90e6-d701748f0851"
}'

Request body

FieldTypeValidation
userIdUUIDRequired, must reference an existing user

Response 200 OK

{
"success": true,
"data": {
"accessToken": "eyJ...",
"refreshToken": "new-base64-encoded-token",
"expiresAt": 1712234400
},
"message": "Dev token minted successfully.",
"timestamp": "2026-04-15T12:00:00Z",
"path": "/api/v1/auth/dev/tokens"
}

Access Token Claims

ClaimDescription
subUser UUID
issupmatches (configurable via JWT_ISSUER)
audupmatches (configurable via JWT_AUDIENCE). The resource server accepts any of upmatches:web, upmatches:mobile, or the legacy upmatches.
iatIssued at timestamp
nbfNot before timestamp
expExpiration timestamp
auth_providersingpass or auth0
singpass_uuidSingpass UUID (if applicable)
auth0_uuidAuth0 UUID (if applicable)
CookieHttpOnlySecureSameSiteMax Age
access_tokenYesYes (HTTPS)Strict15 minutes
refresh_tokenYesYes (HTTPS)Strict7 days
token_expiryNoYes (HTTPS)Strict15 minutes

The token_expiry cookie is readable by the client to know when to trigger a refresh.

Error Handling

ScenarioHTTP StatusError CodeClient Behavior
Invalid or expired refresh token401invalid_refresh_tokenRedirect to login
Missing code or state in callback400invalid_requestShow error
Invalid provider400invalid_providerShow error
OAuth state mismatch (expired session)401Restart login flow

Security

  • PKCE: All OAuth flows use code challenge/verifier (S256)
  • Token rotation: Refresh tokens are single-use; a new token is issued on each refresh
  • Token hashing: Refresh tokens are stored as SHA-256 hashes in Redis
  • CSRF protection: OAuth state parameter validated against Redis session (5-minute expiry)
  • Nonce validation: Prevents token replay attacks
  • DPoP proofs: Singpass uses Demonstration of Proof-of-Possession