Skip to content

LastVet Identity Bridge — OAuth 2.0 + OIDC Spec for Onset

Why this is its own document

The Onset → LastVet integration has three layers: identity (this doc), data shape (ONSET_TO_LASTVET_SYNC_CONTRACT.md), and UX (LASTVET_CONNECT_AND_REVOKE.md). Identity is the load-bearing one — get it wrong and tokens leak, veteran impersonation becomes possible, or revoke doesn't work. This is the layer to over-spec.

This doc is also where we settle the veteran verification question.

Veteran verification: connection success IS the verification

Onset has no is_veteran field today. The conclusion after evaluating options:

  • Self-attestation in onboarding (asking the user "are you a veteran?") is not verification. Anyone can claim. Adding it just creates a soft fiction.
  • VA Lighthouse verification on the Onset side is months of work, partner agreements, and compliance — not justified for a single LastVet integration.
  • Successful OAuth into LastVet is verification. LastVet already operates the VA Lighthouse integration; their veteran_status_confirmed flag and service_data_source column are the source of truth. If LastVet authenticates a user as a veteran, they're a veteran by LastVet's standards, which is exactly the standard that matters because the data is going into LastVet.

Practical consequences

  • Onset does not ask "are you a veteran?" in onboarding.
  • Onset does not store an is_veteran boolean.
  • The "Connect to LastVet" card in Settings is visible to all users, under a "Veteran tools" subheader. Civilians who tap it land on LastVet's authorize screen and discover they need a LastVet account; we don't pre-filter.
  • The thing Onset stores after a successful connection is the LastVet veteran identity claims received from the OIDC id_token: a verified veteran subject id, optional verification status, optional service-data source. Onset uses these for display ("Verified veteran" badge if applicable) but never re-derives them.

This is also why the connect flow happens via LastVet's hosted authorize screen rather than a custom Onset-side form: the verification work happens at LastVet, and we trust the answer.

What if LastVet hasn't verified the veteran yet

LastVet has a state where someone created a LastVet account but hasn't completed VA Lighthouse verification (veteran_status_confirmed = false, service_data_source = 'manual_entry'). For Onset's purposes: - The connection still succeeds; Onset still pushes data into LastVet. - The id_token carries veteran_verified: false; Onset surfaces no "Verified" badge. - LastVet is responsible for what unverified veterans can do on its side (e.g., providers might see different category access). Onset doesn't replicate that gating.

Protocol baseline

