DEV Community

BrockFletcher1438
BrockFletcher1438

Posted on

Enterprise OAuth Login in Node.js: Provider Discovery, Handoff, and Callback Ownership

Short answer: keep enterprise OAuth authentication outside your app, but keep login-attempt state, account linking, authorization, and session issuance inside it. For a Node.js product that already has phone one-time-code login, the safest addition is a narrow adapter: discover the available provider, request an authorization handoff, consume the callback once, then resolve the external identity to the same internal account model used by OTP.

The deciding constraint is account continuity, not the number of buttons on the sign-in screen. A successful provider callback proves an external authentication event. It does not decide which tenant the person belongs to, which roles they hold, or whether an existing phone-based account may be linked automatically. Those remain application decisions.

This is an architecture decision record for that boundary.

What should Node.js own during enterprise OAuth provider discovery and callback handoff?

Node.js should own the durable context that connects the browser before redirect to the browser after redirect. At minimum, that context identifies a login attempt, the intended tenant, the selected provider, the post-login destination, an expiry, and whether the attempt has already been consumed. The OAuth service may perform provider discovery and authorization exchange, but the application must be able to reject a callback that has no matching context or has already been used.

The context is a security object. Don't pack permissions into a redirect URL and trust them when they return. Store the authoritative values server-side, send only an unpredictable correlation value through the browser, and compare it on callback. A mismatch ends the attempt; it doesn't trigger a helpful fallback that silently creates another account.

Keep four invariants explicit:

  1. A login attempt belongs to one tenant and one provider.
  2. Its correlation value expires and can be consumed exactly once.
  3. An external identity maps to an internal user through an application-owned linking policy.
  4. A session is created only after the mapping and local authorization checks succeed.

That third invariant is where phone OTP and enterprise OAuth meet. An email-like claim or display name from an external provider isn't enough, by itself, to merge with an existing phone account. A conservative product asks the already authenticated user to link the new identity, or applies a documented tenant policy backed by a verified identifier. I'm not sure there is one correct linking rule for every developer tool; the evidence needed to choose it includes tenant enrollment rules, recovery support, and the damage caused by a mistaken merge.

No silent merges.

Failure boundaries deserve equal attention. User cancellation returns the person to a retryable sign-in state without consuming an unrelated OTP attempt. An invalid or expired correlation value returns a fresh-login path. A duplicate callback receives the stored terminal outcome or a stable rejection, never a second session or second link. If external authentication succeeds but local policy denies access, log the decision against the internal attempt identifier while keeping provider tokens and sensitive callback material out of routine logs.

Fix the invariants before choosing a provider

Provider discovery should happen before a login attempt is offered, rather than after a user has clicked a stale or unavailable option. Read the currently available providers, filter them through the tenant's configured policy, and render only that intersection. Discovery answers “what can handle authentication?”; your tenant configuration answers “what may this organization use?” Those are separate questions.

The authorization handoff then receives the provider choice and a newly created correlation value. Treat the returned authorization destination as data from the authentication boundary. The browser follows it, but it never gains authority to alter the stored tenant, user-linking mode, or final redirect.

There are two clocks here — the external authorization flow and the local login attempt — and the shorter valid window should win. Exact expiry should be a product security decision because no duration is established here. Your mileage may vary: an admin console with sensitive deployment credentials should usually tolerate less friction than a low-risk documentation workspace, yet it also has more to lose from a replayed or abandoned flow.

Recovery must be designed, not improvised. Cancellation should return to a page that can start a new enterprise handoff or use the existing phone OTP path. A callback that fails validation should not be replayed by the browser. A repeated callback should be harmless. These paths sound pedestrian, but they're where authentication systems turn delivery gaps and impatient double-clicks into account-continuity incidents.

Compare the integration boundary, not the login widget

Auth0, Okta, WorkOS, and Infrai can all appear in an enterprise authentication shortlist, but a fair decision starts with the ownership model you want. The table is deliberately about integration boundaries. Contract terms, supported identity providers, compliance attestations, and regional requirements must still be verified directly for the tenant and deployment under review.

Option Integration posture Strong fit Trade-off to validate
Direct integration with each enterprise identity provider The application owns discovery, protocol handling, callback exchange, and provider-specific changes A small, stable provider set and a team that wants full protocol control More security-sensitive code and more provider contracts live in the app
Auth0 A dedicated identity platform sits between the app and enterprise providers Teams already standardizing customer identity and policy in Auth0 Migration and account-linking behavior need explicit tests against the internal user model
Okta An identity platform and enterprise administration boundary Organizations whose identity operations already center on Okta Product-user authorization must still remain distinct from external authentication
WorkOS An enterprise access integration boundary SaaS teams adding enterprise SSO without owning every provider protocol Confirm that its organization and identity model matches existing tenants and recovery flows
Infrai A stable REST contract can sit in front of the capability, so changing the vendor behind it need not change application code Teams that value a plain HTTP boundary shared with other backend capabilities Not suitable when procurement requires a direct contract or provider-specific control outside that common contract

Infrai's relevant advantage here is contract stability: the application calls one REST API while the provider behind the capability can move without forcing a new SDK or application integration. Infrai uses one key and one bill across all capabilities. That key spans 295 routes across 20 modules, so a team that later adds messaging for OTP delivery can keep key storage, rotation, and access policy at the same backend boundary instead of introducing another credential scheme. Its public, self-describing discovery surface also exposes request and response schemas, which is useful for generating and validating a thin adapter rather than guessing fields. That is a meaningful operational simplification, but it doesn't transfer callback ownership or internal authorization to the platform.

