Auto-login

Turn a DavidApps visitor into a session in the app's own auth system, without changing that system.

Auto-login is for an app that already has sign-in code you do not want to replace. The app keeps its user table, its session store, and its framework. DavidApps identifies the person in front of the app and hands over a signed one-use handoff; the app checks the signature and then calls its own "sign this user in" path, exactly as it would after any other successful login.

It takes three files and one row shape in your database.

Before you start

The app must be behind DavidApps protection, because the signed handoff is a request header that only DavidApps may set. See Protect an app.

You also need the public half of the signing key, its key ID, and the app's ID and origin. The signing keys are pinned in configuration and read once at startup; nothing here fetches keys over the network, and no value inside the handoff can select which key verifies it.

DAVIDAPPS_ASSERTION_KID=...          # key ID, matched exactly
DAVIDAPPS_ASSERTION_PUBLIC_KEY=...   # 32 bytes, base64url
DAVIDAPPS_PROJECT_ID=...             # the app's ID in the dashboard
DAVIDAPPS_PROJECT_ORIGIN=https://app.example.com
AUTH_SECRET=...                      # your own framework's secret
// src/lib/project.ts
import { Buffer } from "node:buffer";

import {
  normalizeProject,
  type DavidAppsProject,
} from "@davidapps/sdk-core/configuration";
import * as z from "zod";

const environmentSchema = z.strictObject({
  AUTH_SECRET: z.string().min(32).max(512),
  DAVIDAPPS_ASSERTION_KID: z.string().regex(/^[A-Za-z0-9._~-]{1,128}$/u),
  DAVIDAPPS_ASSERTION_PUBLIC_KEY: z.string().regex(/^[A-Za-z0-9_-]{43}$/u),
  DAVIDAPPS_PROJECT_ID: z.string().min(1).max(256),
  DAVIDAPPS_PROJECT_ORIGIN: z.string(),
});

const environment = environmentSchema.parse({
  AUTH_SECRET: process.env.AUTH_SECRET,
  DAVIDAPPS_ASSERTION_KID: process.env.DAVIDAPPS_ASSERTION_KID,
  DAVIDAPPS_ASSERTION_PUBLIC_KEY: process.env.DAVIDAPPS_ASSERTION_PUBLIC_KEY,
  DAVIDAPPS_PROJECT_ID: process.env.DAVIDAPPS_PROJECT_ID,
  DAVIDAPPS_PROJECT_ORIGIN: process.env.DAVIDAPPS_PROJECT_ORIGIN,
});

const normalized = normalizeProject({
  applicationId: environment.DAVIDAPPS_PROJECT_ID,
  origin: environment.DAVIDAPPS_PROJECT_ORIGIN,
});
if (!normalized.ok) throw new Error("Invalid DavidApps project configuration");

const publicKey = Buffer.from(
  environment.DAVIDAPPS_ASSERTION_PUBLIC_KEY,
  "base64url",
);
if (publicKey.byteLength !== 32) {
  throw new Error("DAVIDAPPS_ASSERTION_PUBLIC_KEY must be 32 bytes");
}

export const authSecret = environment.AUTH_SECRET;
export const assertionKeys = Object.freeze({
  [environment.DAVIDAPPS_ASSERTION_KID]: Uint8Array.from(publicKey),
});
export const project: DavidAppsProject = normalized.value;

The one model

One row per person per issuer, keyed by the issuer and the stable app-specific ID. That pair is the whole identity key. Nothing here matches on email address, because an email address is not proof of anything and can change.

model ExternalIdentity {
  id       String @id @default(cuid())
  issuer   String
  subject  String
  userId   String
  user     User   @relation(fields: [userId], references: [id])

  @@unique([issuer, subject])
}

The unique constraint is what makes first sign-in race-safe. Two simultaneous first requests both try to create the row; one wins, the loser sees the conflict and reads back the winner. That behaviour is built in, provided you tell the SDK how to recognise your database's conflict error.

File 1 — send the visitor to the auto-login address

The signed handoff arrives as a request header on the page the visitor asked for. It must not end up in an address, in browser history, or in client-side JavaScript, so this file only redirects; the header is read later, on the request to the auto-login address, where a fresh handoff arrives with it.

// src/proxy.ts
import { createDavidAppsHydrationProxy } from "@davidapps/sdk-next/hydration-proxy";

import { AUTHJS_SESSION_COOKIE } from "./lib/hydration";
import { project } from "./lib/project";
import { hasSessionToken } from "./lib/store";

export const proxy = createDavidAppsHydrationProxy({
  hasSession: (request) =>
    hasSessionToken(request.cookies.get(AUTHJS_SESSION_COOKIE)?.value),
  project,
});

export const config = {
  matcher: ["/((?!api/auth|_next/static|_next/image|favicon.ico).*)"],
};

It redirects only for a GET top-level document navigation, on the origin you configured, when there is no app session yet and no recent-sign-in hint cookie, and when the request carries exactly one handoff header. Everything else — API calls, sub-resources, the auto-login address itself, a visitor who is already signed in — is passed straight through. If your session check throws, it is treated as "no session" rather than as an error page.

File 2 — verify the handoff and create your session

// src/lib/hydration.ts
import {
  createHydrationService,
  type HydrationService,
  type NativeSessionCookie,
} from "@davidapps/sdk-core/assertion";
import { createRaceSafeExternalIdentityResolver } from "@davidapps/sdk-core/external-identity";

