ConnectAuthorization Flow

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

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_challengeBASE64URL(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=S256

Required query params:

ParamRequiredNotes
response_typeyesAlways code.
client_idyesFrom app registration.
redirect_uriyesMust match one of the registered URIs exactly.
scopeyesSpace-separated list.
stateyesRandom; client uses to detect CSRF. Required even though spec calls it optional.
code_challengeyesPKCE required for all clients.
code_challenge_methodyesAlways S256. plain is rejected.
nonceOIDC onlyIf you want an id_token, include a nonce.

Step 3 — User authenticates and consents

The identity service:

  1. Checks whether the user is signed in. If not, redirects through WorkOS to sign in.
  2. Looks up the requested scopes vs. what’s already been granted to this client_id for this user.
  3. If any requested scope has not been granted before, renders the consent screen with the full list.
  4. The consent screen shows the app’s name, logo, and a plain-English description of every scope.
  5. 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 only

The refresh token is rotated on every use. Save the new one; the old one is dead.

Error paths

WhereWhat goes wrongWhat you do
/authorizeInvalid client_id or redirect_uriIdentity refuses to redirect; renders an error page. Fix your registration.
/authorizeUser denies consentRedirect back with ?error=access_denied. Show your own copy.
/tokenCode expired or already usedRestart the flow from /authorize.
/tokencode_verifier doesn’t match code_challengeCheck that the verifier you store in session is the one you send.
/tokenRefresh token revoked (user clicked Revoke)Restart from /authorize. The user must re-consent.
Any API callAccess token expired401 with WWW-Authenticate: Bearer error="invalid_token". Refresh and retry.
Any API callScope insufficient403 with error="insufficient_scope" and the required scope name.

All error responses follow RFC 6749 §5.2.