Layer Choice Why
Authorization OAuth 2.0 Authorization Code Standard, well-tooled, supported in ASWebAuthenticationSession.
Public client proof PKCE (S256) Mandatory for mobile; no client secret on iOS.
Identity assertion OpenID Connect (id_token alongside access token) Lets Onset display the verified veteran identity without an extra /me call and without trusting any unverified claim.
Access token format HS256 JWT (v1) LastVet's auth server and resource server are the same Express app; symmetric is fine. RS256 + JWKS is the future-proofing path when a second client appears.
Refresh token Rotating, with reuse detection Industry standard since OAuth 2.1 draft. Reuse kills the entire grant chain.
Redirect URI Custom URL scheme (onset://…) with HTTPS Universal Link fallback (https://theonset.app/…) Shipped path uses ASWebAuthenticationSession + onset://last1/callback / onset://lastvet/callback. Universal Link hosts remain registered for fallback.
Front-channel CSRF state parameter, required, opaque random Standard.
OIDC replay protection nonce parameter, required, opaque random Standard for OIDC; binds id_token to the authorization request.

We are explicitly not doing: - Pushed Authorization Requests (PAR) — defer; not needed at our scale. - mTLS or private key JWT — defer; HS256 from a public client with PKCE is acceptable for v1. - Client Credentials grant — Onset never acts without a veteran. No service-to-service token exists. - Implicit grant — deprecated. - Resource Owner Password Credentials grant — deprecated and a pattern we explicitly want to avoid (no Onset code should ever see a LastVet password).

Actors and trust assumptions

Actor Trust assumption
Veteran (the human) Holds an authoritative LastVet account. Verification status owned by LastVet.
Onset iOS app Public client. Cannot keep a client secret. Can keep tokens in iOS Keychain WhenUnlockedThisDeviceOnly. Can be inspected by an attacker on a jailbroken device — design for token theft to be limited in blast radius (short access TTL, refresh token rotation).
Onset Supabase Stores no LastVet tokens. Stores patient_aggregator_connections rows and audit.
LastVet auth server Issues tokens. Source of truth for grant state.
LastVet resource server (/me/sync) Verifies access tokens locally (shared secret with auth server in v1). Checks scope per-endpoint.
LastVet veteran identity Source of truth for veteran verification status.

The flow, step by step

1. Onset prepares the authorization request

On the iOS device, when the veteran taps "Connect to LastVet":

let codeVerifier = generateCodeVerifier()        // 64 bytes, base64url, no padding
let codeChallenge = sha256(codeVerifier).base64url()
let state = randomBase64url(32)
let nonce = randomBase64url(32)

// Store {codeVerifier, state, nonce} in a transient keychain entry tied to this attempt only.
// They're consumed once on callback and deleted, regardless of success.

Build the authorize URL:

https://api.last.vet/api/v1/oauth/authorize
  ?response_type=code
  &client_id=onset-ios
  &redirect_uri=onset%3A%2F%2Flastvet%2Fcallback
  &scope=openid%20profile%20protocol.write%20adherence.write%20labs.write%20checkins.write
  &state=<state>
  &nonce=<nonce>
  &code_challenge=<codeChallenge>
  &code_challenge_method=S256

(HTTPS fallback redirect https://theonset.app/lastvet/callback remains registered; production xcconfig uses the custom scheme.)

2. ASWebAuthSession opens LastVet's authorize endpoint

LastVet authenticates the veteran (existing flow: email + password, or LastVet's "Sign in with Apple" if available).

LastVet then renders the consent screen:

Connect Onset to LastVet?

Onset wants to add this data to your LastVet record: - Active supplements and protocols - Whether you took each item - Lab results you've already chosen to share with practitioners

Onset can read its own writes back. Onset cannot read other parts of your LastVet record.

You can disconnect any time from either app.

[Allow] [Deny]

The scope strings displayed map cleanly to plain-language bullet items.

On Allow, LastVet generates a one-time code (10 minutes TTL, single-use, bound to client_id + code_challenge + redirect_uri + nonce) and redirects:

onset://lastvet/callback?code=<code>&state=<state>

On Deny, the redirect carries ?error=access_denied&state=<state>.

3. Onset receives the callback (custom scheme / ASWebAuthenticationSession)

ASWebAuthSession delivers the URL to Onset. Onset: - Verifies state matches the stored one. Mismatch → abort, do not exchange. - Extracts code.

4. Token exchange (back-channel, no browser)

POST https://api.last.vet/api/v1/oauth/token
Content-Type: application/x-www-form-urlencoded

grant_type=authorization_code
&code=<code>
&redirect_uri=onset://lastvet/callback
&client_id=onset-ios
&code_verifier=<codeVerifier>

LastVet validates: code unused, not expired, client_id match, redirect_uri match, sha256(code_verifier) == code_challenge. If all pass, returns:

{
  "access_token":  "<JWT>",
  "token_type":    "Bearer",
  "expires_in":    3600,
  "refresh_token": "<opaque-32B>",
  "refresh_token_expires_in": 7776000,
  "id_token":      "<JWT>",
  "scope":         "openid profile protocol.write adherence.write labs.write checkins.write"
}

5. Onset verifies the id_token and persists state

  • Verify id_token signature (HS256 with the shared secret distributed via env, or RS256 via JWKS in the future).
  • Verify iss = https://api.last.vet, aud = onset-ios, nonce matches stored, exp > now.
  • Extract identity claims (see Token shapes below).
  • Save lastvet.refreshToken, lastvet.accessToken, lastvet.veteranId, identity claims to iOS Keychain (WhenUnlockedThisDeviceOnly).
  • Upsert patient_aggregator_connections row in Onset Supabase with status='connected', external_subject_id=<veteran_id from id_token>, scopes=<granted scopes>, connected_at=now().
  • Write connection_established to data_share_events.
  • Discard codeVerifier, state, nonce from transient storage.

6. Subsequent API calls

Onset attaches Authorization: Bearer <access_token> to every POST /api/v1/me/sync. Resource server verifies the JWT in-process (shared secret) and continues.

7. Refresh

On 401 with WWW-Authenticate: Bearer error="invalid_token" (token expired):

POST /api/v1/oauth/token
Content-Type: application/x-www-form-urlencoded

grant_type=refresh_token
&refresh_token=<old-refresh-token>
&client_id=onset-ios

LastVet: - Looks up oauth_authorization_grants by sha256(refresh_token). - If the row's revoked_at is set OR the row has been rotated (a child row exists with rotated_from = this.id AND not the latest in the chain) → token reuse detected. Revoke the entire chain. Return 400 invalid_grant. Onset transitions to reconnect_required. - Otherwise, mint new access + refresh tokens. Insert new oauth_authorization_grants row with rotated_from = old.id. Mark the old row's last_used_at and treat it as superseded. Return the new tokens.

This is OAuth 2.1's recommended rotation + reuse-detection behavior. It catches the case where a stolen refresh token is used after the legitimate device has already used it.

8. Revoke (either direction)

From Onset:

DELETE /api/v1/oauth/token
Authorization: Bearer <access_token>
Content-Type: application/x-www-form-urlencoded

token=<refresh_token>
&token_type_hint=refresh_token

LastVet sets revoked_at, revoked_reason='client_initiated_revoke'. All subsequent uses of any token in this chain return invalid_grant.

From inside LastVet: LastVet's veteran-side "Connected apps" surface marks the grant revoked_at directly. Onset's next API call gets 401 / refresh attempt gets invalid_grant / Onset transitions to revoked_remote and surfaces "Reconnect" in Settings.

Token shapes

Access token (JWT, HS256 v1)

Header:

{ "alg": "HS256", "typ": "JWT" }

Payload:

{
  "iss":  "https://api.last.vet",
  "aud":  "lastvet-resource-server",
  "sub":  "<veteran_uuid>",
  "client_id": "onset-ios",
  "scope": "protocol.write adherence.write labs.write checkins.write",
  "iat":  1747779600,
  "exp":  1747783200,
  "jti":  "<uuid>"
}

The resource server (/me/sync) verifies signature, checks aud and exp, reads sub to derive the veteran context, reads scope to gate per-endpoint behavior. The resource server does not call back to the auth server on each request — verification is in-process.

ID token (JWT, HS256 v1) — OIDC

Payload:

{
  "iss":   "https://api.last.vet",
  "aud":   "onset-ios",
  "sub":   "<veteran_uuid>",
  "iat":   1747779600,
  "exp":   1747783200,
  "nonce": "<echoed nonce from authorize request>",

  // OIDC standard claims
  "name":  "<call_sign>",

  // LastVet custom claims (under a versioned namespace to allow evolution)
  "https://last.vet/claims": {
    "veteran_verified":     true,
    "service_data_source":  "va_lighthouse",
    "branch":               "Army",
    "rank":                 "SGT"
  }
}

Onset reads only sub, name, and veteran_verified. The other claims are available for future use (e.g., showing a veteran's branch in a connected-state card) but Onset must treat them as opaque optional.

Refresh token (opaque)

A 32-byte random string, base64url-encoded. Not a JWT. The auth server stores sha256(refresh_token) in oauth_authorization_grants.refresh_token_hash and looks up by hash on every refresh.

Scopes

Two distinct scope vocabularies live in this system. Don't conflate them.

OAuth scopes (Onset → LastVet capability)

What the auth server grants to Onset. These are machine-to-machine capabilities.

Scope Allows
openid OIDC requirement; auth server returns an id_token.
profile OIDC requirement; auth server may include name / verification claims in the id_token.
protocol.write POST /me/sync with category in ('medications','supplements').
adherence.write POST /me/sync with category = 'behavioral' and event_type in the supplement-intake set.
labs.write POST /me/sync with category = 'labs'.
checkins.write POST /me/sync with category = 'self_reported'. Phase-gated; not granted in v1's default set.

The v1 default consent screen requests openid profile protocol.write adherence.write labs.write. checkins.write is added later when daily-journal sharing ships.

What the veteran grants to specific providers from inside LastVet. These are per-provider, per-category visibility, governed by LastVet's existing consent_grants table and CONSENT_CATEGORIES map.

These two layers compose: - Onset has OAuth scope protocol.write → can write supplement items into LastVet. - LastVet veteran grants Provider X categories: ['supplements'] → Provider X can read those items.

Removing the OAuth scope stops Onset from writing. Removing the LastVet consent stops the provider from reading. They don't substitute for each other.

Threat model

In scope

  • Stolen access token → bounded blast radius (1h TTL, single resource server, scope-gated). No mitigation beyond TTL in v1.
  • Stolen refresh token → reuse detection kills the chain on next legitimate use.
  • CSRF on the authorize callback → state parameter, mandatory.
  • ID token replay → nonce bound to the authorize request; LastVet rejects if seen twice.
  • Authorization code interception → PKCE; the code alone is not enough.
  • Redirect URI hijack → primary redirect is onset:// via ASWebAuthenticationSession (callback bound to the session). HTTPS Universal Link fallback on theonset.app remains allowlisted.
  • Open redirector → LastVet's redirect_uri validation is strict allowlist; no pattern matching.
  • Token leakage via logs → tokens never logged on either side; only jti and request_id go to logs.

Out of scope (v1)

  • Compromised LastVet auth server signing key → would invalidate all tokens; mitigation is operational rotation, deferred until the multi-client phase.
  • Phishing of LastVet credentials → not an OAuth-layer concern; LastVet's own auth handles.
  • Device-bound refresh tokens (DPoP) → defer; not justified at v1 scale.

What LastVet must add to support this (cross-reference)

The concrete migrations, routes, and code edits live in LASTVET_API_CHANGES_REQUIRED.md. This identity bridge doc is the why and the wire-level shape; that doc is the what to actually edit. They should be read together.

What Onset must add to support this (cross-reference)

LASTVET_CONNECT_AND_REVOKE.md covers the iOS UX, Keychain layout, and Onset Supabase tables. The OAuth spec lives here; the connection lifecycle and UI live there.

Future tightenings (not v1)

  • RS256 + JWKS publication when a second client (besides onset-ios) appears. The auth server signs with a private key, both Onset and the LastVet resource server pull the public key from /oauth/jwks. Lets a second client trust the same tokens without sharing a secret.
  • Token introspection endpoint for cases where the resource server needs to check current revocation state (rather than relying on TTL).
  • DPoP to bind tokens to the originating device's keypair, defeating bearer-token theft.
  • Pushed Authorization Requests (PAR) to keep authorize parameters off the URL bar.
  • Step-up auth for high-sensitivity scopes (e.g., re-prompting the LastVet password if the veteran is granting a new scope on an already-connected client).