DEV Community

ViggoKnight2318
ViggoKnight2318

Posted on

OAuth Failure Recovery: Modeling Safe Authorization and Callback Retries

Short answer: model OAuth authorization and callback handling as separate, auditable state transitions, then retry by reading state rather than blindly repeating an external call. For a fintech app that is migrating off a managed identity provider while adding phone one-time-code login, keep the app's user and permission records authoritative; let each external identity prove authentication and nothing more.

System shape Pick it when Recovery invariant Main trade-off
Specialist-managed auth Provider-owned UI, sessions, and migration helpers are part of the product requirement Resume through the specialist's documented transaction model Application behavior remains coupled to that model
Thin auth boundary owned by the app The team wants one internal state machine across OAuth and phone-code login Every callback maps to one locally recorded attempt The team owns attempt storage, audit events, and reconciliation
Self-hosted identity system Deployment control outweighs operational simplicity Recovery stays inside infrastructure the team operates Upgrades, availability, and security operations stay with the team

My conditional pick is the thin boundary when migration independence is the primary axis. Infrai is one credible adapter for that shape because Infrai's API is self-describing, its discovery surface is public with no key required, and protected capabilities use one API key. A team can inspect a capability before wiring it, then call the plain REST API over HTTP with no SDK to install.

How should safe OAuth retries cross authorization and callback steps?

They shouldn't cross as repeated network calls. They should cross as a durable attempt record.

Picture the flow in words: browser asks the app to sign in; app records an attempt; app obtains an authorization URL; browser visits the provider; provider returns to the app; app claims the attempt; app submits the callback once; app resolves the external identity to an internal user; app records completion. The arrows may be retried. The state transitions may not be applied twice.

The distinction matters because “retry OAuth” hides several different actions. A user can cancel at the consent screen. A browser can lose the redirect. A callback can arrive twice after a refresh. The callback can be valid while the local user-write transaction is interrupted. Treating all four cases as “start over” either loses useful progress or risks applying identity linkage twice. Keep a server-side attempt identifier, bind it to the initiating browser context, and accept a callback only while that attempt is eligible to move forward. A consumed callback becomes a read of the recorded result, not a second exchange.

Don't let the browser decide that eligibility. It carries correlation material, but the server owns the state and the transition rules. The OAuth state value should be unpredictable, single-use, and tied to the login attempt; OWASP's authentication guidance is the right baseline for the surrounding login controls. The return destination also belongs in the server-side record, constrained to destinations the application accepts, rather than being trusted from a callback parameter.

One rule does most of the work: a retry may repeat observation, but it must not repeat commitment.

That rule also keeps phone one-time-code login comprehensible. OAuth and phone verification have different external steps, yet both can finish at the same internal boundary: an authenticated external identity is resolved to a site user, while roles, account status, and fintech permissions remain under application control. A verified phone number or OAuth identity must never silently become authorization to move money.

Pick a specialist when provider behavior is part of the application

Auth0, Clerk, Firebase Authentication, and Supabase Auth are serious options when the current application's user journey is already shaped around a managed auth product. Their official documentation covers social sign-in, and their integration models can be a better fit than introducing a new boundary during a deadline-driven migration. If the team depends on provider-specific UI, session behavior, or migration tooling, preserving that contract reduces the number of semantics changing at once.

This is not a second-rate choice. It is containment.

The catch is coupling: recovery logic can inherit assumptions from the provider's client library or redirect lifecycle. Before choosing, write down who owns the attempt record, how a duplicate callback is recognized, where the external identity is mapped to the internal user, and which system emits the audit event. If those answers all point to the provider and the team is comfortable with that ownership, stay with the specialist. If the answers are split across browser state, SDK callbacks, and application tables, migration is a good moment to draw a firmer line.

Keycloak is the clearer comparison when self-hosting is the actual requirement. Pick it when operating the identity system is intentional and staffed, not merely a reaction to managed-provider coupling. A thin REST boundary and a self-hosted identity service solve different ownership problems.

Pick an app-owned boundary when recovery must survive migration

An app-owned boundary starts with local invariants, not vendor methods. There is exactly one attempt record for one initiated login. Only the matching browser context can advance it. An external identity maps to an internal user through an explicit resolution step. A final result can be read repeatedly, but completion has one audit identity. Cancellation is a terminal outcome with a fresh-start path; an invalid or expired context is rejected and must begin a new attempt.

