SMART Refresh and Rotation
Problem
A SMART v2 browser app launches with PKCE, gets an access token plus a refresh token, and is expected to rotate both when the access token nears expiry. The refresh flow is the same as the initial exchange minus the authorization code — and the server issues a fresh refresh token each time (rotation). Miss a rotation and you're locked out. Introspect the token before sensitive operations to catch revocation mid-session.
Prerequisites
- Generated client at
./fhir/r4 - Packages:
@fhir-dsl/core,@fhir-dsl/smart - Server: SMART v2 authorization server advertising
refresh_tokensupport in.well-known/smart-configuration.grant_types_supportedand emittingoffline_accesswhen asked
Steps
1. Authorize with PKCE, asking for offline_access
offline_access unlocks the refresh-token grant. S256 is the only
permitted PKCE method — buildAuthorizeUrl hardcodes that for you.
import {
buildAuthorizeUrl,
codeChallengeS256,
discoverSmartConfiguration,
generateCodeVerifier,
generateState,
} from "@fhir-dsl/smart";
const smartConfig = await discoverSmartConfiguration("https://fhir.example/r4");
const verifier = generateCodeVerifier();
const challenge = await codeChallengeS256(verifier);
const state = generateState();
sessionStorage.setItem("pkce-verifier", verifier);
sessionStorage.setItem("oauth-state", state);
const url = buildAuthorizeUrl({
smartConfig,
clientId: "app-123",
redirectUri: "https://app.example/cb",
scope: "launch/patient openid fhirUser patient/*.rs offline_access",
state,
codeChallenge: challenge,
aud: "https://fhir.example/r4",
});
window.location.assign(url);
2. Exchange the code
On the redirect, swap the authorization code for tokens. The response
carries access_token, expires_in, refresh_token, and possibly
id_token + patient launch context.
import { exchangeCode } from "@fhir-dsl/smart";
const params = new URLSearchParams(window.location.search);
if (params.get("state") !== sessionStorage.getItem("oauth-state")) {
throw new Error("OAuth state mismatch — aborting");
}
const tokens = await exchangeCode({
smartConfig,
clientId: "app-123",
redirectUri: "https://app.example/cb",
code: params.get("code")!,
codeVerifier: sessionStorage.getItem("pkce-verifier")!,
});
3. Wrap the tokens in a SmartClient with a persistent TokenStore
SmartClient implements AuthProvider — plug it into createFhirClient
and every request gets the current access token. When the token is
within refreshIfWithinSec of expiry, the client transparently calls
the refresh endpoint and updates the store.
import { InMemoryTokenStore, SmartClient, withAbsoluteExpiry } from "@fhir-dsl/smart";
import { createFhirClient } from "@fhir-dsl/core";
import type { GeneratedSchema } from "./fhir/r4/client.js";
const tokenStore = new InMemoryTokenStore();
await tokenStore.set(withAbsoluteExpiry(tokens));
const smart = new SmartClient({
smartConfig,
clientId: "app-123",
tokenStore,
refreshIfWithinSec: 60, // refresh when <60s remain on access_token
onTokenRefresh: async (fresh) => {
// Persist rotation — refresh_token will have changed
console.log("rotated; new expiresAt =", fresh.expiresAt);
},
});
const fhir = createFhirClient<GeneratedSchema>({
baseUrl: "https://fhir.example/r4",
auth: smart,
});
For real apps replace InMemoryTokenStore with a store that writes to
IndexedDB, the server session, or a secure cookie — whatever survives
page reloads.
4. Handle refresh-token rotation
SMART v2 servers rotate the refresh token on every refresh call. The
onTokenRefresh callback fires after a successful refresh with the
new stored token (via withAbsoluteExpiry). Always persist what
you're handed; the old refresh token is immediately invalidated.
const smart = new SmartClient({
smartConfig,
clientId: "app-123",
tokenStore,
refreshIfWithinSec: 60,
onTokenRefresh: async (fresh) => {
// Persist: localStorage for a browser app, secure cookie for SSR, etc.
localStorage.setItem("smart-token", JSON.stringify(fresh));
},
});
5. Introspect before sensitive writes
A rotated refresh token is only invalidated server-side. If the user was revoked mid-session, your cached access token may still pass expiry checks locally but fail server-side. Introspect to confirm.
async function introspect(token: string): Promise<{ active: boolean; scope?: string }> {
const endpoint = smartConfig.introspection_endpoint;
if (!endpoint) return { active: true }; // server doesn't expose introspection
const body = new URLSearchParams({ token, token_type_hint: "access_token" });
const res = await fetch(endpoint, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Authorization: `Bearer ${token}`,
},
body,
});
if (!res.ok) return { active: false };
return res.json() as Promise<{ active: boolean; scope?: string }>;
}
const stored = await tokenStore.get();
if (stored) {
const meta = await introspect(stored.access_token);
if (!meta.active) {
await tokenStore.clear();
window.location.assign("/login");
}
}
6. Handle 401 mid-session
Even with refresh-on-expiry and introspection, a server may revoke the
token between two requests. Catch FhirRequestError with status === 401, clear the store, and re-authorize.
import { FhirRequestError } from "@fhir-dsl/core";
try {
const patient = await fhir.read("Patient", smart.patientId!).execute();
} catch (e) {
if (e instanceof FhirRequestError && e.status === 401) {
await tokenStore.clear();
window.location.assign("/login");
return;
}
throw e;
}
Final snippet
import {
buildAuthorizeUrl,
codeChallengeS256,
discoverSmartConfiguration,
exchangeCode,
generateCodeVerifier,
generateState,
InMemoryTokenStore,
SmartClient,
withAbsoluteExpiry,
} from "@fhir-dsl/smart";
import { createFhirClient, FhirRequestError } from "@fhir-dsl/core";
import type { GeneratedSchema } from "./fhir/r4/client.js";
const FHIR_BASE = "https://fhir.example/r4";
export async function startLogin() {
const smartConfig = await discoverSmartConfiguration(FHIR_BASE);
const verifier = generateCodeVerifier();
const challenge = await codeChallengeS256(verifier);
const state = generateState();
sessionStorage.setItem("pkce-verifier", verifier);
sessionStorage.setItem("oauth-state", state);
sessionStorage.setItem("smart-config", JSON.stringify(smartConfig));
window.location.assign(
buildAuthorizeUrl({
smartConfig,
clientId: "app-123",
redirectUri: "https://app.example/cb",
scope: "launch/patient openid fhirUser patient/*.rs offline_access",
state,
codeChallenge: challenge,
aud: FHIR_BASE,
}),
);
}
export async function completeLogin() {
const smartConfig = JSON.parse(sessionStorage.getItem("smart-config")!);
const params = new URLSearchParams(window.location.search);
if (params.get("state") !== sessionStorage.getItem("oauth-state")) {
throw new Error("state mismatch");
}
const tokens = await exchangeCode({
smartConfig,
clientId: "app-123",
redirectUri: "https://app.example/cb",
code: params.get("code")!,
codeVerifier: sessionStorage.getItem("pkce-verifier")!,
});
const tokenStore = new InMemoryTokenStore();
await tokenStore.set(withAbsoluteExpiry(tokens));
const smart = new SmartClient({
smartConfig,
clientId: "app-123",
tokenStore,
refreshIfWithinSec: 60,
onTokenRefresh: async (fresh) => {
localStorage.setItem("smart-token", JSON.stringify(fresh));
},
});
const fhir = createFhirClient<GeneratedSchema>({
baseUrl: FHIR_BASE,
auth: smart,
});
return { smart, fhir, tokenStore };
}
export async function withUnauthorizedRetry<T>(
fn: () => Promise<T>,
onUnauthorized: () => Promise<void>,
): Promise<T> {
try {
return await fn();
} catch (e) {
if (e instanceof FhirRequestError && e.status === 401) {
await onUnauthorized();
}
throw e;
}
}
Troubleshooting
- "invalid_grant" on refresh → the refresh token rotated and the
old one was used twice. Your
onTokenRefreshpersistence is not atomic. Make the write-to-storage step synchronous with the refresh response. SmartClientrefreshes on every request →refreshIfWithinSecis too large relative toexpires_in. A 3600 s token withrefreshIfWithinSec: 4000refreshes every call. KeeprefreshIfWithinSec≤ 10% ofexpires_in.- No
refresh_tokenin the token response → the server didn't honouroffline_access. ChecksmartConfig.scopes_supportedand confirm the scope went through unmodified. - Introspection endpoint 404 → some SMART v2 servers don't expose RFC 7662 introspection. Fall back to a "best effort" assumption: trust the local expiry and re-authorize on 401.
- 401 on every call after re-login → check that you cleared the
TokenStorebetween sessions. A stale refresh token persists until you callawait tokenStore.clear().