Sign in with DavidApps
Add DavidApps as the OpenID Connect identity provider for an app that owns its own session.
Use this when the app already has, or wants, its own session and just needs DavidApps to say who someone is. It is ordinary OpenID Connect: authorization code, mandatory PKCE, discovery, and an ID token your app verifies.
What the app receives for each person is a stable ID that is specific to that app. The same person signing in to two of your apps produces two different IDs, and neither can be joined to the other, so one app can never use the identifier as a lookup key into another. You do not need a user table just to remember who is signed in. When a business row needs an owner, store the issuer and that ID together; both values form the identity.
Register the app
In the dashboard, open the app's Keys and connections screen and press Set up sign-in. It asks for the addresses DavidApps may send people back to, one per line, and hands back a client ID and a client secret. The secret is shown once and cannot be shown again; making a new one invalidates the old one immediately, so have somewhere to put it before you press the button.
The return addresses are matched exactly. The route this SDK serves is
/_da/callback, so an address looks like:
https://app.example.com/_da/callbackEvery address on one app must be on the same host, and all of them must be
https — except localhost, 127.0.0.1, and [::1], which may also use plain
http. A port is part of the address like anything else: whatever you save is
what has to arrive, on any host. If you have saved development addresses on the
app, the same screen offers to fill the matching loopback callbacks in for you.
Setup also registers /api/auth/callback/davidapps (Auth.js) and
/api/auth/oauth2/callback/davidapps (Better Auth) on that same host, so
those libraries can use their default return path without a second client.
Localhost is a different host than production, so it is a different client and
a different pairwise ID — that is required, not a bug.
Auth.js, Better Auth, and Vercel
Auth.js can sign people in without a DavidApps SDK. Better Auth uses the
dedicated integration below. Discovery is
https://id.davidapps.dev/.well-known/openid-configuration. The issuer accepts
both client_secret_basic and client_secret_post. Signing is EdDSA
(Ed25519). PKCE S256 is mandatory. The person ID is specific to that client.
Auth.js (NextAuth v5) on a server-side app, including Vercel:
import NextAuth from "next-auth";
import { createDavidAppsAuthJsConfig } from "@davidapps/sdk-next/authjs";
export const { handlers, auth, signIn, signOut } = NextAuth({
...createDavidAppsAuthJsConfig({
clientId: process.env.AUTH_DAVIDAPPS_ID!,
clientSecret: process.env.AUTH_DAVIDAPPS_SECRET!,
}),
secret: process.env.AUTH_SECRET,
});This form has no adapter and creates no local user, account, or session rows. Do not add an Auth.js adapter around it: adapters make Auth.js create local user and account rows. Your application database remains separate and stores only the canonical owner key where a business record needs ownership. Auth.js keeps a small identity snapshot in its encrypted JWE cookie. The snapshot includes the exact issuer and app-specific ID, access role, organization, and optional name, email, and picture. Provider access, refresh, and ID tokens are never copied into it. Its expiration comes from the verified issuer token and is never extended when the session is read; it is always at most fifteen minutes.
The configuration requires PKCE, state, and nonce. client_secret_basic also
works at the issuer. Register
https://<production-host>/api/auth/callback/davidapps. Better Auth uses
https://<production-host>/api/auth/oauth2/callback/davidapps. The optional
createDavidAppsProviderPreset helper in @davidapps/sdk-core/oidc fills the
same fields if you already depend on that package.
When an invoice, note, or project needs an owner, it may store the canonical identity without duplicating a profile:
import { davidAppsIdentityKey } from "@davidapps/sdk-next/authjs";
const ownerIdentityKey = davidAppsIdentityKey(session.user.identity);Treat the opaque result as one value. Never identify a person by email, and never use the app-specific ID without its issuer.
examples/consumer-authjs-oidc is the complete reference app for this shape.
It has no adapter and no user, account, or session table. The separate
examples/consumer-authjs app demonstrates auto-login for an application that
already owns database sessions.
Better Auth as a consumer (your app, not this issuer) uses the dedicated
DavidApps integration. Do not use Better Auth's genericOAuth for this issuer:
version 1.6.26 decodes the ID token without verifying its signature, issuer,
audience, or nonce.
import { createDavidAppsBetterAuth } from "@davidapps/sdk-better-auth";
export const auth = createDavidAppsBetterAuth({
baseURL: "https://app.example.com",
clientId: process.env.DAVIDAPPS_CLIENT_ID!,
clientSecret: process.env.DAVIDAPPS_CLIENT_SECRET!,
secret: process.env.BETTER_AUTH_SECRET!,
});This integration stores no user, account, or session row in the application's
database. It verifies PKCE, state, nonce, signature, issuer, and audience, then
keeps only an encrypted session cookie until the verified issuer token expires,
never longer than fifteen minutes. Use identity.key as the canonical owner
key. user.id and user.pairwiseSubject are compatibility views of the bare
app-specific ID and must not be persisted without the issuer. Email and display
name are optional attributes, never identity keys. Provider access, refresh,
and ID tokens are discarded after the callback and never enter a browser
cookie. This is a dedicated adapterless Better Auth instance; keep it separate
from any database-backed Better Auth instance in the same application.
Vercel is Sign in with DavidApps only. Wall-it-off and auto-login need an
in-cluster check that can set identity headers. Preview URLs on *.vercel.app
cannot share a production client: the person ID is one host per client. Use a
stable production host, or a separate preview client (which mints a different
ID).
Install the SDK
The packages are built, packed, and install-verified, but they are not published to a package registry yet. Today, depend on them from this workspace or from a packed tarball:
// package.json
{
"dependencies": {
"@davidapps/sdk-better-auth": "workspace:*",
"@davidapps/sdk-core": "workspace:*",
"@davidapps/sdk-next": "workspace:*",
},
}Once the packages are published, the same integration installs with:
# after publication — not available yet
pnpm add @davidapps/sdk-better-auth @davidapps/sdk-core @davidapps/sdk-nextConfiguration
Six values, all read once at startup and validated before anything runs. The client secret is server-only and must never reach the browser.
DAVIDAPPS_ISSUER=https://id.davidapps.dev
DAVIDAPPS_JWKS_URL=https://id.davidapps.dev/api/auth/jwks
DAVIDAPPS_CLIENT_ID=...
DAVIDAPPS_CLIENT_SECRET=...
DAVIDAPPS_PROJECT_ID=... # the app's ID in the dashboard
DAVIDAPPS_PROJECT_ORIGIN=https://app.example.com// src/lib/project.ts
import {
normalizeIssuerOrigin,
normalizeProject,
type DavidAppsProject,
type IssuerOrigin,
} from "@davidapps/sdk-core/configuration";
import * as z from "zod";
const environmentSchema = z.strictObject({
DAVIDAPPS_CLIENT_ID: z.string().min(1).max(256),
DAVIDAPPS_CLIENT_SECRET: z.string().min(32).max(512),
DAVIDAPPS_ISSUER: z.string(),
DAVIDAPPS_JWKS_URL: z.string(),
DAVIDAPPS_PROJECT_ID: z.string().min(1).max(256),
DAVIDAPPS_PROJECT_ORIGIN: z.string(),
});
const environment = environmentSchema.parse({
DAVIDAPPS_CLIENT_ID: process.env.DAVIDAPPS_CLIENT_ID,
DAVIDAPPS_CLIENT_SECRET: process.env.DAVIDAPPS_CLIENT_SECRET,
DAVIDAPPS_ISSUER: process.env.DAVIDAPPS_ISSUER,
DAVIDAPPS_JWKS_URL: process.env.DAVIDAPPS_JWKS_URL,
DAVIDAPPS_PROJECT_ID: process.env.DAVIDAPPS_PROJECT_ID,
DAVIDAPPS_PROJECT_ORIGIN: process.env.DAVIDAPPS_PROJECT_ORIGIN,
});
const normalizedProject = normalizeProject({
applicationId: environment.DAVIDAPPS_PROJECT_ID,
origin: environment.DAVIDAPPS_PROJECT_ORIGIN,
});
const normalizedIssuer = normalizeIssuerOrigin(environment.DAVIDAPPS_ISSUER);
if (!normalizedProject.ok || !normalizedIssuer.ok) {
throw new Error("Invalid DavidApps consumer configuration");
}
export const clientId = environment.DAVIDAPPS_CLIENT_ID;
export const clientSecret = environment.DAVIDAPPS_CLIENT_SECRET;
export const issuer: IssuerOrigin = normalizedIssuer.value;
export const jwksUrl = environment.DAVIDAPPS_JWKS_URL;
export const project: DavidAppsProject = normalizedProject.value;normalizeProject derives the callback address and the audience from the
origin you gave it, so those two can never drift from the value you registered.
Both helpers return a result rather than throwing, and both refuse credentials,
paths, queries, and fragments in an origin.
Exchange the code and verify the identity
The token request accepts HTTP Basic or a form client_secret (what Auth.js
and Better Auth send by default). buildAuthorizationCodeTokenRequest still
builds Basic and keeps the secret out of the form — do not run that helper in
the browser.
// src/lib/oidc.ts
import {
buildAuthorizationCodeTokenRequest,
createRemoteOidcIdTokenVerifier,
} from "@davidapps/sdk-core/oidc";
import * as z from "zod";
import { clientId, clientSecret, issuer, jwksUrl } from "./project";
const tokenResponseSchema = z.strictObject({
access_token: z.string().min(1).max(8_192),
expires_in: z.number().int().positive().max(900),
id_token: z.string().min(1).max(12_288),
scope: z.string().min(1).max(512),
token_type: z.literal("Bearer"),
});
const verifier = createRemoteOidcIdTokenVerifier({ issuer, jwksUrl });
if (verifier === undefined) {
throw new TypeError("Invalid OIDC verification configuration");
}
export const exchanger = Object.freeze({
exchange: async (input: {
readonly code: string;
readonly codeVerifier: string;
readonly expectedAudience: string;
readonly expectedIssuer: typeof issuer;
readonly expectedNonce: string;
readonly redirectUri: string;
readonly signal: AbortSignal;
}) => {
if (
input.expectedAudience !== clientId ||
input.expectedIssuer !== issuer ||
input.signal.aborted
) {
throw new TypeError("OIDC token exchange denied");
}
const tokenRequest = buildAuthorizationCodeTokenRequest({
clientId,
clientSecret,
code: input.code,
codeVerifier: input.codeVerifier,
redirectUri: input.redirectUri,
});
const response = await fetch(`${issuer}/api/auth/oauth2/token`, {
body: tokenRequest.body,
headers: {
authorization: tokenRequest.authorization,
"content-type": "application/x-www-form-urlencoded",
},
method: "POST",
redirect: "error",
signal: input.signal,
});
if (!response.ok) throw new Error("OIDC token exchange failed");
const token = tokenResponseSchema.parse(await response.json());
const verified = await verifier.verify({
expectedAudience: input.expectedAudience,
expectedNonce: input.expectedNonce,
token: token.id_token,
});
if (!verified.ok) throw new TypeError("OIDC identity verification failed");
return { claims: verified.value, nonce: verified.value.nonce };
},
});createRemoteOidcIdTokenVerifier returns undefined if the signing-key
address is not a real path on the configured issuer, which is why the check
above is not optional. When it is built, it only ever fetches that one pinned
address, refuses redirects, bounds the response, and accepts only Ed25519 keys.
Verification then requires the exact issuer, the exact audience, a matching
nonce, a current lifetime no longer than fifteen minutes, and an identifier in
the app-specific shape. A token that fails any of those comes back as
{ ok: false, error: "invalid_identity" } with nothing else to inspect.
The example in examples/consumer-next reads the token response through a
byte-bounded reader instead of response.json(). Copy that if the app talks to
anything you do not fully control.
Two stores your app owns
The SDK does not choose where state lives, so you supply two small things:
- A one-use sign-in state store.
issuerecords the attempt;consumemust be atomic and must return nothing unless the browser, origin, and audience all match the record.BoundedSilentSsoStateStorefrom@davidapps/sdk-core/silent-ssois an in-process implementation, fine for a single instance; anything horizontally scaled needs the same guarantees in shared storage. - Your session. A function that takes verified claims and returns the
cookies that sign the person in. It must rotate the session identifier, and
the SDK checks every cookie it is handed: host-only naming,
HttpOnly,SameSite,Secureoutside loopback development, and a lifetime no longer than 900 seconds.
examples/consumer-next implements both — a file-backed state store and an
HMAC-sealed session cookie — and its file-backed store is a single-host
demonstration, not a distributed one.
Silent sign-in
On the paths you list, a visitor with a live DavidApps session can be signed in without ever seeing a screen. The file Next 16 loads before every request decides whether to try:
// src/proxy.ts
import { beginSilentSso } from "@davidapps/sdk-core/silent-sso";
import { createDavidAppsProxy } from "@davidapps/sdk-next/proxy";
import { clientId, issuer, project } from "./lib/project";
import { hasSession } from "./lib/session-store";
import { states } from "./lib/state-store";
export const proxy = createDavidAppsProxy({
beginSilentSso: (input) =>
beginSilentSso({
authorizationEndpoint: `${issuer}/api/auth/oauth2/authorize`,
...(input.browserBinding === undefined
? {}
: { browserBinding: input.browserBinding }),
clientId,
clock: { nowMilliseconds: () => Date.now() },
issuer,
next: input.next,
project,
signal: input.signal,
states,
}),
hasSession: (request) => hasSession(request),
project,
silentPaths: ["/dashboard"],
});
export const config = {
matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"],
};An attempt is made only when all of these hold: the request is a GET
top-level document navigation, its path matches one of silentPaths, its
origin is the origin you configured, there is no app session already, and the
one-attempt cookie is absent. Anything else continues anonymously.
prompt=none is fixed by the SDK, and so is everything around it — the client,
the redirect address, the response type, the scopes, and the freshly generated
PKCE verifier, nonce, and state. The endpoint is checked against the configured
issuer, and no caller-supplied query survives, so neither this app nor a
visitor can steer the request somewhere else.
The one-attempt guard
Without a guard, a visitor with no DavidApps session would bounce between the two sites forever. A short-lived cookie stops that:
__Host-da-tried Max-Age 300, Secure, HttpOnly, SameSite=LaxIt is set when an attempt is actually started, when the request carries duplicate copies of the guard or binding cookie, when the request cannot be handled at all, and when the deadline below is hit. It is not set when there is already a session, when the guard is already present, and when the path or the request shape was never eligible in the first place — those simply continue. A successful silent sign-in clears it; a visitor who turns out to be signed out keeps it, and after five minutes the app will quietly try once more.
The callback
Both the ordinary and the silent flow come back to the same route:
// src/app/%5Fda/[...da]/route.ts
import { completeSilentCallback } from "@davidapps/sdk-core/silent-sso";
import { createDavidAppsRouteHandlers } from "@davidapps/sdk-next/routes";
import { exchanger } from "@/lib/oidc";
import { clientId, issuer, project } from "@/lib/project";
import { createSession } from "@/lib/session-store";
import { states } from "@/lib/state-store";
const handlers = createDavidAppsRouteHandlers({
completeSilentCallback: (input) =>
completeSilentCallback({
browserBinding: input.browserBinding,
clientId,
clock: { nowMilliseconds: () => Date.now() },
exchanger,
issuer,
project,
requestUrl: input.requestUrl,
sessions: {
create: ({ claims }) => Promise.resolve(createSession(claims)),
},
signal: input.signal,
states,
}),
hydration: {
hydrate: () =>
Promise.resolve({ error: "hydration_denied" as const, ok: false }),
},
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. These handlers
serve exactly three addresses and refuse everything else under that prefix,
and they refuse POST everywhere:
/_da/callback both the ordinary and the silent return
/_da/hydrate the auto-login entry point
/_da/health a liveness answer with no state in itGive the auto-login option a function that always denies, as above, unless the app is actually using Auto-login.
What the callback does with state
The one-use state is spent only on an exact match: right browser, right origin, right audience. A state value that does not match any record consumes nothing, and a record whose browser, origin, or audience disagrees is kept rather than burned, so a bad request cannot invalidate the real one that is still in flight. The returned record is then re-checked against the live configuration before any token is requested.
A callback carrying login_required, interaction_required,
consent_required, or account_selection_required is a normal answer: the
visitor has no DavidApps session, so the app continues anonymously with the
guard cookie set. Any other error is treated as a failure.
The deadline
Silent sign-in is a hard 550 milliseconds. If the store, the network, or the
exchange has not finished by then, the helper abandons the attempt and returns
an opaque 503 with the guard cookie set, rather than holding the page. There
is no partial state to clean up, and nothing about the cause is disclosed.
Using the provider preset
If the app configures OIDC through a framework's provider list rather than
these helpers, createDavidAppsProviderPreset produces the correct settings.
It returns a result, not a preset:
import { createDavidAppsProviderPreset } from "@davidapps/sdk-core/oidc";
const preset = createDavidAppsProviderPreset(
process.env.DAVIDAPPS_ISSUER ?? "",
);
if (!preset.ok) {
// preset.error === "invalid_provider"
throw new Error("DAVIDAPPS_ISSUER must be an exact HTTPS origin");
}
const davidapps = preset.value;
// {
// checks: ["pkce", "state", "nonce"],
// discoveryUrl: "https://id.davidapps.dev/.well-known/openid-configuration",
// id: "davidapps",
// name: "DavidApps",
// pkceMethod: "S256",
// scopes: "openid email profile",
// subjectType: "pairwise",
// type: "oidc",
// }PKCE with S256, state, and nonce are all required, configuration comes from
discovery, and identity is app-specific. The preset carries no secret and no
token, so it is safe to log and safe to keep beside the rest of your provider
configuration. Supply the client secret separately, on the server.
Failures are opaque on purpose
Every refusal from these helpers is the same refusal. A wrong browser, a
replayed state, a mismatched audience, a bad nonce, an unverifiable token, and
a session your own code rejected all produce callback_denied, and the route
answers with a small JSON body and no detail. There is nothing to read back
from a failed attempt, which is what stops one from being used to probe the
next.
Next
- Groups and access — deciding who may sign in at all.
- Sign-in page — how the page they see is styled.
- Auto-login — for an app that cannot adopt this flow.