Skip to content

LastVet API Changes Required for Onset Integration

Purpose

Concrete, scoped change list for last.vet-api (and last.vet-provider UI) so the Onset → LastVet sync contract can ship. Companion to ONSET_TO_LASTVET_SYNC_CONTRACT.md.

This is small. The heavy lifting (consent grants, health_record_items, provider portal, audit log, Part 2 redaction) already exists.

Summary

Five changes: 1. Add 'onset' to the allowed source values on /me/sync and let an Onset-issued token write under that source. 2. Introduce real auth (OAuth 2.0 Authorization Code + PKCE) for external clients. Onset is the first one. The existing header-based Phase 1 bootstrap auth continues to work for the LastVet iOS app for now. 3. Add supplements and protocol_adherence to CONSENT_CATEGORIES so providers can grant/deny these specifically. 4. Provider-portal UI: add a "Supplements & Protocols" section that surfaces health_record_items WHERE source='onset'. 5. Add a "Connected apps" surface in the LastVet iOS app (or web profile) where the veteran can see and revoke the Onset connection.

1. /me/sync accepts source: "onset"

File: last.vet-api/src/routes/health-record.ts

Currently:

// line ~63
const result = await repository.syncVeteranItems({
  veteranId: internalVeteranId,
  categories,
  items,
  syncedAt,
  source: "va"   // ← hard-coded
});

Change: derive source from the authenticated principal. If the request is JWT-authenticated with client_id = "onset-ios", write source: "onset". Otherwise (existing LastVet-iOS-via-header path), continue writing source: "va".

const source = req.authenticatedClient?.id === "onset-ios" ? "onset" : "va";

The health_record_items table already accepts arbitrary 32-char source strings; no migration needed for the column itself.

2. OAuth 2.0 + OIDC Authorization Code + PKCE server

Why: Onset is the first non-LastVet client. The existing header-based "Phase 1 bootstrap auth" remains for LastVet iOS; external clients need real auth.

Authoritative spec: LASTVET_IDENTITY_BRIDGE.md — read it for the full flow, token shapes, threat model, and reasoning. This section lists only the LastVet-side artifacts to build.

New files

  • last.vet-api/src/oauth/ — module: token mint/verify, refresh rotation with reuse detection, scope validation, OIDC id_token issuance.
  • last.vet-api/src/routes/oauth.ts — routes (below).
  • last.vet-api/migrations/020_oauth_clients_and_grants.sql — tables (below).
  • last.vet-web (or last.vet-api server-rendered): the /oauth/authorize consent screen UI. Lightweight; copy lives in LASTVET_IDENTITY_BRIDGE.md.

New tables

-- migrations/020_oauth_clients_and_grants.sql

CREATE TABLE oauth_clients (
  id                  TEXT PRIMARY KEY,            -- 'onset-ios' (sandbox row distinct: 'onset-ios-sandbox')
  display_name        TEXT NOT NULL,               -- 'Onset'
  client_type         TEXT NOT NULL
                       CHECK (client_type IN ('public','confidential')),
  client_secret_hash  TEXT,                         -- MUST be NULL for client_type='public'
  redirect_uris       TEXT[] NOT NULL,             -- exact-match allowlist; no wildcards
  allowed_scopes      TEXT[] NOT NULL,
  require_pkce        BOOLEAN NOT NULL DEFAULT TRUE,
  is_active           BOOLEAN NOT NULL DEFAULT TRUE,
  created_at          TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  CONSTRAINT public_client_no_secret
    CHECK (client_type <> 'public' OR client_secret_hash IS NULL),
  CONSTRAINT public_client_must_pkce
    CHECK (client_type <> 'public' OR require_pkce = TRUE)
);

CREATE TABLE oauth_authorization_codes (
  code_hash           TEXT PRIMARY KEY,            -- sha256 of issued code
  client_id           TEXT NOT NULL REFERENCES oauth_clients(id),
  veteran_id          UUID NOT NULL REFERENCES veterans(id) ON DELETE CASCADE,
  scopes              TEXT[] NOT NULL,
  redirect_uri        TEXT NOT NULL,
  code_challenge      TEXT NOT NULL,
  code_challenge_method TEXT NOT NULL CHECK (code_challenge_method = 'S256'),
  nonce               TEXT,                         -- from authorize request, echoed in id_token
  issued_at           TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  expires_at          TIMESTAMPTZ NOT NULL,        -- issued_at + 10 minutes
  consumed_at         TIMESTAMPTZ                   -- single-use; non-null = already used
);

