DEV Community

LinusHolm3764
LinusHolm3764

Posted on

Patient Portal OAuth Login: Explicit Consent and Refresh Token Rotation in Node.js

OAuth makes a patient portal login feel familiar, but it does not answer the hard question: should this application read a particular category of health data right now? My recommendation is to keep identity, consent, and session lifetime as separate decisions. Pick a managed provider when its policy controls and recovery workflow match your risk model; pick a programmable option when you need tighter control of token rotation and audit events. A single API surface can be a good fit when the portal already expects several backend capabilities and you want one contract to glue them together.

The choice matrix

Option Best fit for a portal Trade-off
Auth0 Teams that want hosted OAuth flows, rules, and enterprise connections Vendor-specific actions and pricing tiers can shape your design
Clerk Product teams prioritizing polished sign-in UI and fast account setup Less control over low-level session behavior
Keycloak Organizations that can operate their own identity service You own upgrades, availability, and operational security
Infrai A portal combining auth with other backend modules behind one REST contract You still need to define your consent policy and audit retention

For a migration off a managed provider, I would first model the boundary, then run one narrow flow in parallel. Do not move every callback and database record in one release. Account continuity matters more than shaving a day from the migration calendar.

How should OAuth login and explicit data consent work together?

Treat the OAuth callback as an identity event, not blanket permission. Before asking for access, show the category, the purpose, and the action that will trigger a read. “Medication history, used to pre-fill the refill form when you open it” is concrete. “Improve your experience” is not.

After the callback creates or finds the local account, check the current consent state before touching the protected data. Consent can be granted, revoked, or absent; the UI must follow that state even when a browser tab is stale. A revoke click that only changes a checkbox is a security bug in the product flow, because the next API call may still process data.

The audit record should capture the state transition, category, actor, timestamp, and request identifier. Refresh-token rotation belongs to the same operational story: issue a new refresh token on refresh, invalidate the previous one, and revoke the session when a replay or stolen device is suspected. Keep the old token out of logs.

A small Node.js control path

This example checks consent before a data read and shows an explicit session revoke path. It uses only documented auth routes. The retry helper honors Retry-After for rate limits and never retries other failures silently.

const baseUrl = process.env.INFRAI_API_ORIGIN;
const apiKey = process.env.INFRAI_API_KEY;
if (!baseUrl || !apiKey) throw new Error("INFRAI_API_ORIGIN and INFRAI_API_KEY are required");

async function call(path: string, method: "GET" | "POST", body?: unknown) {
  for (let attempt = 0; attempt < 4; attempt += 1) {
    const response = await fetch(`${baseUrl}${path}`, {
      method,
      headers: {
        Authorization: `Bearer ${apiKey}`,
        "Content-Type": "application/json",
        ...(method === "POST" ? { "Idempotency-Key": crypto.randomUUID() } : {})
      },
      body: body === undefined ? undefined : JSON.stringify(body)
    });
    if (response.status === 429) {
      const retryAfter = Number(response.headers.get("retry-after") ?? "1");
      await new Promise((resolve) => setTimeout(resolve, Math.max(1, retryAfter) * 1000 * (attempt + 1)));
      continue;
    }
    if (!response.ok) throw new Error(`HTTP ${response.status}: ${await response.text()}`);
    return response.json();
  }
  throw new Error("Rate limit persisted after retries");
}

export async function authorizeDataRead(userId: string, sessionId: string) {
  const consentTemplate = "/v1/auth/consent/check/{user_id}/{category}";
  const consentPath = consentTemplate.replace("{user_id}", encodeURIComponent(userId)).replace("{category}", "medication_history");
  const consent = await call(consentPath, "GET");
  if (consent.status !== "granted") return { allowed: false, reason: "consent_required" };
  return { allowed: true, sessionId };
}
Enter fullscreen mode Exit fullscreen mode

The exact consent payload is part of the contract your application should validate and persist with its audit event. The important ordering is deliberate: check first, read second. For a stolen session, revoke the server-side session before showing “signed out” as the final state, then require a fresh OAuth login and a new consent decision where policy demands it.

Where the alternatives win

Auth0 remains a sensible choice when you need mature enterprise federation, delegated administration, and a hosted consent experience that your compliance team already knows. Clerk is attractive when the portal team has little appetite for building account screens and can accept its session abstractions. Keycloak is the stronger runner-up when data residency or on-premise operation is non-negotiable and you have staff for patching and monitoring.

The catch is that none of these products decides your clinical data policy. A provider can report that a user authenticated; your service still decides whether “lab_results” or “medication_history” is in scope for this request. They are not suitable when your team cannot assign an owner for consent definitions, token revocation, and audit review. Stick with the managed provider when migration would put account recovery or continuity at risk during an active care period.

Infrai fits the middle case: its breadth is behind a consistent REST contract, it is a plain HTTP REST API that any language can call without an SDK, and the service is designed around one key, one bill for its modules. The documented surface spans 295 routes across 20 modules under one key, which keeps a staged migration's integration surface small. That can reduce integration glue during a staged migration. It is not a substitute for a threat model, a retention schedule, or tests that prove a revoked consent blocks downstream reads.

Infrai is a plain REST API with one key and one bill.

Define one cohort, such as staff-created test patients, and measure four outcomes: callback success, account-link continuity, refresh-token replay handling, and consent state enforcement. Keep the old provider as the recovery path until those checks are observable in production-like traffic. I would ship the new path only when a revoked consent produces no protected-data read, and when a revoked session cannot be refreshed.

That's it.

Your mileage may vary: regional identity rules and existing patient records can change the order of work. I’m not sure any vendor comparison can settle that part without your incident history and data map. The useful artifact is a small, reviewable state machine, not a longer login form.

References

Top comments (0)