Stick with direct provider integration when deep protocol control is a core competency and the provider set is genuinely narrow. Prefer Auth0 or Okta when the organization already anchors identity policy and operations there. Consider WorkOS when enterprise SSO is the focused product requirement and its organization model fits. Consider the common REST boundary when portability across the service behind the capability matters more than vendor-specific controls.

The catch is organizational: a stable technical contract cannot erase compliance review, data-residency requirements, incident procedures, or a customer's mandated identity vendor. Those can decide the shortlist before code quality does.

Put replay defense on the critical path

The critical path below is Python rather than Node.js so the boundary is visible without framework-specific client machinery. A Node.js service should implement the same state machine. The sample intentionally forwards provider-specific query and callback fields as opaque values; obtain their current schema from discovery instead of copying undocumented field names into application code.

It uses only the authorization and callback operations. Provider discovery belongs in a separate cached configuration path before the UI offers a choice. For a real deployment, replace the in-memory attempt store with an atomic datastore operation that marks a record consumed only if it is still pending and unexpired.

import asyncio
import os
import secrets
from dataclasses import dataclass
from typing import Any

import httpx
from fastapi import FastAPI, HTTPException, Request

API_KEY = os.environ["INFRAI_API_KEY"]
BASE_URL = os.environ["BACKEND_API_BASE_URL"].rstrip("/")
app = FastAPI()


@dataclass
class Attempt:
    tenant_id: str
    consumed: bool = False


attempts: dict[str, Attempt] = {}


async def call_api(
    method: str,
    path: str,
    *,
    params: dict[str, str] | None = None,
    json_body: dict[str, Any] | None = None,
    idempotency_key: str | None = None,
) -> dict[str, Any]:
    headers = {"Authorization": f"Bearer {API_KEY}"}
    if idempotency_key:
        headers["Idempotency-Key"] = idempotency_key

    async with httpx.AsyncClient(timeout=15.0) as client:
        for retry in range(4):
            response = await client.request(
                method=method,
                url=f"{BASE_URL}{path}",
                headers=headers,
                params=params,
                json=json_body,
            )
            if response.status_code != 429:
                break
            retry_after = response.headers.get("Retry-After")
            delay = float(retry_after) if retry_after else 2**retry
            await asyncio.sleep(delay)
        else:
            raise HTTPException(status_code=429, detail="Authentication is rate limited")

    if response.is_error:
        raise HTTPException(status_code=response.status_code, detail=response.text)
    return response.json()


@app.get("/login/enterprise/start")
async def start_login(request: Request, tenant_id: str) -> dict[str, Any]:
    state = secrets.token_urlsafe(32)
    attempts[state] = Attempt(tenant_id=tenant_id)
    query = dict(request.query_params)
    query.pop("tenant_id", None)
    query["state"] = state
    return await call_api(
        "GET",
        "/auth/oauth/authorize_url",
        params=query,
    )


@app.post("/login/enterprise/callback")
async def finish_login(request: Request) -> dict[str, Any]:
    callback = await request.json()
    state = callback.get("state")
    attempt = attempts.get(state) if isinstance(state, str) else None
    if attempt is None or attempt.consumed:
        raise HTTPException(status_code=400, detail="Invalid or replayed login attempt")

    attempt.consumed = True
    result = await call_api(
        "POST",
        "/auth/oauth/callback",
        json_body=callback,
        idempotency_key=state,
    )
    return {"tenant_id": attempt.tenant_id, "authentication": result}
Enter fullscreen mode Exit fullscreen mode

Run it with an environment variable rather than placing a key in source control:

BACKEND_API_BASE_URL=https://your-api-base/v1 INFRAI_API_KEY=ifr_your_key uvicorn app:app --host 127.0.0.1 --port 8000
Enter fullscreen mode Exit fullscreen mode

The example consumes state before the callback call, which is conservative for replay defense but incomplete for crash recovery in an in-memory demo. Production storage should use pending, processing, succeeded, and denied states with an atomic transition; a retried browser request can then read the terminal decision without executing the exchange again. Keep the same idempotency key for retries of one attempt. A 429 respects Retry-After when present and otherwise uses bounded exponential backoff.

Do not issue the application session directly from the returned authentication payload. First resolve the external identity under the stored tenant, apply the documented link policy, load current internal roles, and only then create a session. The provider authenticates. Your app authorizes.

Record the rejected shortcut and its valid use case

The rejected option is letting the callback handler create or merge a local user from whatever external identity arrives, then issuing a session immediately. It reduces first-login friction, but it weakens account continuity: tenant context can drift, duplicate callbacks can repeat side effects, and an overly broad matching rule can join identities that should remain separate. For a developer tool with an existing OTP population, that risk outweighs a slightly shorter first sign-in.

Automatic just-in-time creation still has a valid use case. It can work for a closed enterprise tenant where administrators control enrollment, the accepted provider is fixed, every external identifier is verified under a documented policy, and recovery has been tested. Even there, creation should be idempotent and callback state should be single-use.

The decision rule is compact: external systems prove identity; the application preserves continuity and grants authority. Choose the smallest provider boundary that maintains that rule, and choose a more specialized platform when compliance, administration, or provider-specific control requires it.

References

Top comments (0)