The EMR OAuth leg we were missing, and why it fails closed
Our EMR sync had a callback that could exchange a code for tokens — but nothing that ever sent the clinician to the vendor's consent screen. We built the authorize leg, and made it refuse rather than fake a connection.
Half a handshake
OAuth 2.0 authorization-code is a three-beat flow: authorize → vendor consent screen → callback (token exchange). Our tree had the callback. It consumed a state parameter and exchanged the code for tokens. But there was no authorize leg — nothing that minted the state, built the vendor's authorize URL, and 302'd the clinician into the consent screen in the first place. The callback was a door with no corridor leading to it. This commit built the corridor.
What the authorize route actually does
It takes an already-created IntegrationConnection (status pending, carrying the vendor and OAuth client_id), mints a single-use state with a 15-minute TTL bound to the clinic, persists it, and redirects the browser to the vendor's authorize URL with response_type=code, client_id, redirect_uri, scope, and state. For SMART-on-FHIR vendors it also sets aud to the FHIR base the token is minted for, which those servers require.
The state is the CSRF and replay guard: the callback matches the connection by (id, clinicId) via the saved state, so the round-trip is clinic-scoped end to end.
// Single-use, TTL-bounded state bound to this clinic
const state = randomBytes(24).toString('hex');
await saveOAuthState(state, conn.vendor, conn.clinicId);
const authorizeUrl = new URL(vendor.authorizeUrl);
authorizeUrl.searchParams.set('response_type', 'code');
authorizeUrl.searchParams.set('client_id', conn.clientId);
authorizeUrl.searchParams.set('redirect_uri', redirectUri);
authorizeUrl.searchParams.set('state', state);
if (conn.fhirBaseUrl) authorizeUrl.searchParams.set('aud', conn.fhirBaseUrl);
return NextResponse.redirect(authorizeUrl.toString());Refusing the wrong flow instead of faking it
Not every EMR uses browser consent. IntelleChart, for example, uses SMART Backend Services / client_credentials via its own pull route — there is no human consent screen to send anyone to. The authorize route detects this: a vendor whose authorize URL points at the non-routable placeholder host is rejected with a typed 4xx (“uses a non-interactive flow, not browser consent”) rather than shipping the clinician to a dead page. Same for a connection missing its client_id — it returns “complete registration first”, not a silent no-op.
Fail closedEvery missing prerequisite is a 4xx, never a fabricated success.
Why the missing leg is the honest kind of gap
It would have been easy to paper over this — stub a connected status, show a green check, move on. That is exactly the kind of theater the doctrine forbids. A connection is only real once the vendor has actually issued tokens through a state we minted and consumed.
Building the authorize leg means the connections UI now reflects a real OAuth round-trip or an explicit refusal, and nothing in between. The callback validates the state, pulls the connection's stored client credentials, and only then exchanges the code.