@glydi/passkey-server
v0.1.0
Published
Lightweight, framework-agnostic WebAuthn challenge + verification handlers for Glide. Built on @simplewebauthn/server.
Readme
@glydi/passkey-server
Lightweight, framework-agnostic WebAuthn challenge + verification handlers for Glide. Built on @simplewebauthn/server.
Install
Glide is not yet published to npm. Install from a packed tarball or via pnpm link.
Tarball (recommended):
{
"dependencies": {
"@glydi/passkey-server": "file:../glide/dist-packs/glydi-passkey-server-0.1.0.tgz",
"@glydi/passkey-core": "file:../glide/dist-packs/glydi-passkey-core-0.1.0.tgz"
},
"pnpm": {
"overrides": {
"@glydi/passkey-core": "file:../glide/dist-packs/glydi-passkey-core-0.1.0.tgz"
}
}
}The pnpm.overrides entry for @glydi/passkey-core prevents pnpm from trying to fetch
the transitive peer from the npm registry. See docs/DISTRIBUTION.md
for full tarball and pnpm link instructions.
Forthcoming:
npm install @glydi/passkey-serverwill be the public form once the package is published. It is not yet available on npm.
Minimal Usage
The correct pattern for Next.js App Router is lazy initialization — wrapping
createGlideServer and createPasskeyRouteHandler inside functions that construct
on first call rather than at module scope. This is required because createInMemoryStore()
throws under NODE_ENV=production, and next build evaluates module-level code with
NODE_ENV=production — so eager construction breaks the build.
lib/glide.ts — lazy server initializer:
import { createGlideServer, createInMemoryStore } from "@glydi/passkey-server";
const RP_ID = process.env.GLIDE_RP_ID ?? "localhost";
const ORIGIN = process.env.GLIDE_ORIGIN ?? "http://localhost:3000";
let _glide: ReturnType<typeof createGlideServer> | null = null;
export function getGlide(): ReturnType<typeof createGlideServer> {
if (_glide === null) {
_glide = createGlideServer({
rpName: "My App",
rpID: RP_ID,
origin: ORIGIN,
store: createInMemoryStore(),
});
}
return _glide;
}app/api/passkey/[action]/route.ts — lazy route handler:
import { createPasskeyRouteHandler } from "@glydi/passkey-server";
import { getGlide } from "../../../../lib/glide";
import {
getOrCreateSessionId,
getSessionUserId,
startUserSession,
} from "../../../../lib/session";
let handler: ((request: Request) => Promise<Response>) | null = null;
export function POST(request: Request): Promise<Response> {
if (handler === null) {
handler = createPasskeyRouteHandler({
server: getGlide(),
getSessionId: () => getOrCreateSessionId(),
getUserId: () => getSessionUserId(),
onAuthSuccess: (user) => startUserSession(user.id),
});
}
return handler(request);
}The handler is a Web-standard (request: Request) => Promise<Response> — it mounts
directly as a Next.js App Router POST export. It dispatches to the correct ceremony
handler by reading the last URL segment: register-begin, register-finish,
authenticate-begin, authenticate-finish.
See the Quickstart for the full Next.js wiring including session helpers and the client button.
API
createGlideServer(config)
Creates the four WebAuthn ceremony handlers. Returns an object with:
registerBegin(ctx: BeginContext): Promise<PublicKeyCredentialCreationOptionsJSON>registerFinish(ctx: FinishContext): Promise<GlideAuthResult>authenticateBegin(ctx: BeginContext): Promise<PublicKeyCredentialRequestOptionsJSON>authenticateFinish(ctx: FinishContext): Promise<GlideAuthResult>
GlideServerConfig fields:
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| rpName | string | yes | Human-readable Relying Party name shown in some UIs. |
| rpID | string | yes | Registrable domain, e.g. "localhost" or "example.com". |
| origin | string \| string[] | yes | Exact expected origin(s), e.g. "http://localhost:3000". |
| store | GlideStore | yes | Storage implementation (challenges, credentials, users). |
| challengeTtlMs | number | no | Challenge TTL in ms. Default 120000 (2 min). |
| userVerification | "required" \| "preferred" \| "discouraged" | no | WebAuthn user verification. Default "preferred". |
createPasskeyRouteHandler(config)
Creates a single Web-standard request handler that dispatches all four passkey routes.
Returns (request: Request) => Promise<Response>.
PasskeyRouteHandlerConfig fields:
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| server | ReturnType<typeof createGlideServer> | yes | The Glide server instance. |
| getSessionId | (request: Request) => Promise<string> | yes | Returns the pre-auth session id (from an HttpOnly cookie). |
| getUserId | (request: Request) => Promise<string \| undefined> | no | Returns the authenticated user id for add-a-device flows. Must only return the currently authenticated user. |
| onAuthSuccess | (user: GlideAuthResult["user"], request: Request) => Promise<void> | no | Called after successful authentication to mint your session. |
Response codes: 405 (non-POST), 404 (unknown action), 400 (GlideServerError), 500 (unexpected).
createInMemoryStore()
Creates an in-memory GlideStore for development and testing. Returns a GlideStore
implementing challenges, credentials, and users sub-stores.
Dev only:
createInMemoryStore()loses all data on process restart.Production guard: Under
NODE_ENV=production,createInMemoryStore()throws unlessGLIDE_ALLOW_INMEMORY="1"is set. This prevents accidentally running with an ephemeral store in production.For production, implement a persistent store against the
GlideStoreinterface — see STORE.md for the contract and invariants, andapps/demo/lib/sqlite-store.tsfor a SQLite reference implementation.
assertSecureSecret(envVar, value)
function assertSecureSecret(envVar: string, value: string | undefined): asserts value is stringValidates that a secret environment variable is safe to use. Call this lazily inside
your getSecret() function (at request time, not at module scope) so it does not fire
during next build.
- Throws if
valueisundefined, an empty string, or equals the dev placeholder"dev-only-insecure-secret-change-me". - Warns (does not throw) if
value.length < 32.
Exported types
GlideServerConfig, BeginContext, FinishContext, GlideAuthResult, GlideServerError,
GlideStore, ChallengeStore, CredentialStore, UserStore, StoredCredential,
GlideUserRecord, AuthenticatorTransportFuture, PasskeyRouteHandlerConfig
Security
HARD-01: GLIDE_SESSION_SECRET (required)
The session module must sign user session cookies with a strong secret. Set this in your
.env.local before starting the server:
GLIDE_SESSION_SECRET=$(openssl rand -base64 32)assertSecureSecret("GLIDE_SESSION_SECRET", raw) is called at request time when the
session is first signed or verified. If the value is unset, empty, or equals the dev
placeholder, it throws immediately — the first passkey attempt will fail with an internal
server error. Secrets shorter than 32 characters produce a warning.
See SECURITY.md for the full token-storage and session rules.
HARD-02: In-memory store is dev only
createInMemoryStore() is blocked under NODE_ENV=production (unless
GLIDE_ALLOW_INMEMORY="1" is explicitly set). For production, replace it with a
persistent GlideStore implementation. See STORE.md for the interface
contract and the store skeleton.
getUserId callback security
The optional getUserId(request) callback is the add-a-device seam. It must only
return the id of the currently authenticated user — derived from a tamper-evident session
(HttpOnly cookie or signed token), never from an unsigned query parameter. Returning any
other user's id allows an attacker to attach their passkey to a victim's account.
Links
- Root README — architecture overview
- Quickstart — full Next.js App Router integration walkthrough
- Distribution guide — tarball and pnpm link install details
- STORE.md — GlideStore contract and BYO-store guidance