CREATE TABLE oauth_authorization_grants (
  id                       UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  veteran_id               UUID NOT NULL REFERENCES veterans(id) ON DELETE CASCADE,
  client_id                TEXT NOT NULL REFERENCES oauth_clients(id),
  scopes                   TEXT[] NOT NULL,
  refresh_token_hash       TEXT NOT NULL,           -- sha256(refresh_token)
  refresh_token_expires_at TIMESTAMPTZ NOT NULL,
  rotated_from             UUID REFERENCES oauth_authorization_grants(id),
  rotated_to               UUID REFERENCES oauth_authorization_grants(id), -- set when this generation is superseded
  granted_at               TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  revoked_at               TIMESTAMPTZ,
  revoked_reason           TEXT,
  last_used_at             TIMESTAMPTZ
);

CREATE INDEX idx_oauth_grants_active_lookup
  ON oauth_authorization_grants(refresh_token_hash)
  WHERE revoked_at IS NULL;

CREATE INDEX idx_oauth_grants_veteran_client_active
  ON oauth_authorization_grants(veteran_id, client_id)
  WHERE revoked_at IS NULL AND rotated_to IS NULL;

The two-table split (oauth_authorization_codes separate from oauth_authorization_grants) keeps the short-lived code rows from polluting the long-lived grant rows. Codes auto-expire after 10 minutes and get vacuumed by a daily job.

New routes

GET    /api/v1/oauth/authorize       # consent screen + code issuance
POST   /api/v1/oauth/token           # code → access+refresh; refresh rotation
DELETE /api/v1/oauth/token           # revoke
GET    /api/v1/oauth/jwks            # deferred to RS256 transition; not in v1

Critical behaviors (full rationale in identity-bridge doc): - PKCE required for all public clients; reject /oauth/token exchange that arrives without code_verifier from a client_type='public' client. - Refresh token rotation with reuse detection: when a refresh token is presented, mint new tokens and set the old grant's rotated_to to the new grant's id. If a refresh token is presented for a grant whose rotated_to is non-null OR revoked_at is non-null, treat as token reuse: revoke the entire chain (walk rotated_from upward and downward, set revoked_at on all), return 400 invalid_grant. - Strict redirect_uri allowlist: exact-match against oauth_clients.redirect_uris. No prefix matching, no glob. - State parameter required on /oauth/authorize; echoed verbatim on redirect. - Nonce parameter required for openid scope; bound into the code row, echoed verbatim in the issued id_token.

Token signing

For v1: HS256 with secret LASTVET_OAUTH_SIGNING_SECRET injected via env. Both auth server and resource server (same Express app) verify in-process. The existing last.vet-api/src/services/provider-session.service.ts already implements HS256 JWT mint + verify and is the right template — copy/adapt rather than reinventing.

For future (when a second client appears): RS256 + /oauth/jwks published. Not v1.

Auth middleware extension

File: last.vet-api/src/middleware/auth.ts

Add a JWT path that runs before the existing header-based path:

// pseudo
const authz = req.header("authorization");
if (authz?.startsWith("Bearer ")) {
  const token = authz.slice(7);
  const claims = verifyExternalAccessToken(token);   // HS256 verify, aud/iss/exp checks
  if (claims) {
    req.authenticatedClient = { id: claims.client_id, scopes: claims.scope.split(' ') };
    req.authenticatedUser   = { id: claims.sub, type: "veteran", name: "External Client" };
    req.veteranId           = claims.sub;
    return next();
  }
  // invalid Bearer → 401 immediately, do NOT fall through to header path
  res.status(401).json({ error: "invalid_token" }).end();
  return;
}
// no Bearer → existing header-based bootstrap (for LastVet iOS only)