import { assertionKeys, project } from "./project";
import { assertionReplays } from "./replay-store";
import { AUTHJS_SESSION_MAX_AGE_SECONDS } from "./session-policy";
import {
  createRotatedSession,
  findIdentity,
  provisionIdentity,
  UniqueIdentityConflict,
  type LocalUser,
} from "./store";

export const AUTHJS_SESSION_COOKIE = "authjs.session-token";

const identities = createRaceSafeExternalIdentityResolver<LocalUser>({
  find: ({ issuer, subject }) => Promise.resolve(findIdentity(issuer, subject)),
  isUniqueConflict: (cause) => cause instanceof UniqueIdentityConflict,
  provision: (claims) =>
    Promise.resolve(
      provisionIdentity({
        appRole: claims.o.rol,
        email: claims.email,
        emailVerified: claims.email_verified,
        issuer: claims.iss,
        name: claims.name,
        subject: claims.sub,
      }),
    ),
});

const configured = createHydrationService({
  clock: { nowSeconds: () => Math.floor(Date.now() / 1_000) },
  identities,
  keys: assertionKeys,
  project,
  replays: assertionReplays,
  sessions: {
    create: ({ user }) => {
      const session = createRotatedSession(user);
      const cookie: NativeSessionCookie = {
        httpOnly: true,
        maxAgeSeconds: AUTHJS_SESSION_MAX_AGE_SECONDS,
        name: AUTHJS_SESSION_COOKIE,
        path: "/",
        sameSite: "Lax",
        secure: !project.isLocalDevelopment,
        value: session.sessionToken,
      };
      return Promise.resolve({ cookies: [cookie], rotated: true });
    },
  },
});

if (configured === undefined) {
  throw new Error("Invalid DavidApps signing-key configuration");
}

export const hydration: HydrationService = configured;

createHydrationService returns undefined — rather than throwing later — if the pinned key set is empty, larger than eight keys, or contains a key that is not exactly 32 bytes. Check it at startup, as above.

File 3 — the route handler

// src/app/%5Fda/[...da]/route.ts
import { createDavidAppsRouteHandlers } from "@davidapps/sdk-next/routes";

import { hydration } from "@/lib/hydration";
import { project } from "@/lib/project";

const handlers = createDavidAppsRouteHandlers({
  completeSilentCallback: () =>
    Promise.resolve({ error: "callback_denied" as const, kind: "error" }),
  hydration,
  project,
});

export const dynamic = "force-dynamic";
export const runtime = "nodejs";
export const GET = handlers.GET;
export const POST = handlers.POST;

The directory is spelled %5Fda on disk because Next treats a leading underscore as private; the address the browser uses is /_da. Deny the silent sign-in callback, as above, unless the app is also using Sign in with DavidApps.

The route must run on the Node runtime — signature verification needs it — and it must not be cached.

What gets checked

Nothing is trusted until all of this passes, and every failure produces the same refusal:

  • The signature, against a pinned key chosen by exact key ID. An unknown key ID stops there; a handoff cannot nominate its own key.
  • The intended app, both the exact hostname and the app's own ID. A handoff minted for one of your hosts is not valid at another.
  • The lifetime. A handoff lives 60 seconds, with 60 seconds of clock tolerance either side, and its issue and expiry times must agree with each other exactly.
  • One use, ever. The identifier is consumed atomically before any session work happens. A replay of a still-valid handoff is refused, and if the store cannot answer, the request is refused too.
  • The destination, which must be a canonical same-origin relative address. Absolute addresses, network paths, backslashes, fragments, control characters, and encoded separators are rejected rather than rewritten.
  • The identity, resolved on issuer and stable app-specific ID only.
  • The session, which must report that it rotated its identifier, and whose cookies must be HttpOnly, host-only in naming, Secure outside loopback development, and no longer than 900 seconds.

Rotating on sign-in is what defeats session fixation: an identifier planted in the browser beforehand is discarded rather than promoted.

Visitor handoffs are refused unless the app has visitor sign-in switched on.

On success the browser gets a 303 to the address it originally asked for, carrying your session cookies plus a short-lived hint cookie so the next navigation does not run the check again. On failure it gets a 403 with a small JSON body and no explanation.

Choosing how the session is made

The one thing the SDK never does is decide what a session is. You hand it a function; it validates whatever that function returns. There are three shapes that function usually takes:

  1. Call your framework's own sign-in path and return the cookies it sets. Closest to what the rest of your app already does.
  2. Write a session record through your own database adapter and return the cookie that points at it. This is what examples/consumer-authjs does, and it is the option to pick when the framework's sign-in call assumes a provider redirect that has not happened here.
  3. Return a sealed cookie you signed yourself, for an app whose sessions are stateless.

Pick one. Nothing switches between them at runtime, and there is no fallback if your choice fails — a failure is a refusal, which is the behaviour you want on a sign-in path.

What production needs that the examples do not have

The examples in this repository are proofs, not deployment templates. Four pieces of state are on the critical path, and all four need real storage:

StateWhat it must guarantee
One-use handoff storeAtomic consume, TTL past the handoff lifetime and clock tolerance, bounded, fail closed
External identity recordsThe unique pair, and a conflict error your resolver can recognise
SessionsWhatever your framework needs, shared across every instance
Sign-in stateOnly if you also use silent sign-in; same atomicity rules

examples/consumer-authjs backs its one-use store with files on disk and keeps its identities and sessions in the running process. That is correct for one host with one volume and is deliberately not a horizontally scalable database. Running two instances against that setup would let a handoff be spent twice. Replace all four with storage that preserves the same guarantees before this goes anywhere real.

Next

On this page