Authorization flow
Loop supports exactly one user-facing grant: authorization_code with PKCE. No implicit, no password, no device flow (yet). PKCE is required for every client type — confidential or public.
Why only this flow
- Authorization_code + PKCE is the modern OAuth 2.1 default. It works for web apps, mobile, SPAs, and native CLIs alike.
- Implicit is removed from OAuth 2.1 for good reasons (tokens in URLs, no refresh).
- Password grant gives third parties your password — never acceptable for a HIPAA platform.
- Device flow can be added later if we ship TVs or kiosks.
The state machine
┌──────────┐ 1. /authorize ┌─────────────────┐
│ Client │ ─────────────────────► │ identity service │
└──────────┘ └─────────────────┘
▲ │
│ │ 2. Sign-in
│ ▼
│ ┌─────────────────┐
│ │ WorkOS / login │
│ └─────────────────┘
│ │
│ │ 3. Consent screen
│ ▼
│ ┌─────────────────┐
│ 4. redirect with ?code=... │ render consent │
│ ◄─────────────────────────── │ (Allow / Deny) │
│ └─────────────────┘
│ 5. POST /token (code + verifier) │
│ ─────────────────────────────────────►│
│ 6. { access_token, refresh_token } │
│ ◄─────────────────────────────────────│Sequence diagram — full authorization flow
Sequence diagram — token refresh
Sequence diagram — consent denied + revocation
Step 1 — Generate the verifier and challenge
Before redirecting the user, your app generates:
code_verifier— a random string, 43–128 chars,[A-Za-z0-9-._~].code_challenge—BASE64URL(SHA256(code_verifier)).
Store the verifier in your session (encrypted cookie, server-side session, etc.).
import crypto from "node:crypto";
const verifier = crypto.randomBytes(32).toString("base64url");
const challenge = crypto.createHash("sha256").update(verifier).digest("base64url");Step 2 — Redirect to /authorize
GET https://identity.platform.loop.health/v1/oauth/authorize
?response_type=code
&client_id=<your_client_id>
&redirect_uri=<urlencoded redirect URI>
&scope=<space-separated scopes>
&state=<random CSRF token, store in session>
&code_challenge=<base64url SHA256 of verifier>
&code_challenge_method=S256Required query params:
| Param | Required | Notes |
|---|---|---|
response_type | yes | Always code. |
client_id | yes | From app registration. |
redirect_uri | yes | Must match one of the registered URIs exactly. |
scope | yes | Space-separated list. |
state | yes | Random; client uses to detect CSRF. Required even though spec calls it optional. |
code_challenge | yes | PKCE required for all clients. |
code_challenge_method | yes | Always S256. plain is rejected. |
nonce | OIDC only | If you want an id_token, include a nonce. |
Step 3 — User authenticates and consents
The identity service:
- Checks whether the user is signed in. If not, redirects through WorkOS to sign in.
- Looks up the requested scopes vs. what’s already been granted to this
client_idfor this user. - If any requested scope has not been granted before, renders the consent screen with the full list.
- The consent screen shows the app’s name, logo, and a plain-English description of every scope.
- The user clicks Allow or Deny.
On Allow, identity persists a row in oauth_grants (user_id, client_id, scopes_granted, granted_at) and proceeds. On Deny, the user is redirected back with an error.
Step 4 — Redirect back to your app
On approval:
https://your-app.example.com/auth/loop/callback
?code=<short-lived authorization code>
&state=<the state you sent>On denial:
https://your-app.example.com/auth/loop/callback
?error=access_denied
&error_description=User%20denied%20the%20request
&state=<the state you sent>Always verify state matches your session value. If not, drop the request — it is forged.
Step 5 — Exchange code for tokens
POST https://identity.platform.loop.health/v1/oauth/token
Content-Type: application/x-www-form-urlencoded
grant_type=authorization_code
&code=<authorization_code>
&redirect_uri=<exact same redirect URI>
&client_id=<your_client_id>
&client_secret=<your_client_secret> # confidential clients only
&code_verifier=<the verifier from step 1>Authorization codes are single-use and expire after 60 seconds. Don’t store them; exchange immediately.
Step 6 — Use the tokens
GET https://<service>.platform.loop.health/v1/<resource>
Authorization: Bearer <access_token>When the access token expires, exchange the refresh token:
POST https://identity.platform.loop.health/v1/oauth/token
grant_type=refresh_token
&refresh_token=<refresh_token>
&client_id=<your_client_id>
&client_secret=<your_client_secret> # confidential clients onlyThe refresh token is rotated on every use. Save the new one; the old one is dead.
Error paths
| Where | What goes wrong | What you do |
|---|---|---|
| /authorize | Invalid client_id or redirect_uri | Identity refuses to redirect; renders an error page. Fix your registration. |
| /authorize | User denies consent | Redirect back with ?error=access_denied. Show your own copy. |
| /token | Code expired or already used | Restart the flow from /authorize. |
| /token | code_verifier doesn’t match code_challenge | Check that the verifier you store in session is the one you send. |
| /token | Refresh token revoked (user clicked Revoke) | Restart from /authorize. The user must re-consent. |
| Any API call | Access token expired | 401 with WWW-Authenticate: Bearer error="invalid_token". Refresh and retry. |
| Any API call | Scope insufficient | 403 with error="insufficient_scope" and the required scope name. |
All error responses follow RFC 6749 §5.2.