Rationale for the early-401 vs fall-through: a malformed Bearer token should not be silently downgraded to header auth, which would let an attacker disable Bearer auth by sending garbage and then send x-user-id headers.

Per-endpoint scope enforcement

/me/sync requires the JWT to carry the scope corresponding to the category of each item being written:

Item category Required JWT scope
medications, supplements protocol.write
behavioral adherence.write
labs labs.write
self_reported checkins.write

Any item whose category is not covered by a granted scope causes the entire request to fail with 403 insufficient_scope. Partial writes are not allowed — the client should split or upgrade scope.

File: last.vet-api/src/consent/categories.ts

export const CONSENT_CATEGORIES = {
  // ...existing entries unchanged...
  supplements:        { name: "Supplements & Protocols", part2: false },
  protocol_adherence: { name: "Protocol Adherence",      part2: false },
};

supplements covers the "what is the veteran taking" view (health_record_items category=supplements). protocol_adherence is a separate category specifically because adherence data is more sensitive than the protocol itself — providers may need access to one without the other (e.g. a referral provider needs to know what the veteran is taking but does not need a daily intake log).

expandFullRecord() automatically picks these up because it just enumerates keys minus full_record itself. No code change there.

Update consent UI in LastVet iOS and last.vet-provider to render the two new categories with their human-readable names.

4. Provider-portal "Supplements & Protocols" view

Files: last.vet-provider/src/app/dashboard/... (exact path TBD by LastVet's existing routing).

Surface a new card/section on the veteran detail page that: - Reads from health_record_items WHERE veteran_id = $1 AND source = 'onset' AND category IN ('supplements','medications'). - Groups by raw->>'protocol_id'. One protocol per group. - For each protocol, shows: name, practitioner name (if is_practitioner_authored), active items with their schedule summary, and a sparkline of last 14 days adherence pulled from category = 'behavioral' AND event_type LIKE 'supplement_intake_%'. - Respects existing consent — provider only sees this if the veteran granted supplements (or full_record) to that provider. - If protocol_adherence is not granted, hide the sparkline and show "Adherence not shared" placeholder, but still show the items.

This view is read-only. Providers cannot edit Onset-sourced items from inside LastVet; they would have to leave a clinical note recommending a change, same as for any other source.

5. "Connected apps" surface for veterans

File: new in last.vet-ios (or in the LastVet web profile page if there is one).

Veteran-facing list of OAuth clients with active grants. Reads from oauth_authorization_grants WHERE veteran_id = $1 AND revoked_at IS NULL. For each: - App name (oauth_clients.display_name). - Connected since (granted_at). - Last sync (last_used_at). - Scopes granted (decoded into human readable strings). - "Disconnect" button → calls DELETE /api/v1/oauth/token server-side, sets revoked_at, blocks future syncs.

Mirror surface lives in Onset Settings; either side disconnecting must invalidate the other side cleanly. Onset detects remote revocation via 401/refresh failure (see sync contract).

Migration ordering

  1. Ship migration 020_oauth_clients_and_grants.sql and seed oauth_clients with the onset-ios row (manually for now, in production via env-driven init script).
  2. Deploy auth middleware change behind a feature flag oauth_external_clients_enabled. Existing LastVet iOS continues to work via headers.
  3. Deploy /me/sync source-derivation change.
  4. Deploy /oauth/authorize, /oauth/token, /oauth/token DELETE, /oauth/jwks routes.
  5. Deploy consent category additions.
  6. Ship provider-portal "Supplements & Protocols" view.
  7. Ship veteran-side "Connected apps" view.
  8. Onset side wires the connect flow and starts dogfooding in TestFlight.
  9. Production cutover.

Steps 1–7 are all on the LastVet side and can ship before Onset has anything connected. None of them break existing LastVet flows.

Out of scope for this delta

  • mTLS for the Onset client.
  • Refresh-token-binding to a specific device.
  • Token introspection endpoint (/oauth/introspect) — defer until a second client.
  • Public JWKS endpoint with key rotation — defer to RS256 transition.
  • Inbound provider OAuth (third-party providers calling LastVet API directly with their own client) — different doc, different roadmap.