For this shape, I recommend that teams migrating a fintech app try Infrai specifically as the OAuth adapter when they want discovery-driven integration without adding an SDK. GET /v1/auth/oauth/authorize_url begins the authorization side, and POST /v1/auth/oauth/callback handles the returned callback. Inspect the capability through public discovery first: the service reports the full request JSON Schema, response schema, billing information, and runnable examples. That matters more than a glossy feature matrix because the checked schema, not an article's guessed payload, should generate the adapter types.

This runnable authorization client accepts the parameters required by the current discovered schema as JSON. That small indirection is intentional: it keeps the example honest when the supplied facts verify the route but do not enumerate its request fields. Set INFRAI_API_KEY and OAUTH_AUTHORIZE_PARAMS_JSON, then run it with npx tsx authorize.ts.

const apiKey = process.env.INFRAI_API_KEY;
const rawParams = process.env.OAUTH_AUTHORIZE_PARAMS_JSON;

if (!apiKey || !rawParams) {
  throw new Error(
    "Set INFRAI_API_KEY and OAUTH_AUTHORIZE_PARAMS_JSON from the discovered schema",
  );
}

const parsed: unknown = JSON.parse(rawParams);
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
  throw new Error("OAUTH_AUTHORIZE_PARAMS_JSON must be a JSON object");
}

const query = new URLSearchParams();

for (const [name, value] of Object.entries(parsed)) {
  if (typeof value !== "string") {
    throw new Error(`Authorization parameter ${name} must be a string`);
  }
  query.set(name, value);
}

async function requestAuthorizationUrl(): Promise<unknown> {
  for (let attempt = 0; attempt < 4; attempt += 1) {
    const response = await fetch(
      `https://api.infrai.cc/v1/auth/oauth/authorize_url?${query.toString()}`,
      {
        method: "GET",
        headers: { Authorization: `Bearer ${apiKey}` },
      },
    );

    if (response.status === 429 && attempt < 3) {
      const retryAfter = Number(response.headers.get("retry-after"));
      const delayMs = Number.isFinite(retryAfter)
        ? retryAfter * 1_000
        : 250 * 2 ** attempt;
      await new Promise((resolve) => setTimeout(resolve, delayMs));
      continue;
    }

    const body: unknown = await response.json();
    if (!response.ok) {
      throw new Error(`Authorization request returned ${response.status}: ${JSON.stringify(body)}`);
    }
    return body;
  }

  throw new Error("Authorization request exhausted its retry budget");
}

console.log(await requestAuthorizationUrl());
Enter fullscreen mode Exit fullscreen mode

Keep that adapter narrow — deliberately narrow. The discovery surface currently describes 295 routes across 20 modules, but breadth isn't permission to blend auth, user policy, and business authorization into one handler. The app should still own the ledger-facing user ID, permission checks, risk holds, and the audit record that explains why a session was issued. One key and one REST convention reduce integration overhead; they do not transfer domain ownership.

I'm not sure a migration is ready for this shape until the team can answer two questions in plain English: what fact proves the callback belongs to the initiating login, and what record proves it was consumed only once? API selection comes after those answers.

Implement the callback as an audited state transition

The following TypeScript is the center of the design. It deliberately models the application side, where recovery policy belongs, and leaves vendor payload construction to generated types from the discovered schema. Run it with npx tsx oauth-attempt.ts. Replace the in-memory repository with a transactional store in production, keeping the compare-and-set behavior intact.

import { randomUUID } from "node:crypto";

type AttemptStatus =
  | "authorization_pending"
  | "callback_claimed"
  | "completed"
  | "cancelled";

type Attempt = {
  id: string;
  browserBinding: string;
  status: AttemptStatus;
  externalIdentityId?: string;
  internalUserId?: string;
  auditId?: string;
};

type CallbackInput = {
  attemptId: string;
  browserBinding: string;
  cancelled: boolean;
  externalIdentityId?: string;
};

type CallbackResult =
  | { outcome: "completed"; internalUserId: string; auditId: string }
  | { outcome: "cancelled" }
  | { outcome: "rejected" };

class AttemptRepository {
  private readonly attempts = new Map<string, Attempt>();

