Skip to content

Last1 ID OAuth2/OIDC Integration (v1)

Last1 ID supports OAuth 2.0 Authorization Code flow with PKCE as the primary standard for cross-platform login and consented API access.

Canonical Issuer

  • Production issuer: https://auth.last1.id
  • Discovery document: https://auth.last1.id/.well-known/openid-configuration
  • JWKS: https://auth.last1.id/.well-known/jwks.json
  • Do not use https://last1.id as the issuer. That host is marketing HTML, not OIDC JSON.

Endpoints

  • Authorization: GET /oauth/authorize
  • Token: POST /oauth/token
  • OIDC discovery: GET /.well-known/openid-configuration
  • JWKS: GET /.well-known/jwks.json
  • Consent validation helper: GET /api/oauth/authorize/validate
  • Consent approval helper (signed-in user): POST /api/oauth/authorize/approve

Creating Client Credentials (Org Admin)

  • Open App clients in the Last1 ID admin portal.
  • Click Register app, fill app ID + name, then submit.
  • Save:
  • client_id: click Copy Client ID (the row UUID). The App ID slug is also accepted on /oauth/authorize.
  • client_secret (shown once in registration success modal)
  • If client_secret is lost, click Regenerate client secret on the app row and update your deployment secret store.

Required Authorization Request Params

  • response_type=code
  • client_id
  • redirect_uri (must exactly match registered URI)
  • scope (space-delimited)
  • state
  • code_challenge (PKCE S256 required for public clients)
  • code_challenge_method=S256

Optional: - nonce (recommended; required when requesting openid)

Token Request

POST /oauth/token

Supported grant types: - authorization_code - refresh_token

For authorization_code: - grant_type=authorization_code - code - redirect_uri - client_id - code_verifier (required for PKCE/public clients)

For refresh_token: - grant_type=refresh_token - refresh_token - client_id

Client authentication: - Confidential clients: client_secret_basic or client_secret_post - Public clients: none + PKCE

Token Response

  • access_token
  • token_type (Bearer)
  • expires_in
  • scope
  • refresh_token
  • refresh_expires_in
  • id_token (included when openid scope granted)

Supported Scopes

  • openid
  • profile
  • offline_access
  • identity:read
  • credentials:read
  • credentials:write
  • credentials:issue
  • credentials:verify
  • app_links:read
  • trust_events:read
  • trust_events:write

Security Requirements

  • Exact redirect URI matching (no wildcard redirects)
  • PKCE (S256) for public clients
  • Single-use short-lived authorization codes
  • Rotating refresh tokens
  • Token exchange audit logging
  • Per-IP rate limiting on OAuth endpoints
  • Discovery issuer must exactly match configured LAST1_ISSUER_URL
  • JWKS must contain at least one signing key (RS256 expected in production)

Identity Linkage Contract

After a successful /oauth/token exchange, the partner receives:

  • id_token — JWT whose sub claim is the user's last1.id identity UUID. This is the canonical key the partner should store on its user record (e.g. veterans.last1_identity_id on LastVet, users.last1_identity_id on RadioCheck).
  • access_token — JWT for future calls into scoped last1.id APIs. (Access-token validation on resource routes is a future enhancement; today the id_token carries everything a partner needs to link.)

On the last1.id side, the token endpoint upserts a row into identity_app_links keyed on (identity_id, app_id). Per the post-2026-05 contract, identity_app_links.app_user_id is intentionally NULL after an OAuth login. The OAuth authorization-code flow does not carry the partner's internal user id, and writing the last1.id identity UUID into app_user_id (as earlier versions did) corrupted the column's semantic meaning.

If a partner wants app_user_id populated for bidirectional discoverability on the last1.id consent dashboard, they should call the (future) POST /api/app-links/register endpoint with their app secret and their internal user id after creating the local user record from id_token.sub. Most partners do not need this — keeping the link in their own DB via last1_identity_id is sufficient.

The legacy POST /api/identity/authorize/complete flow is dual-run and being retired. RadioCheck and MATR still use /authorize?app=... until they ship PKCE. New integrations must not start on the bridge. After both partners ship PKCE, /authorize?app= and authorize/complete retire.

Sign in with Last1 ID — End-to-End

For a partner with a backend (e.g. last.vet-provider talking to last.vet-api):

  1. Frontend generates a PKCE pair, stores code_verifier in session storage, and redirects to:
https://auth.last1.id/oauth/authorize
  ?response_type=code
  &client_id=<Copy-Client-ID-UUID-or-app-id-slug>
  &redirect_uri=<your-registered-callback>
  &scope=openid+email+profile+offline_access+identity:read
  &state=<csrf-token>
  &nonce=<one-time-nonce>
  &code_challenge=<base64url-sha256(verifier)>
  &code_challenge_method=S256
  1. last1.id authenticates the user (Supabase Auth), shows the consent screen, and on Allow redirects to:
<your-registered-callback>?code=<auth-code>&state=<csrf-token>
  1. Frontend posts { code, code_verifier, state } to its own backend (do not exchange the code in the browser for confidential clients).

  2. Backend exchanges the code:

POST https://auth.last1.id/oauth/token
Content-Type: application/x-www-form-urlencoded

grant_type=authorization_code
&code=<auth-code>
&redirect_uri=<your-registered-callback>
&client_id=<Copy-Client-ID-UUID-or-app-id-slug>
&client_secret=<your-app-secret>   # confidential clients
&code_verifier=<original-verifier>
  1. Backend verifies the returned id_token JWT against https://auth.last1.id/.well-known/jwks.json, asserts iss, aud=<client_id>, nonce, and a recent iat. Then upserts the local user record keyed on id_token.sub and issues a local session.

Backward Compatibility

The /authorize?app= + POST /api/identity/authorize/complete bridge is dual-run only and is being retired. Do not start new integrations on it. Use Authorization Code + PKCE.