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_confirmedflag andservice_data_sourcecolumn 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_veteranboolean. - 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:
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_tokensignature (HS256 with the shared secret distributed via env, or RS256 via JWKS in the future). - Verify
iss = https://api.last.vet,aud = onset-ios,noncematches 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_connectionsrow in Onset Supabase withstatus='connected',external_subject_id=<veteran_id from id_token>,scopes=<granted scopes>,connected_at=now(). - Write
connection_establishedtodata_share_events. - Discard
codeVerifier,state,noncefrom 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:
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.
LastVet consent grants (LastVet → provider visibility)¶
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 →
stateparameter, mandatory. - ID token replay →
noncebound 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://viaASWebAuthenticationSession(callback bound to the session). HTTPS Universal Link fallback ontheonset.appremains allowlisted. - Open redirector → LastVet's
redirect_urivalidation is strict allowlist; no pattern matching. - Token leakage via logs → tokens never logged on either side; only
jtiandrequest_idgo 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).