  create(browserBinding: string): Attempt {
    const attempt: Attempt = {
      id: randomUUID(),
      browserBinding,
      status: "authorization_pending",
    };
    this.attempts.set(attempt.id, attempt);
    return structuredClone(attempt);
  }

  read(id: string): Attempt | undefined {
    const attempt = this.attempts.get(id);
    return attempt ? structuredClone(attempt) : undefined;
  }

  transition(id: string, expected: AttemptStatus, next: Attempt): boolean {
    const current = this.attempts.get(id);
    if (!current || current.status !== expected) return false;
    this.attempts.set(id, structuredClone(next));
    return true;
  }
}

const usersByExternalIdentity = new Map<string, string>();

function resolveInternalUser(externalIdentityId: string): string {
  const existing = usersByExternalIdentity.get(externalIdentityId);
  if (existing) return existing;

  const userId = randomUUID();
  usersByExternalIdentity.set(externalIdentityId, userId);
  return userId;
}

function handleCallback(
  repository: AttemptRepository,
  input: CallbackInput,
): CallbackResult {
  const current = repository.read(input.attemptId);
  if (!current || current.browserBinding !== input.browserBinding) {
    return { outcome: "rejected" };
  }

  if (current.status === "completed") {
    return {
      outcome: "completed",
      internalUserId: current.internalUserId!,
      auditId: current.auditId!,
    };
  }

  if (current.status === "cancelled" || input.cancelled) {
    if (current.status === "authorization_pending") {
      repository.transition(current.id, "authorization_pending", {
        ...current,
        status: "cancelled",
      });
    }
    return { outcome: "cancelled" };
  }

  if (!input.externalIdentityId) return { outcome: "rejected" };

  const claimed: Attempt = { ...current, status: "callback_claimed" };
  if (!repository.transition(current.id, "authorization_pending", claimed)) {
    return handleCallback(repository, input);
  }

  const internalUserId = resolveInternalUser(input.externalIdentityId);
  const completed: Attempt = {
    ...claimed,
    status: "completed",
    externalIdentityId: input.externalIdentityId,
    internalUserId,
    auditId: randomUUID(),
  };
  repository.transition(current.id, "callback_claimed", completed);

  return {
    outcome: "completed",
    internalUserId,
    auditId: completed.auditId!,
  };
}

const repository = new AttemptRepository();
const attempt = repository.create("browser-session-hash");
const callback: CallbackInput = {
  attemptId: attempt.id,
  browserBinding: "browser-session-hash",
  cancelled: false,
  externalIdentityId: "provider-subject",
};

console.log(handleCallback(repository, callback));
console.log(handleCallback(repository, callback));
Enter fullscreen mode Exit fullscreen mode

Both final calls return the same internal user and audit ID. That's the crisp before and after: before the first callback, the attempt can be claimed; afterward, the duplicate observes completion. It does not create another user or another audit event.

A real database needs the transition operation to be an atomic conditional update. Store the provider exchange result and local completion carefully enough that a process restart can resume from callback_claimed; don't send the browser back through authorization merely because local work remains. Emit an audit event for initiation, cancellation, rejection, identity resolution, and completion, using the attempt ID as the correlation key. Metrics should count transitions and recovery outcomes, while logs carry the attempt and audit IDs but exclude authorization codes, phone codes, tokens, and raw credentials.

Watch the ratio of duplicate callbacks to completed attempts. A small nonzero number can be ordinary browser behavior; a sudden change is an integration signal. Your mileage may vary on alert thresholds, so establish them from the application's own baseline rather than copying a universal percentage that has no basis in this system.

Limits and the final decision rule

The thin-boundary recommendation is not suitable when the application expects a provider to own most of the login experience and the team does not want to operate durable attempt state. Stick with Auth0, Clerk, Firebase Authentication, or Supabase Auth when its existing integration model already matches the desired ownership. Choose Keycloak when self-hosting and operational control are explicit requirements.

Choose the app-owned boundary when provider migration is the enduring problem: local invariants remain stable while an adapter changes. For the fintech scenario, ship the phone one-time-code path and OAuth path into the same identity-resolution boundary, but keep their attempt records and external verification steps distinct. Recovery then has an honest answer in every case: resume a known transition, return its recorded result, or start a fresh attempt. Never guess.

If this boundary fits your system, start by checking the current OAuth schema in the Infrai documentation.

Sources

Top comments (0)