@glydi/passkey-server 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +207 -0
- package/dist/index.d.ts +244 -0
- package/dist/index.js +331 -0
- package/dist/index.js.map +1 -0
- package/package.json +36 -0
package/README.md
ADDED
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
# @glydi/passkey-server
|
|
2
|
+
|
|
3
|
+
Lightweight, framework-agnostic WebAuthn challenge + verification handlers for Glide. Built on @simplewebauthn/server.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
Glide is not yet published to npm. Install from a packed tarball or via `pnpm link`.
|
|
8
|
+
|
|
9
|
+
**Tarball (recommended):**
|
|
10
|
+
|
|
11
|
+
```json
|
|
12
|
+
{
|
|
13
|
+
"dependencies": {
|
|
14
|
+
"@glydi/passkey-server": "file:../glide/dist-packs/glydi-passkey-server-0.1.0.tgz",
|
|
15
|
+
"@glydi/passkey-core": "file:../glide/dist-packs/glydi-passkey-core-0.1.0.tgz"
|
|
16
|
+
},
|
|
17
|
+
"pnpm": {
|
|
18
|
+
"overrides": {
|
|
19
|
+
"@glydi/passkey-core": "file:../glide/dist-packs/glydi-passkey-core-0.1.0.tgz"
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
The `pnpm.overrides` entry for `@glydi/passkey-core` prevents pnpm from trying to fetch
|
|
26
|
+
the transitive peer from the npm registry. See [docs/DISTRIBUTION.md](../../docs/DISTRIBUTION.md)
|
|
27
|
+
for full tarball and `pnpm link` instructions.
|
|
28
|
+
|
|
29
|
+
> **Forthcoming:** `npm install @glydi/passkey-server` will be the public form once the
|
|
30
|
+
> package is published. It is not yet available on npm.
|
|
31
|
+
|
|
32
|
+
## Minimal Usage
|
|
33
|
+
|
|
34
|
+
The correct pattern for Next.js App Router is **lazy initialization** — wrapping
|
|
35
|
+
`createGlideServer` and `createPasskeyRouteHandler` inside functions that construct
|
|
36
|
+
on first call rather than at module scope. This is required because `createInMemoryStore()`
|
|
37
|
+
throws under `NODE_ENV=production`, and `next build` evaluates module-level code with
|
|
38
|
+
`NODE_ENV=production` — so eager construction breaks the build.
|
|
39
|
+
|
|
40
|
+
**`lib/glide.ts` — lazy server initializer:**
|
|
41
|
+
|
|
42
|
+
```ts
|
|
43
|
+
import { createGlideServer, createInMemoryStore } from "@glydi/passkey-server";
|
|
44
|
+
|
|
45
|
+
const RP_ID = process.env.GLIDE_RP_ID ?? "localhost";
|
|
46
|
+
const ORIGIN = process.env.GLIDE_ORIGIN ?? "http://localhost:3000";
|
|
47
|
+
|
|
48
|
+
let _glide: ReturnType<typeof createGlideServer> | null = null;
|
|
49
|
+
|
|
50
|
+
export function getGlide(): ReturnType<typeof createGlideServer> {
|
|
51
|
+
if (_glide === null) {
|
|
52
|
+
_glide = createGlideServer({
|
|
53
|
+
rpName: "My App",
|
|
54
|
+
rpID: RP_ID,
|
|
55
|
+
origin: ORIGIN,
|
|
56
|
+
store: createInMemoryStore(),
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
return _glide;
|
|
60
|
+
}
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
**`app/api/passkey/[action]/route.ts` — lazy route handler:**
|
|
64
|
+
|
|
65
|
+
```ts
|
|
66
|
+
import { createPasskeyRouteHandler } from "@glydi/passkey-server";
|
|
67
|
+
import { getGlide } from "../../../../lib/glide";
|
|
68
|
+
import {
|
|
69
|
+
getOrCreateSessionId,
|
|
70
|
+
getSessionUserId,
|
|
71
|
+
startUserSession,
|
|
72
|
+
} from "../../../../lib/session";
|
|
73
|
+
|
|
74
|
+
let handler: ((request: Request) => Promise<Response>) | null = null;
|
|
75
|
+
|
|
76
|
+
export function POST(request: Request): Promise<Response> {
|
|
77
|
+
if (handler === null) {
|
|
78
|
+
handler = createPasskeyRouteHandler({
|
|
79
|
+
server: getGlide(),
|
|
80
|
+
getSessionId: () => getOrCreateSessionId(),
|
|
81
|
+
getUserId: () => getSessionUserId(),
|
|
82
|
+
onAuthSuccess: (user) => startUserSession(user.id),
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
return handler(request);
|
|
86
|
+
}
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
The handler is a Web-standard `(request: Request) => Promise<Response>` — it mounts
|
|
90
|
+
directly as a Next.js App Router `POST` export. It dispatches to the correct ceremony
|
|
91
|
+
handler by reading the last URL segment: `register-begin`, `register-finish`,
|
|
92
|
+
`authenticate-begin`, `authenticate-finish`.
|
|
93
|
+
|
|
94
|
+
See the [Quickstart](../../docs/QUICKSTART.md) for the full Next.js wiring including
|
|
95
|
+
session helpers and the client button.
|
|
96
|
+
|
|
97
|
+
## API
|
|
98
|
+
|
|
99
|
+
### `createGlideServer(config)`
|
|
100
|
+
|
|
101
|
+
Creates the four WebAuthn ceremony handlers. Returns an object with:
|
|
102
|
+
|
|
103
|
+
- `registerBegin(ctx: BeginContext): Promise<PublicKeyCredentialCreationOptionsJSON>`
|
|
104
|
+
- `registerFinish(ctx: FinishContext): Promise<GlideAuthResult>`
|
|
105
|
+
- `authenticateBegin(ctx: BeginContext): Promise<PublicKeyCredentialRequestOptionsJSON>`
|
|
106
|
+
- `authenticateFinish(ctx: FinishContext): Promise<GlideAuthResult>`
|
|
107
|
+
|
|
108
|
+
**`GlideServerConfig` fields:**
|
|
109
|
+
|
|
110
|
+
| Field | Type | Required | Description |
|
|
111
|
+
|-------|------|----------|-------------|
|
|
112
|
+
| `rpName` | `string` | yes | Human-readable Relying Party name shown in some UIs. |
|
|
113
|
+
| `rpID` | `string` | yes | Registrable domain, e.g. `"localhost"` or `"example.com"`. |
|
|
114
|
+
| `origin` | `string \| string[]` | yes | Exact expected origin(s), e.g. `"http://localhost:3000"`. |
|
|
115
|
+
| `store` | `GlideStore` | yes | Storage implementation (challenges, credentials, users). |
|
|
116
|
+
| `challengeTtlMs` | `number` | no | Challenge TTL in ms. Default `120000` (2 min). |
|
|
117
|
+
| `userVerification` | `"required" \| "preferred" \| "discouraged"` | no | WebAuthn user verification. Default `"preferred"`. |
|
|
118
|
+
|
|
119
|
+
### `createPasskeyRouteHandler(config)`
|
|
120
|
+
|
|
121
|
+
Creates a single Web-standard request handler that dispatches all four passkey routes.
|
|
122
|
+
Returns `(request: Request) => Promise<Response>`.
|
|
123
|
+
|
|
124
|
+
**`PasskeyRouteHandlerConfig` fields:**
|
|
125
|
+
|
|
126
|
+
| Field | Type | Required | Description |
|
|
127
|
+
|-------|------|----------|-------------|
|
|
128
|
+
| `server` | `ReturnType<typeof createGlideServer>` | yes | The Glide server instance. |
|
|
129
|
+
| `getSessionId` | `(request: Request) => Promise<string>` | yes | Returns the pre-auth session id (from an HttpOnly cookie). |
|
|
130
|
+
| `getUserId` | `(request: Request) => Promise<string \| undefined>` | no | Returns the authenticated user id for add-a-device flows. Must only return the currently authenticated user. |
|
|
131
|
+
| `onAuthSuccess` | `(user: GlideAuthResult["user"], request: Request) => Promise<void>` | no | Called after successful authentication to mint your session. |
|
|
132
|
+
|
|
133
|
+
**Response codes:** `405` (non-POST), `404` (unknown action), `400` (`GlideServerError`), `500` (unexpected).
|
|
134
|
+
|
|
135
|
+
### `createInMemoryStore()`
|
|
136
|
+
|
|
137
|
+
Creates an in-memory `GlideStore` for development and testing. Returns a `GlideStore`
|
|
138
|
+
implementing `challenges`, `credentials`, and `users` sub-stores.
|
|
139
|
+
|
|
140
|
+
> **Dev only:** `createInMemoryStore()` loses all data on process restart.
|
|
141
|
+
>
|
|
142
|
+
> **Production guard:** Under `NODE_ENV=production`, `createInMemoryStore()` throws unless
|
|
143
|
+
> `GLIDE_ALLOW_INMEMORY="1"` is set. This prevents accidentally running with an ephemeral
|
|
144
|
+
> store in production.
|
|
145
|
+
>
|
|
146
|
+
> For production, implement a persistent store against the `GlideStore` interface — see
|
|
147
|
+
> [STORE.md](./STORE.md) for the contract and invariants, and `apps/demo/lib/sqlite-store.ts`
|
|
148
|
+
> for a SQLite reference implementation.
|
|
149
|
+
|
|
150
|
+
### `assertSecureSecret(envVar, value)`
|
|
151
|
+
|
|
152
|
+
```ts
|
|
153
|
+
function assertSecureSecret(envVar: string, value: string | undefined): asserts value is string
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
Validates that a secret environment variable is safe to use. Call this lazily inside
|
|
157
|
+
your `getSecret()` function (at request time, not at module scope) so it does not fire
|
|
158
|
+
during `next build`.
|
|
159
|
+
|
|
160
|
+
- **Throws** if `value` is `undefined`, an empty string, or equals the dev placeholder
|
|
161
|
+
`"dev-only-insecure-secret-change-me"`.
|
|
162
|
+
- **Warns** (does not throw) if `value.length < 32`.
|
|
163
|
+
|
|
164
|
+
### Exported types
|
|
165
|
+
|
|
166
|
+
`GlideServerConfig`, `BeginContext`, `FinishContext`, `GlideAuthResult`, `GlideServerError`,
|
|
167
|
+
`GlideStore`, `ChallengeStore`, `CredentialStore`, `UserStore`, `StoredCredential`,
|
|
168
|
+
`GlideUserRecord`, `AuthenticatorTransportFuture`, `PasskeyRouteHandlerConfig`
|
|
169
|
+
|
|
170
|
+
## Security
|
|
171
|
+
|
|
172
|
+
### HARD-01: `GLIDE_SESSION_SECRET` (required)
|
|
173
|
+
|
|
174
|
+
The session module must sign user session cookies with a strong secret. Set this in your
|
|
175
|
+
`.env.local` before starting the server:
|
|
176
|
+
|
|
177
|
+
```bash
|
|
178
|
+
GLIDE_SESSION_SECRET=$(openssl rand -base64 32)
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
`assertSecureSecret("GLIDE_SESSION_SECRET", raw)` is called at request time when the
|
|
182
|
+
session is first signed or verified. If the value is unset, empty, or equals the dev
|
|
183
|
+
placeholder, it throws immediately — the first passkey attempt will fail with an internal
|
|
184
|
+
server error. Secrets shorter than 32 characters produce a warning.
|
|
185
|
+
|
|
186
|
+
See [SECURITY.md](../../SECURITY.md) for the full token-storage and session rules.
|
|
187
|
+
|
|
188
|
+
### HARD-02: In-memory store is dev only
|
|
189
|
+
|
|
190
|
+
`createInMemoryStore()` is blocked under `NODE_ENV=production` (unless
|
|
191
|
+
`GLIDE_ALLOW_INMEMORY="1"` is explicitly set). For production, replace it with a
|
|
192
|
+
persistent `GlideStore` implementation. See [STORE.md](./STORE.md) for the interface
|
|
193
|
+
contract and the store skeleton.
|
|
194
|
+
|
|
195
|
+
### `getUserId` callback security
|
|
196
|
+
|
|
197
|
+
The optional `getUserId(request)` callback is the add-a-device seam. It **must** only
|
|
198
|
+
return the id of the currently authenticated user — derived from a tamper-evident session
|
|
199
|
+
(HttpOnly cookie or signed token), never from an unsigned query parameter. Returning any
|
|
200
|
+
other user's id allows an attacker to attach their passkey to a victim's account.
|
|
201
|
+
|
|
202
|
+
## Links
|
|
203
|
+
|
|
204
|
+
- [Root README](../../README.md) — architecture overview
|
|
205
|
+
- [Quickstart](../../docs/QUICKSTART.md) — full Next.js App Router integration walkthrough
|
|
206
|
+
- [Distribution guide](../../docs/DISTRIBUTION.md) — tarball and pnpm link install details
|
|
207
|
+
- [STORE.md](./STORE.md) — GlideStore contract and BYO-store guidance
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
import * as _simplewebauthn_server from '@simplewebauthn/server';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Storage interfaces the Glide server needs, plus a dev-only in-memory impl.
|
|
5
|
+
*
|
|
6
|
+
* In production, back these with your own DB/Redis. The point of the interface
|
|
7
|
+
* is that Glide never owns your user data — "bring your own backend".
|
|
8
|
+
*/
|
|
9
|
+
interface StoredCredential {
|
|
10
|
+
/** Owning user's stable id. */
|
|
11
|
+
userId: string;
|
|
12
|
+
/** base64url credential id (PublicKeyCredential.id). */
|
|
13
|
+
credentialId: string;
|
|
14
|
+
/** COSE public key bytes. */
|
|
15
|
+
publicKey: Uint8Array;
|
|
16
|
+
/** Signature counter; updated on every successful authentication. */
|
|
17
|
+
counter: number;
|
|
18
|
+
transports?: AuthenticatorTransportFuture[] | undefined;
|
|
19
|
+
/** "singleDevice" | "multiDevice" — multiDevice = synced passkey. */
|
|
20
|
+
deviceType?: string | undefined;
|
|
21
|
+
backedUp?: boolean | undefined;
|
|
22
|
+
/** User-facing label, e.g. "MacBook Touch ID". Optional. */
|
|
23
|
+
name?: string | undefined;
|
|
24
|
+
/** Unix ms when the credential was registered. */
|
|
25
|
+
createdAt?: number | undefined;
|
|
26
|
+
}
|
|
27
|
+
/** Minimal transport list mirror to avoid importing DOM lib on the server. */
|
|
28
|
+
type AuthenticatorTransportFuture = "ble" | "cable" | "hybrid" | "internal" | "nfc" | "smart-card" | "usb";
|
|
29
|
+
interface GlideUserRecord {
|
|
30
|
+
/** Opaque, stable, app-owned user id (NOT the email). */
|
|
31
|
+
id: string;
|
|
32
|
+
name?: string;
|
|
33
|
+
email?: string;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Per-session challenge store. The challenge MUST be:
|
|
37
|
+
* - random (handled by @simplewebauthn), single-use, short-lived,
|
|
38
|
+
* - bound to THIS session id (never accept another session's challenge).
|
|
39
|
+
* Back this with your server-side session (Redis) or a sealed HttpOnly cookie.
|
|
40
|
+
*/
|
|
41
|
+
interface ChallengeStore {
|
|
42
|
+
get(sessionId: string): Promise<string | undefined>;
|
|
43
|
+
set(sessionId: string, challenge: string, ttlMs: number): Promise<void>;
|
|
44
|
+
/** Clear on success AND failure to enforce single-use. */
|
|
45
|
+
clear(sessionId: string): Promise<void>;
|
|
46
|
+
}
|
|
47
|
+
interface CredentialStore {
|
|
48
|
+
findById(credentialId: string): Promise<StoredCredential | undefined>;
|
|
49
|
+
listByUser(userId: string): Promise<StoredCredential[]>;
|
|
50
|
+
save(userId: string, cred: Omit<StoredCredential, "userId">): Promise<void>;
|
|
51
|
+
updateCounter(credentialId: string, counter: number): Promise<void>;
|
|
52
|
+
/** Remove a credential (account management — "remove this passkey"). */
|
|
53
|
+
delete(credentialId: string): Promise<void>;
|
|
54
|
+
/** Set a user-facing label. */
|
|
55
|
+
rename(credentialId: string, name: string): Promise<void>;
|
|
56
|
+
}
|
|
57
|
+
interface UserStore {
|
|
58
|
+
findByUsername(username: string): Promise<GlideUserRecord | undefined>;
|
|
59
|
+
findById(userId: string): Promise<GlideUserRecord | undefined>;
|
|
60
|
+
/** Create-or-get; returns the stable user record. */
|
|
61
|
+
upsert(username: string | undefined): Promise<GlideUserRecord>;
|
|
62
|
+
}
|
|
63
|
+
interface GlideStore {
|
|
64
|
+
challenges: ChallengeStore;
|
|
65
|
+
credentials: CredentialStore;
|
|
66
|
+
users: UserStore;
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* In-memory store for local development and tests ONLY. Not for production:
|
|
70
|
+
* it's per-process, unbounded, and lost on restart.
|
|
71
|
+
*/
|
|
72
|
+
declare function createInMemoryStore(): GlideStore;
|
|
73
|
+
|
|
74
|
+
interface GlideServerConfig {
|
|
75
|
+
/** Human-readable RP name shown in some authenticator UIs. */
|
|
76
|
+
rpName: string;
|
|
77
|
+
/** Registrable domain, e.g. "example.com" (a suffix of every origin). */
|
|
78
|
+
rpID: string;
|
|
79
|
+
/** Exact expected origin(s), e.g. "https://app.example.com". */
|
|
80
|
+
origin: string | string[];
|
|
81
|
+
store: GlideStore;
|
|
82
|
+
/** Challenge lifetime; should match the client `timeout`. Default 120s. */
|
|
83
|
+
challengeTtlMs?: number;
|
|
84
|
+
/** "required" forces biometric/PIN; "preferred" is the smooth default. */
|
|
85
|
+
userVerification?: "required" | "preferred" | "discouraged";
|
|
86
|
+
}
|
|
87
|
+
interface BeginContext {
|
|
88
|
+
/** Stable id for the current (pre-auth) browser session. */
|
|
89
|
+
sessionId: string;
|
|
90
|
+
/** Optional username hint from the client (used for known-user flows). */
|
|
91
|
+
username?: string;
|
|
92
|
+
/**
|
|
93
|
+
* Register a new credential for an EXISTING user (add-a-device). When set,
|
|
94
|
+
* the ceremony attaches to this user id instead of upserting by username —
|
|
95
|
+
* the correct path for "add another passkey" while already signed in.
|
|
96
|
+
* (registerBegin only.)
|
|
97
|
+
*/
|
|
98
|
+
userId?: string;
|
|
99
|
+
}
|
|
100
|
+
interface FinishContext {
|
|
101
|
+
sessionId: string;
|
|
102
|
+
/** The raw JSON the browser produced (registration or assertion response). */
|
|
103
|
+
body: any;
|
|
104
|
+
}
|
|
105
|
+
interface GlideAuthResult {
|
|
106
|
+
user: {
|
|
107
|
+
id: string;
|
|
108
|
+
name?: string;
|
|
109
|
+
email?: string;
|
|
110
|
+
};
|
|
111
|
+
/**
|
|
112
|
+
* The SDK does NOT mint tokens for you — issue your own session here
|
|
113
|
+
* (set an HttpOnly cookie in your transport layer). Returned for convenience
|
|
114
|
+
* if you choose to hand a short-lived access token to the client (memory only).
|
|
115
|
+
*/
|
|
116
|
+
accessToken?: string;
|
|
117
|
+
}
|
|
118
|
+
declare function createGlideServer(config: GlideServerConfig): {
|
|
119
|
+
/** POST /register/begin — returns PublicKeyCredentialCreationOptionsJSON. */
|
|
120
|
+
registerBegin(ctx: BeginContext): Promise<_simplewebauthn_server.PublicKeyCredentialCreationOptionsJSON>;
|
|
121
|
+
/** POST /register/finish — verifies attestation, persists the credential. */
|
|
122
|
+
registerFinish(ctx: FinishContext): Promise<GlideAuthResult>;
|
|
123
|
+
/** POST /authenticate/begin — returns PublicKeyCredentialRequestOptionsJSON. */
|
|
124
|
+
authenticateBegin(ctx: BeginContext): Promise<_simplewebauthn_server.PublicKeyCredentialRequestOptionsJSON>;
|
|
125
|
+
/** POST /authenticate/finish — verifies the assertion, bumps the counter. */
|
|
126
|
+
authenticateFinish(ctx: FinishContext): Promise<GlideAuthResult>;
|
|
127
|
+
};
|
|
128
|
+
declare class GlideServerError extends Error {
|
|
129
|
+
code: string;
|
|
130
|
+
constructor(code: string, message: string);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Assert that a named secret env var is set, non-empty, and not the known
|
|
135
|
+
* dev placeholder. Declared with `asserts value is string` so TypeScript
|
|
136
|
+
* narrows the type at the call site — no non-null assertion needed.
|
|
137
|
+
*
|
|
138
|
+
* Call before the secret is first used to sign/verify so misconfigured
|
|
139
|
+
* deployments fail fast rather than silently signing sessions with an insecure
|
|
140
|
+
* key. In Next.js App Router, call it lazily (e.g. inside the function that
|
|
141
|
+
* reads the secret), NOT at module top-level — a module-load throw fires during
|
|
142
|
+
* `next build` page-data collection and breaks the build.
|
|
143
|
+
*
|
|
144
|
+
* Warns (does not throw) when the secret is shorter than 32 chars.
|
|
145
|
+
*
|
|
146
|
+
* @example
|
|
147
|
+
* const SECRET = process.env.GLIDE_SESSION_SECRET;
|
|
148
|
+
* assertSecureSecret("GLIDE_SESSION_SECRET", SECRET);
|
|
149
|
+
* // TypeScript now narrows SECRET to `string`
|
|
150
|
+
*
|
|
151
|
+
* @param envVar - The name of the environment variable (used in error/warn messages).
|
|
152
|
+
* @param value - The raw value to assert against.
|
|
153
|
+
*/
|
|
154
|
+
declare function assertSecureSecret(envVar: string, value: string | undefined): asserts value is string;
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Framework-neutral route-handler factory for the four Glide WebAuthn ceremonies.
|
|
158
|
+
*
|
|
159
|
+
* Returns a single `(request: Request) => Promise<Response>` handler that
|
|
160
|
+
* integrators can mount directly as a Next.js App Router POST route, or adapt
|
|
161
|
+
* to any framework that accepts Web-standard Request/Response objects.
|
|
162
|
+
*
|
|
163
|
+
* The factory imports nothing from `next/server` — it uses only the Web Fetch
|
|
164
|
+
* API (`Request`, `Response`) so it stays framework-neutral.
|
|
165
|
+
*
|
|
166
|
+
* @example Next.js App Router (apps/demo/app/api/passkey/[action]/route.ts):
|
|
167
|
+
* ```ts
|
|
168
|
+
* import { createPasskeyRouteHandler } from "@glydi/passkey-server";
|
|
169
|
+
* import { glide } from "../../../../lib/glide";
|
|
170
|
+
* import { getOrCreateSessionId, getSessionUserId, startUserSession } from "../../../../lib/session";
|
|
171
|
+
*
|
|
172
|
+
* export const POST = createPasskeyRouteHandler({
|
|
173
|
+
* server: glide,
|
|
174
|
+
* getSessionId: () => getOrCreateSessionId(),
|
|
175
|
+
* getUserId: () => getSessionUserId(),
|
|
176
|
+
* onAuthSuccess: (user) => startUserSession(user.id),
|
|
177
|
+
* });
|
|
178
|
+
* ```
|
|
179
|
+
*/
|
|
180
|
+
|
|
181
|
+
interface PasskeyRouteHandlerConfig {
|
|
182
|
+
/**
|
|
183
|
+
* The GlideServer instance returned by createGlideServer().
|
|
184
|
+
* Create it once at module load and pass it in — do NOT call createGlideServer
|
|
185
|
+
* inside this config.
|
|
186
|
+
*/
|
|
187
|
+
server: ReturnType<typeof createGlideServer>;
|
|
188
|
+
/**
|
|
189
|
+
* Resolve (or create) the pre-auth session id for this request.
|
|
190
|
+
*
|
|
191
|
+
* In Next.js App Router: return `getOrCreateSessionId()` which reads/writes
|
|
192
|
+
* via `cookies()` from `next/headers`. The `request` parameter is provided
|
|
193
|
+
* for framework-neutral cookie reading (Express: `request.headers.get("cookie")`);
|
|
194
|
+
* Next.js apps can safely ignore it.
|
|
195
|
+
*/
|
|
196
|
+
getSessionId: (request: Request) => Promise<string>;
|
|
197
|
+
/**
|
|
198
|
+
* Optional: return the currently signed-in user id for add-a-device flows.
|
|
199
|
+
*
|
|
200
|
+
* When provided and non-undefined, `register-begin` attaches the new credential
|
|
201
|
+
* to this existing user instead of creating a fresh sign-up.
|
|
202
|
+
*
|
|
203
|
+
* SECURITY: This callback MUST only return the authenticated user's id — verified
|
|
204
|
+
* from a tamper-evident session cookie (e.g., HMAC-signed as the demo does with
|
|
205
|
+
* `getSessionUserId`). Returning any arbitrary user id would allow credential
|
|
206
|
+
* hijacking.
|
|
207
|
+
*/
|
|
208
|
+
getUserId?: (request: Request) => Promise<string | undefined>;
|
|
209
|
+
/**
|
|
210
|
+
* Optional: called after `register-finish` and `authenticate-finish` with the
|
|
211
|
+
* authenticated user. Use this to mint your own login session (set an HttpOnly
|
|
212
|
+
* cookie). The factory does not issue tokens — this is your moment to do so.
|
|
213
|
+
*
|
|
214
|
+
* Return value: anything you return is shallow-merged into the JSON response
|
|
215
|
+
* body (on top of the ceremony's `{ user, ... }` result). This is how an auth
|
|
216
|
+
* bridge hands the client a token it must exchange — e.g. the Glide↔Firebase
|
|
217
|
+
* bridge returns `{ accessToken: firebaseCustomToken }`, which surfaces to the
|
|
218
|
+
* client's `onSuccess(result)` as `result.accessToken`. Returning `void` (the
|
|
219
|
+
* common case, when you only set a cookie) leaves the body untouched.
|
|
220
|
+
*
|
|
221
|
+
* Errors thrown here are treated as 500 internal errors (the callback runs inside
|
|
222
|
+
* the try/catch, so a session-minting failure is a request failure).
|
|
223
|
+
*/
|
|
224
|
+
onAuthSuccess?: (user: GlideAuthResult["user"], request: Request) => Promise<void | ({
|
|
225
|
+
accessToken?: string;
|
|
226
|
+
} & Record<string, unknown>)>;
|
|
227
|
+
}
|
|
228
|
+
/**
|
|
229
|
+
* Creates a Web-standard `(request: Request) => Promise<Response>` handler
|
|
230
|
+
* that dispatches to all four Glide WebAuthn ceremonies based on the last path
|
|
231
|
+
* segment of the request URL:
|
|
232
|
+
* - `register-begin`
|
|
233
|
+
* - `register-finish`
|
|
234
|
+
* - `authenticate-begin`
|
|
235
|
+
* - `authenticate-finish`
|
|
236
|
+
*
|
|
237
|
+
* Non-POST requests → 405
|
|
238
|
+
* Unknown actions → 404 `{ error: "unknown_action" }`
|
|
239
|
+
* GlideServerError → 400 `{ error: code, message }`
|
|
240
|
+
* Unexpected errors → 500 `{ error: "internal" }` (logged server-side)
|
|
241
|
+
*/
|
|
242
|
+
declare function createPasskeyRouteHandler(config: PasskeyRouteHandlerConfig): (request: Request) => Promise<Response>;
|
|
243
|
+
|
|
244
|
+
export { type AuthenticatorTransportFuture, type BeginContext, type ChallengeStore, type CredentialStore, type FinishContext, type GlideAuthResult, type GlideServerConfig, GlideServerError, type GlideStore, type GlideUserRecord, type PasskeyRouteHandlerConfig, type StoredCredential, type UserStore, assertSecureSecret, createGlideServer, createInMemoryStore, createPasskeyRouteHandler };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
import { verifyAuthenticationResponse, generateAuthenticationOptions, verifyRegistrationResponse, generateRegistrationOptions } from '@simplewebauthn/server';
|
|
2
|
+
|
|
3
|
+
// src/glide-server.ts
|
|
4
|
+
var enc = new TextEncoder();
|
|
5
|
+
function createGlideServer(config) {
|
|
6
|
+
const ttl = config.challengeTtlMs ?? 12e4;
|
|
7
|
+
const uv = config.userVerification ?? "preferred";
|
|
8
|
+
const expectedOrigin = config.origin;
|
|
9
|
+
return {
|
|
10
|
+
/** POST /register/begin — returns PublicKeyCredentialCreationOptionsJSON. */
|
|
11
|
+
async registerBegin(ctx) {
|
|
12
|
+
const user = ctx.userId ? await config.store.users.findById(ctx.userId) : await config.store.users.upsert(ctx.username);
|
|
13
|
+
if (!user) {
|
|
14
|
+
throw new GlideServerError("unknown_user", "No such user to register for.");
|
|
15
|
+
}
|
|
16
|
+
const existing = await config.store.credentials.listByUser(user.id);
|
|
17
|
+
const options = await generateRegistrationOptions({
|
|
18
|
+
rpName: config.rpName,
|
|
19
|
+
rpID: config.rpID,
|
|
20
|
+
userName: user.name ?? user.id,
|
|
21
|
+
userDisplayName: user.name ?? "New user",
|
|
22
|
+
// Tie the credential to OUR stable user id (opaque handle, not email).
|
|
23
|
+
userID: enc.encode(user.id),
|
|
24
|
+
attestationType: "none",
|
|
25
|
+
authenticatorSelection: {
|
|
26
|
+
residentKey: "required",
|
|
27
|
+
// make it a true discoverable passkey
|
|
28
|
+
userVerification: uv
|
|
29
|
+
},
|
|
30
|
+
// Prevent re-registering an authenticator the user already has.
|
|
31
|
+
excludeCredentials: existing.map((c) => ({
|
|
32
|
+
id: c.credentialId,
|
|
33
|
+
...c.transports ? { transports: c.transports } : {}
|
|
34
|
+
})),
|
|
35
|
+
supportedAlgorithmIDs: [-7, -257]
|
|
36
|
+
// ES256, RS256
|
|
37
|
+
});
|
|
38
|
+
await config.store.challenges.set(ctx.sessionId, options.challenge, ttl);
|
|
39
|
+
await config.store.challenges.set(
|
|
40
|
+
userKey(ctx.sessionId),
|
|
41
|
+
user.id,
|
|
42
|
+
ttl
|
|
43
|
+
);
|
|
44
|
+
return options;
|
|
45
|
+
},
|
|
46
|
+
/** POST /register/finish — verifies attestation, persists the credential. */
|
|
47
|
+
async registerFinish(ctx) {
|
|
48
|
+
const expectedChallenge = await config.store.challenges.get(ctx.sessionId);
|
|
49
|
+
const userId = await config.store.challenges.get(userKey(ctx.sessionId));
|
|
50
|
+
await config.store.challenges.clear(ctx.sessionId);
|
|
51
|
+
await config.store.challenges.clear(userKey(ctx.sessionId));
|
|
52
|
+
if (!expectedChallenge || !userId) {
|
|
53
|
+
throw new GlideServerError("no_challenge", "No active challenge.");
|
|
54
|
+
}
|
|
55
|
+
const verification = await verifyRegistrationResponse({
|
|
56
|
+
response: ctx.body,
|
|
57
|
+
expectedChallenge,
|
|
58
|
+
expectedOrigin,
|
|
59
|
+
expectedRPID: config.rpID,
|
|
60
|
+
requireUserVerification: uv === "required"
|
|
61
|
+
});
|
|
62
|
+
if (!verification.verified || !verification.registrationInfo) {
|
|
63
|
+
throw new GlideServerError("not_verified", "Registration failed verification.");
|
|
64
|
+
}
|
|
65
|
+
const { credential, credentialDeviceType, credentialBackedUp } = verification.registrationInfo;
|
|
66
|
+
await config.store.credentials.save(userId, {
|
|
67
|
+
credentialId: credential.id,
|
|
68
|
+
publicKey: credential.publicKey,
|
|
69
|
+
counter: credential.counter,
|
|
70
|
+
transports: credential.transports,
|
|
71
|
+
deviceType: credentialDeviceType,
|
|
72
|
+
backedUp: credentialBackedUp,
|
|
73
|
+
createdAt: Date.now()
|
|
74
|
+
});
|
|
75
|
+
const user = await config.store.users.findById(userId);
|
|
76
|
+
return { user: user ?? { id: userId } };
|
|
77
|
+
},
|
|
78
|
+
/** POST /authenticate/begin — returns PublicKeyCredentialRequestOptionsJSON. */
|
|
79
|
+
async authenticateBegin(ctx) {
|
|
80
|
+
let allowCredentials;
|
|
81
|
+
if (ctx.username) {
|
|
82
|
+
const user = await config.store.users.findByUsername(ctx.username);
|
|
83
|
+
if (user) {
|
|
84
|
+
const creds = await config.store.credentials.listByUser(user.id);
|
|
85
|
+
allowCredentials = creds.map((c) => ({
|
|
86
|
+
id: c.credentialId,
|
|
87
|
+
...c.transports ? { transports: c.transports } : {}
|
|
88
|
+
}));
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
const options = await generateAuthenticationOptions({
|
|
92
|
+
rpID: config.rpID,
|
|
93
|
+
userVerification: uv,
|
|
94
|
+
...allowCredentials ? { allowCredentials } : {}
|
|
95
|
+
});
|
|
96
|
+
await config.store.challenges.set(ctx.sessionId, options.challenge, ttl);
|
|
97
|
+
return options;
|
|
98
|
+
},
|
|
99
|
+
/** POST /authenticate/finish — verifies the assertion, bumps the counter. */
|
|
100
|
+
async authenticateFinish(ctx) {
|
|
101
|
+
const expectedChallenge = await config.store.challenges.get(ctx.sessionId);
|
|
102
|
+
await config.store.challenges.clear(ctx.sessionId);
|
|
103
|
+
if (!expectedChallenge) {
|
|
104
|
+
throw new GlideServerError("no_challenge", "No active challenge.");
|
|
105
|
+
}
|
|
106
|
+
const dbCred = await config.store.credentials.findById(ctx.body?.id);
|
|
107
|
+
if (!dbCred) {
|
|
108
|
+
throw new GlideServerError("unknown_credential", "Unrecognized passkey.");
|
|
109
|
+
}
|
|
110
|
+
const verification = await verifyAuthenticationResponse({
|
|
111
|
+
response: ctx.body,
|
|
112
|
+
expectedChallenge,
|
|
113
|
+
expectedOrigin,
|
|
114
|
+
expectedRPID: config.rpID,
|
|
115
|
+
requireUserVerification: uv === "required",
|
|
116
|
+
credential: {
|
|
117
|
+
id: dbCred.credentialId,
|
|
118
|
+
publicKey: dbCred.publicKey,
|
|
119
|
+
counter: dbCred.counter,
|
|
120
|
+
...dbCred.transports ? { transports: dbCred.transports } : {}
|
|
121
|
+
}
|
|
122
|
+
});
|
|
123
|
+
if (!verification.verified) {
|
|
124
|
+
throw new GlideServerError("not_verified", "Authentication failed.");
|
|
125
|
+
}
|
|
126
|
+
await config.store.credentials.updateCounter(
|
|
127
|
+
dbCred.credentialId,
|
|
128
|
+
verification.authenticationInfo.newCounter
|
|
129
|
+
);
|
|
130
|
+
const owner = await config.store.users.findById(dbCred.userId);
|
|
131
|
+
return { user: owner ?? { id: dbCred.userId } };
|
|
132
|
+
}
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
var GlideServerError = class extends Error {
|
|
136
|
+
constructor(code, message) {
|
|
137
|
+
super(message);
|
|
138
|
+
this.name = "GlideServerError";
|
|
139
|
+
this.code = code;
|
|
140
|
+
}
|
|
141
|
+
};
|
|
142
|
+
function userKey(sessionId) {
|
|
143
|
+
return `${sessionId}::reg-user`;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// src/store.ts
|
|
147
|
+
function createInMemoryStore() {
|
|
148
|
+
if (process.env.NODE_ENV === "production" && process.env.GLIDE_ALLOW_INMEMORY !== "1") {
|
|
149
|
+
throw new Error(
|
|
150
|
+
"[Glide] createInMemoryStore() is not allowed in production. Use a persistent store (e.g. createSqliteStore()), or set GLIDE_ALLOW_INMEMORY=1 to explicitly opt in (e.g. Vercel demo)."
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
const challenges = /* @__PURE__ */ new Map();
|
|
154
|
+
const credentials = /* @__PURE__ */ new Map();
|
|
155
|
+
const credsByUser = /* @__PURE__ */ new Map();
|
|
156
|
+
const users = /* @__PURE__ */ new Map();
|
|
157
|
+
const usersByName = /* @__PURE__ */ new Map();
|
|
158
|
+
let seq = 0;
|
|
159
|
+
return {
|
|
160
|
+
challenges: {
|
|
161
|
+
async get(sessionId) {
|
|
162
|
+
const entry = challenges.get(sessionId);
|
|
163
|
+
if (!entry)
|
|
164
|
+
return void 0;
|
|
165
|
+
if (entry.expires < dateNow()) {
|
|
166
|
+
challenges.delete(sessionId);
|
|
167
|
+
return void 0;
|
|
168
|
+
}
|
|
169
|
+
return entry.value;
|
|
170
|
+
},
|
|
171
|
+
async set(sessionId, value, ttlMs) {
|
|
172
|
+
challenges.set(sessionId, { value, expires: dateNow() + ttlMs });
|
|
173
|
+
},
|
|
174
|
+
async clear(sessionId) {
|
|
175
|
+
challenges.delete(sessionId);
|
|
176
|
+
}
|
|
177
|
+
},
|
|
178
|
+
credentials: {
|
|
179
|
+
async findById(id) {
|
|
180
|
+
return credentials.get(id);
|
|
181
|
+
},
|
|
182
|
+
async listByUser(userId) {
|
|
183
|
+
const ids = credsByUser.get(userId) ?? /* @__PURE__ */ new Set();
|
|
184
|
+
return [...ids].map((id) => credentials.get(id)).filter(Boolean);
|
|
185
|
+
},
|
|
186
|
+
async save(userId, cred) {
|
|
187
|
+
credentials.set(cred.credentialId, { ...cred, userId });
|
|
188
|
+
const set = credsByUser.get(userId) ?? /* @__PURE__ */ new Set();
|
|
189
|
+
set.add(cred.credentialId);
|
|
190
|
+
credsByUser.set(userId, set);
|
|
191
|
+
},
|
|
192
|
+
async updateCounter(id, counter) {
|
|
193
|
+
const c = credentials.get(id);
|
|
194
|
+
if (c)
|
|
195
|
+
c.counter = counter;
|
|
196
|
+
},
|
|
197
|
+
async delete(id) {
|
|
198
|
+
const c = credentials.get(id);
|
|
199
|
+
if (!c)
|
|
200
|
+
return;
|
|
201
|
+
credentials.delete(id);
|
|
202
|
+
credsByUser.get(c.userId)?.delete(id);
|
|
203
|
+
},
|
|
204
|
+
async rename(id, name) {
|
|
205
|
+
const c = credentials.get(id);
|
|
206
|
+
if (c)
|
|
207
|
+
c.name = name;
|
|
208
|
+
}
|
|
209
|
+
},
|
|
210
|
+
users: {
|
|
211
|
+
async findByUsername(username) {
|
|
212
|
+
const id = usersByName.get(username);
|
|
213
|
+
return id ? users.get(id) : void 0;
|
|
214
|
+
},
|
|
215
|
+
async findById(id) {
|
|
216
|
+
return users.get(id);
|
|
217
|
+
},
|
|
218
|
+
async upsert(username) {
|
|
219
|
+
if (username) {
|
|
220
|
+
const existingId = usersByName.get(username);
|
|
221
|
+
if (existingId)
|
|
222
|
+
return users.get(existingId);
|
|
223
|
+
}
|
|
224
|
+
const id = `usr_${(++seq).toString(36)}_${randomSuffix()}`;
|
|
225
|
+
const record = username ? { id, name: username, email: username } : { id };
|
|
226
|
+
users.set(id, record);
|
|
227
|
+
if (username)
|
|
228
|
+
usersByName.set(username, id);
|
|
229
|
+
return record;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
function dateNow() {
|
|
235
|
+
return Date.now();
|
|
236
|
+
}
|
|
237
|
+
function randomSuffix() {
|
|
238
|
+
return Math.random().toString(36).slice(2, 8);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// src/env-assert.ts
|
|
242
|
+
var DEV_PLACEHOLDER = "dev-only-insecure-secret-change-me";
|
|
243
|
+
function assertSecureSecret(envVar, value) {
|
|
244
|
+
if (!value || value === DEV_PLACEHOLDER) {
|
|
245
|
+
throw new Error(
|
|
246
|
+
`[Glide] ${envVar} is not set or equals the dev placeholder. Generate a real secret: openssl rand -base64 32`
|
|
247
|
+
);
|
|
248
|
+
}
|
|
249
|
+
if (value.length < 32) {
|
|
250
|
+
console.warn(
|
|
251
|
+
`[Glide] ${envVar} is shorter than 32 characters. Consider generating a longer secret for production.`
|
|
252
|
+
);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// src/route-handler.ts
|
|
257
|
+
function jsonResponse(body, status) {
|
|
258
|
+
return new Response(JSON.stringify(body), {
|
|
259
|
+
status,
|
|
260
|
+
headers: { "Content-Type": "application/json" }
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
async function safeJson(request) {
|
|
264
|
+
try {
|
|
265
|
+
return await request.json();
|
|
266
|
+
} catch {
|
|
267
|
+
return {};
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
function lastSegment(url) {
|
|
271
|
+
const { pathname } = new URL(url);
|
|
272
|
+
const parts = pathname.split("/").filter(Boolean);
|
|
273
|
+
return parts[parts.length - 1] ?? "";
|
|
274
|
+
}
|
|
275
|
+
function createPasskeyRouteHandler(config) {
|
|
276
|
+
return async (request) => {
|
|
277
|
+
if (request.method !== "POST") {
|
|
278
|
+
return jsonResponse({ error: "method_not_allowed" }, 405);
|
|
279
|
+
}
|
|
280
|
+
const action = lastSegment(request.url);
|
|
281
|
+
const sessionId = await config.getSessionId(request);
|
|
282
|
+
const body = await safeJson(request);
|
|
283
|
+
try {
|
|
284
|
+
switch (action) {
|
|
285
|
+
case "register-begin": {
|
|
286
|
+
const userId = await config.getUserId?.(request);
|
|
287
|
+
const username = typeof body.username === "string" ? body.username : void 0;
|
|
288
|
+
const result = await config.server.registerBegin({
|
|
289
|
+
sessionId,
|
|
290
|
+
...username !== void 0 ? { username } : {},
|
|
291
|
+
...userId ? { userId } : {}
|
|
292
|
+
});
|
|
293
|
+
return jsonResponse(result, 200);
|
|
294
|
+
}
|
|
295
|
+
case "register-finish": {
|
|
296
|
+
const result = await config.server.registerFinish({ sessionId, body });
|
|
297
|
+
const extra = await config.onAuthSuccess?.(result.user, request);
|
|
298
|
+
return jsonResponse({ ...result, ...extra ?? {} }, 200);
|
|
299
|
+
}
|
|
300
|
+
case "authenticate-begin": {
|
|
301
|
+
const username = typeof body.username === "string" ? body.username : void 0;
|
|
302
|
+
const result = await config.server.authenticateBegin({
|
|
303
|
+
sessionId,
|
|
304
|
+
...username !== void 0 ? { username } : {}
|
|
305
|
+
});
|
|
306
|
+
return jsonResponse(result, 200);
|
|
307
|
+
}
|
|
308
|
+
case "authenticate-finish": {
|
|
309
|
+
const result = await config.server.authenticateFinish({
|
|
310
|
+
sessionId,
|
|
311
|
+
body
|
|
312
|
+
});
|
|
313
|
+
const extra = await config.onAuthSuccess?.(result.user, request);
|
|
314
|
+
return jsonResponse({ ...result, ...extra ?? {} }, 200);
|
|
315
|
+
}
|
|
316
|
+
default:
|
|
317
|
+
return jsonResponse({ error: "unknown_action" }, 404);
|
|
318
|
+
}
|
|
319
|
+
} catch (err) {
|
|
320
|
+
if (err instanceof GlideServerError) {
|
|
321
|
+
return jsonResponse({ error: err.code, message: err.message }, 400);
|
|
322
|
+
}
|
|
323
|
+
console.error("[glide] unexpected error", err);
|
|
324
|
+
return jsonResponse({ error: "internal" }, 500);
|
|
325
|
+
}
|
|
326
|
+
};
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
export { GlideServerError, assertSecureSecret, createGlideServer, createInMemoryStore, createPasskeyRouteHandler };
|
|
330
|
+
//# sourceMappingURL=out.js.map
|
|
331
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/glide-server.ts","../src/store.ts","../src/env-assert.ts","../src/route-handler.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Framework-agnostic WebAuthn handlers. Transport-neutral: each handler takes a\n * plain context (the caller's session id + parsed JSON body) and returns a plain\n * object to serialize. Wire them into Next route handlers, Express, Hono, etc.\n *\n * All cryptographic verification is delegated to @simplewebauthn/server (v13) —\n * we never hand-roll CBOR/COSE/attestation parsing.\n */\n\nimport {\n generateRegistrationOptions,\n verifyRegistrationResponse,\n generateAuthenticationOptions,\n verifyAuthenticationResponse,\n} from \"@simplewebauthn/server\";\nimport type { GlideStore } from \"./store.js\";\n\nexport interface GlideServerConfig {\n /** Human-readable RP name shown in some authenticator UIs. */\n rpName: string;\n /** Registrable domain, e.g. \"example.com\" (a suffix of every origin). */\n rpID: string;\n /** Exact expected origin(s), e.g. \"https://app.example.com\". */\n origin: string | string[];\n store: GlideStore;\n /** Challenge lifetime; should match the client `timeout`. Default 120s. */\n challengeTtlMs?: number;\n /** \"required\" forces biometric/PIN; \"preferred\" is the smooth default. */\n userVerification?: \"required\" | \"preferred\" | \"discouraged\";\n}\n\nexport interface BeginContext {\n /** Stable id for the current (pre-auth) browser session. */\n sessionId: string;\n /** Optional username hint from the client (used for known-user flows). */\n username?: string;\n /**\n * Register a new credential for an EXISTING user (add-a-device). When set,\n * the ceremony attaches to this user id instead of upserting by username —\n * the correct path for \"add another passkey\" while already signed in.\n * (registerBegin only.)\n */\n userId?: string;\n}\n\nexport interface FinishContext {\n sessionId: string;\n /** The raw JSON the browser produced (registration or assertion response). */\n body: any;\n}\n\nexport interface GlideAuthResult {\n user: { id: string; name?: string; email?: string };\n /**\n * The SDK does NOT mint tokens for you — issue your own session here\n * (set an HttpOnly cookie in your transport layer). Returned for convenience\n * if you choose to hand a short-lived access token to the client (memory only).\n */\n accessToken?: string;\n}\n\nconst enc = new TextEncoder();\n\nexport function createGlideServer(config: GlideServerConfig) {\n const ttl = config.challengeTtlMs ?? 120_000;\n const uv = config.userVerification ?? \"preferred\";\n const expectedOrigin = config.origin;\n\n return {\n /** POST /register/begin — returns PublicKeyCredentialCreationOptionsJSON. */\n async registerBegin(ctx: BeginContext) {\n // Add-a-device: attach to the existing signed-in user when a userId is\n // given; otherwise upsert by username (new sign-up).\n const user = ctx.userId\n ? await config.store.users.findById(ctx.userId)\n : await config.store.users.upsert(ctx.username);\n if (!user) {\n throw new GlideServerError(\"unknown_user\", \"No such user to register for.\");\n }\n const existing = await config.store.credentials.listByUser(user.id);\n\n const options = await generateRegistrationOptions({\n rpName: config.rpName,\n rpID: config.rpID,\n userName: user.name ?? user.id,\n userDisplayName: user.name ?? \"New user\",\n // Tie the credential to OUR stable user id (opaque handle, not email).\n userID: enc.encode(user.id),\n attestationType: \"none\",\n authenticatorSelection: {\n residentKey: \"required\", // make it a true discoverable passkey\n userVerification: uv,\n },\n // Prevent re-registering an authenticator the user already has.\n excludeCredentials: existing.map((c) => ({\n id: c.credentialId,\n ...(c.transports ? { transports: c.transports } : {}),\n })),\n supportedAlgorithmIDs: [-7, -257], // ES256, RS256\n });\n\n await config.store.challenges.set(ctx.sessionId, options.challenge, ttl);\n // Stash the user id alongside the session so finish() knows who registered.\n await config.store.challenges.set(\n userKey(ctx.sessionId),\n user.id,\n ttl,\n );\n return options;\n },\n\n /** POST /register/finish — verifies attestation, persists the credential. */\n async registerFinish(ctx: FinishContext): Promise<GlideAuthResult> {\n const expectedChallenge = await config.store.challenges.get(ctx.sessionId);\n const userId = await config.store.challenges.get(userKey(ctx.sessionId));\n // Single-use: clear immediately, regardless of outcome below.\n await config.store.challenges.clear(ctx.sessionId);\n await config.store.challenges.clear(userKey(ctx.sessionId));\n if (!expectedChallenge || !userId) {\n throw new GlideServerError(\"no_challenge\", \"No active challenge.\");\n }\n\n const verification = await verifyRegistrationResponse({\n response: ctx.body,\n expectedChallenge,\n expectedOrigin,\n expectedRPID: config.rpID,\n requireUserVerification: uv === \"required\",\n });\n if (!verification.verified || !verification.registrationInfo) {\n throw new GlideServerError(\"not_verified\", \"Registration failed verification.\");\n }\n\n const { credential, credentialDeviceType, credentialBackedUp } =\n verification.registrationInfo;\n await config.store.credentials.save(userId, {\n credentialId: credential.id,\n publicKey: credential.publicKey,\n counter: credential.counter,\n transports: credential.transports,\n deviceType: credentialDeviceType,\n backedUp: credentialBackedUp,\n createdAt: Date.now(),\n });\n\n const user = await config.store.users.findById(userId);\n return { user: user ?? { id: userId } };\n },\n\n /** POST /authenticate/begin — returns PublicKeyCredentialRequestOptionsJSON. */\n async authenticateBegin(ctx: BeginContext) {\n // Usernameless/discoverable by default: empty allowCredentials.\n let allowCredentials: { id: string; transports?: any[] }[] | undefined;\n if (ctx.username) {\n const user = await config.store.users.findByUsername(ctx.username);\n if (user) {\n const creds = await config.store.credentials.listByUser(user.id);\n allowCredentials = creds.map((c) => ({\n id: c.credentialId,\n ...(c.transports ? { transports: c.transports } : {}),\n }));\n }\n }\n\n const options = await generateAuthenticationOptions({\n rpID: config.rpID,\n userVerification: uv,\n ...(allowCredentials ? { allowCredentials } : {}),\n });\n\n await config.store.challenges.set(ctx.sessionId, options.challenge, ttl);\n return options;\n },\n\n /** POST /authenticate/finish — verifies the assertion, bumps the counter. */\n async authenticateFinish(ctx: FinishContext): Promise<GlideAuthResult> {\n const expectedChallenge = await config.store.challenges.get(ctx.sessionId);\n await config.store.challenges.clear(ctx.sessionId); // single-use\n if (!expectedChallenge) {\n throw new GlideServerError(\"no_challenge\", \"No active challenge.\");\n }\n\n const dbCred = await config.store.credentials.findById(ctx.body?.id);\n if (!dbCred) {\n throw new GlideServerError(\"unknown_credential\", \"Unrecognized passkey.\");\n }\n\n const verification = await verifyAuthenticationResponse({\n response: ctx.body,\n expectedChallenge,\n expectedOrigin,\n expectedRPID: config.rpID,\n requireUserVerification: uv === \"required\",\n credential: {\n id: dbCred.credentialId,\n publicKey: dbCred.publicKey,\n counter: dbCred.counter,\n ...(dbCred.transports ? { transports: dbCred.transports } : {}),\n },\n });\n if (!verification.verified) {\n throw new GlideServerError(\"not_verified\", \"Authentication failed.\");\n }\n\n // Replay protection: persist the new signature counter.\n await config.store.credentials.updateCounter(\n dbCred.credentialId,\n verification.authenticationInfo.newCounter,\n );\n\n const owner = await config.store.users.findById(dbCred.userId);\n return { user: owner ?? { id: dbCred.userId } };\n },\n };\n}\n\nexport class GlideServerError extends Error {\n code: string;\n constructor(code: string, message: string) {\n super(message);\n this.name = \"GlideServerError\";\n this.code = code;\n }\n}\n\nfunction userKey(sessionId: string): string {\n return `${sessionId}::reg-user`;\n}\n","/**\n * Storage interfaces the Glide server needs, plus a dev-only in-memory impl.\n *\n * In production, back these with your own DB/Redis. The point of the interface\n * is that Glide never owns your user data — \"bring your own backend\".\n */\n\nexport interface StoredCredential {\n /** Owning user's stable id. */\n userId: string;\n /** base64url credential id (PublicKeyCredential.id). */\n credentialId: string;\n /** COSE public key bytes. */\n publicKey: Uint8Array;\n /** Signature counter; updated on every successful authentication. */\n counter: number;\n transports?: AuthenticatorTransportFuture[] | undefined;\n /** \"singleDevice\" | \"multiDevice\" — multiDevice = synced passkey. */\n deviceType?: string | undefined;\n backedUp?: boolean | undefined;\n /** User-facing label, e.g. \"MacBook Touch ID\". Optional. */\n name?: string | undefined;\n /** Unix ms when the credential was registered. */\n createdAt?: number | undefined;\n}\n\n/** Minimal transport list mirror to avoid importing DOM lib on the server. */\nexport type AuthenticatorTransportFuture =\n | \"ble\"\n | \"cable\"\n | \"hybrid\"\n | \"internal\"\n | \"nfc\"\n | \"smart-card\"\n | \"usb\";\n\nexport interface GlideUserRecord {\n /** Opaque, stable, app-owned user id (NOT the email). */\n id: string;\n name?: string;\n email?: string;\n}\n\n/**\n * Per-session challenge store. The challenge MUST be:\n * - random (handled by @simplewebauthn), single-use, short-lived,\n * - bound to THIS session id (never accept another session's challenge).\n * Back this with your server-side session (Redis) or a sealed HttpOnly cookie.\n */\nexport interface ChallengeStore {\n get(sessionId: string): Promise<string | undefined>;\n set(sessionId: string, challenge: string, ttlMs: number): Promise<void>;\n /** Clear on success AND failure to enforce single-use. */\n clear(sessionId: string): Promise<void>;\n}\n\nexport interface CredentialStore {\n findById(credentialId: string): Promise<StoredCredential | undefined>;\n listByUser(userId: string): Promise<StoredCredential[]>;\n save(userId: string, cred: Omit<StoredCredential, \"userId\">): Promise<void>;\n updateCounter(credentialId: string, counter: number): Promise<void>;\n /** Remove a credential (account management — \"remove this passkey\"). */\n delete(credentialId: string): Promise<void>;\n /** Set a user-facing label. */\n rename(credentialId: string, name: string): Promise<void>;\n}\n\nexport interface UserStore {\n findByUsername(username: string): Promise<GlideUserRecord | undefined>;\n findById(userId: string): Promise<GlideUserRecord | undefined>;\n /** Create-or-get; returns the stable user record. */\n upsert(username: string | undefined): Promise<GlideUserRecord>;\n}\n\nexport interface GlideStore {\n challenges: ChallengeStore;\n credentials: CredentialStore;\n users: UserStore;\n}\n\n/* ------------------------------------------------------------------ dev impl */\n\n/**\n * In-memory store for local development and tests ONLY. Not for production:\n * it's per-process, unbounded, and lost on restart.\n */\nexport function createInMemoryStore(): GlideStore {\n if (\n process.env.NODE_ENV === \"production\" &&\n process.env.GLIDE_ALLOW_INMEMORY !== \"1\"\n ) {\n throw new Error(\n \"[Glide] createInMemoryStore() is not allowed in production. \" +\n \"Use a persistent store (e.g. createSqliteStore()), or set \" +\n \"GLIDE_ALLOW_INMEMORY=1 to explicitly opt in (e.g. Vercel demo).\",\n );\n }\n const challenges = new Map<string, { value: string; expires: number }>();\n const credentials = new Map<string, StoredCredential>();\n const credsByUser = new Map<string, Set<string>>();\n const users = new Map<string, GlideUserRecord>();\n const usersByName = new Map<string, string>();\n let seq = 0;\n\n return {\n challenges: {\n async get(sessionId) {\n const entry = challenges.get(sessionId);\n if (!entry) return undefined;\n if (entry.expires < dateNow()) {\n challenges.delete(sessionId);\n return undefined;\n }\n return entry.value;\n },\n async set(sessionId, value, ttlMs) {\n challenges.set(sessionId, { value, expires: dateNow() + ttlMs });\n },\n async clear(sessionId) {\n challenges.delete(sessionId);\n },\n },\n credentials: {\n async findById(id) {\n return credentials.get(id);\n },\n async listByUser(userId) {\n const ids = credsByUser.get(userId) ?? new Set();\n return [...ids].map((id) => credentials.get(id)!).filter(Boolean);\n },\n async save(userId, cred) {\n credentials.set(cred.credentialId, { ...cred, userId });\n const set = credsByUser.get(userId) ?? new Set<string>();\n set.add(cred.credentialId);\n credsByUser.set(userId, set);\n },\n async updateCounter(id, counter) {\n const c = credentials.get(id);\n if (c) c.counter = counter;\n },\n async delete(id) {\n const c = credentials.get(id);\n if (!c) return;\n credentials.delete(id);\n credsByUser.get(c.userId)?.delete(id);\n },\n async rename(id, name) {\n const c = credentials.get(id);\n if (c) c.name = name;\n },\n },\n users: {\n async findByUsername(username) {\n const id = usersByName.get(username);\n return id ? users.get(id) : undefined;\n },\n async findById(id) {\n return users.get(id);\n },\n async upsert(username) {\n if (username) {\n const existingId = usersByName.get(username);\n if (existingId) return users.get(existingId)!;\n }\n const id = `usr_${(++seq).toString(36)}_${randomSuffix()}`;\n const record: GlideUserRecord = username\n ? { id, name: username, email: username }\n : { id };\n users.set(id, record);\n if (username) usersByName.set(username, id);\n return record;\n },\n },\n };\n}\n\n// Indirections so the in-memory store is easy to make deterministic in tests.\nfunction dateNow(): number {\n return Date.now();\n}\nfunction randomSuffix(): string {\n return Math.random().toString(36).slice(2, 8);\n}\n","const DEV_PLACEHOLDER = \"dev-only-insecure-secret-change-me\";\n\n/**\n * Assert that a named secret env var is set, non-empty, and not the known\n * dev placeholder. Declared with `asserts value is string` so TypeScript\n * narrows the type at the call site — no non-null assertion needed.\n *\n * Call before the secret is first used to sign/verify so misconfigured\n * deployments fail fast rather than silently signing sessions with an insecure\n * key. In Next.js App Router, call it lazily (e.g. inside the function that\n * reads the secret), NOT at module top-level — a module-load throw fires during\n * `next build` page-data collection and breaks the build.\n *\n * Warns (does not throw) when the secret is shorter than 32 chars.\n *\n * @example\n * const SECRET = process.env.GLIDE_SESSION_SECRET;\n * assertSecureSecret(\"GLIDE_SESSION_SECRET\", SECRET);\n * // TypeScript now narrows SECRET to `string`\n *\n * @param envVar - The name of the environment variable (used in error/warn messages).\n * @param value - The raw value to assert against.\n */\nexport function assertSecureSecret(\n envVar: string,\n value: string | undefined,\n): asserts value is string {\n if (!value || value === DEV_PLACEHOLDER) {\n throw new Error(\n `[Glide] ${envVar} is not set or equals the dev placeholder. ` +\n `Generate a real secret: openssl rand -base64 32`,\n );\n }\n if (value.length < 32) {\n console.warn(\n `[Glide] ${envVar} is shorter than 32 characters. ` +\n `Consider generating a longer secret for production.`,\n );\n }\n}\n","/**\n * Framework-neutral route-handler factory for the four Glide WebAuthn ceremonies.\n *\n * Returns a single `(request: Request) => Promise<Response>` handler that\n * integrators can mount directly as a Next.js App Router POST route, or adapt\n * to any framework that accepts Web-standard Request/Response objects.\n *\n * The factory imports nothing from `next/server` — it uses only the Web Fetch\n * API (`Request`, `Response`) so it stays framework-neutral.\n *\n * @example Next.js App Router (apps/demo/app/api/passkey/[action]/route.ts):\n * ```ts\n * import { createPasskeyRouteHandler } from \"@glydi/passkey-server\";\n * import { glide } from \"../../../../lib/glide\";\n * import { getOrCreateSessionId, getSessionUserId, startUserSession } from \"../../../../lib/session\";\n *\n * export const POST = createPasskeyRouteHandler({\n * server: glide,\n * getSessionId: () => getOrCreateSessionId(),\n * getUserId: () => getSessionUserId(),\n * onAuthSuccess: (user) => startUserSession(user.id),\n * });\n * ```\n */\n\nimport type { GlideAuthResult } from \"./glide-server.js\";\nimport { GlideServerError } from \"./glide-server.js\";\nimport type { createGlideServer } from \"./glide-server.js\";\n\n// ---------------------------------------------------------------------------\n// Config interface\n// ---------------------------------------------------------------------------\n\nexport interface PasskeyRouteHandlerConfig {\n /**\n * The GlideServer instance returned by createGlideServer().\n * Create it once at module load and pass it in — do NOT call createGlideServer\n * inside this config.\n */\n server: ReturnType<typeof createGlideServer>;\n\n /**\n * Resolve (or create) the pre-auth session id for this request.\n *\n * In Next.js App Router: return `getOrCreateSessionId()` which reads/writes\n * via `cookies()` from `next/headers`. The `request` parameter is provided\n * for framework-neutral cookie reading (Express: `request.headers.get(\"cookie\")`);\n * Next.js apps can safely ignore it.\n */\n getSessionId: (request: Request) => Promise<string>;\n\n /**\n * Optional: return the currently signed-in user id for add-a-device flows.\n *\n * When provided and non-undefined, `register-begin` attaches the new credential\n * to this existing user instead of creating a fresh sign-up.\n *\n * SECURITY: This callback MUST only return the authenticated user's id — verified\n * from a tamper-evident session cookie (e.g., HMAC-signed as the demo does with\n * `getSessionUserId`). Returning any arbitrary user id would allow credential\n * hijacking.\n */\n getUserId?: (request: Request) => Promise<string | undefined>;\n\n /**\n * Optional: called after `register-finish` and `authenticate-finish` with the\n * authenticated user. Use this to mint your own login session (set an HttpOnly\n * cookie). The factory does not issue tokens — this is your moment to do so.\n *\n * Return value: anything you return is shallow-merged into the JSON response\n * body (on top of the ceremony's `{ user, ... }` result). This is how an auth\n * bridge hands the client a token it must exchange — e.g. the Glide↔Firebase\n * bridge returns `{ accessToken: firebaseCustomToken }`, which surfaces to the\n * client's `onSuccess(result)` as `result.accessToken`. Returning `void` (the\n * common case, when you only set a cookie) leaves the body untouched.\n *\n * Errors thrown here are treated as 500 internal errors (the callback runs inside\n * the try/catch, so a session-minting failure is a request failure).\n */\n onAuthSuccess?: (\n user: GlideAuthResult[\"user\"],\n request: Request,\n ) => Promise<void | ({ accessToken?: string } & Record<string, unknown>)>;\n}\n\n// ---------------------------------------------------------------------------\n// Module-local helpers (no top-level side effects — tree-shaking compatible)\n// ---------------------------------------------------------------------------\n\nfunction jsonResponse(body: unknown, status: number): Response {\n return new Response(JSON.stringify(body), {\n status,\n headers: { \"Content-Type\": \"application/json\" },\n });\n}\n\nasync function safeJson(request: Request): Promise<Record<string, unknown>> {\n try {\n return (await request.json()) as Record<string, unknown>;\n } catch {\n return {};\n }\n}\n\n/**\n * Extracts the last non-empty path segment from an absolute URL.\n * `request.url` in Next.js App Router is always absolute — no base arg needed.\n *\n * Examples:\n * \"http://localhost/api/passkey/register-begin\" → \"register-begin\"\n * \"http://localhost/api/passkey/register-begin/\" → \"register-begin\"\n */\nfunction lastSegment(url: string): string {\n const { pathname } = new URL(url);\n const parts = pathname.split(\"/\").filter(Boolean);\n return parts[parts.length - 1] ?? \"\";\n}\n\n// ---------------------------------------------------------------------------\n// Factory\n// ---------------------------------------------------------------------------\n\n/**\n * Creates a Web-standard `(request: Request) => Promise<Response>` handler\n * that dispatches to all four Glide WebAuthn ceremonies based on the last path\n * segment of the request URL:\n * - `register-begin`\n * - `register-finish`\n * - `authenticate-begin`\n * - `authenticate-finish`\n *\n * Non-POST requests → 405\n * Unknown actions → 404 `{ error: \"unknown_action\" }`\n * GlideServerError → 400 `{ error: code, message }`\n * Unexpected errors → 500 `{ error: \"internal\" }` (logged server-side)\n */\nexport function createPasskeyRouteHandler(\n config: PasskeyRouteHandlerConfig,\n): (request: Request) => Promise<Response> {\n return async (request) => {\n // Method guard — the factory is POST-only; framework routing is not assumed.\n if (request.method !== \"POST\") {\n return jsonResponse({ error: \"method_not_allowed\" }, 405);\n }\n\n const action = lastSegment(request.url);\n const sessionId = await config.getSessionId(request);\n const body = await safeJson(request);\n\n try {\n switch (action) {\n case \"register-begin\": {\n // Add-a-device: forward userId when the visitor is already signed in.\n const userId = await config.getUserId?.(request);\n // Runtime guard: reject non-string values (e.g. numeric username from\n // a malformed client) rather than forwarding them to the store layer.\n const username =\n typeof body.username === \"string\" ? body.username : undefined;\n const result = await config.server.registerBegin({\n sessionId,\n ...(username !== undefined ? { username } : {}),\n ...(userId ? { userId } : {}),\n });\n return jsonResponse(result, 200);\n }\n\n case \"register-finish\": {\n const result = await config.server.registerFinish({ sessionId, body });\n // onAuthSuccess is inside the try block: a session-minting failure is a\n // request failure and must surface as 500, not be swallowed silently.\n const extra = await config.onAuthSuccess?.(result.user, request);\n return jsonResponse({ ...result, ...(extra ?? {}) }, 200);\n }\n\n case \"authenticate-begin\": {\n // Runtime guard: reject non-string values (e.g. numeric username from\n // a malformed client) rather than forwarding them to the store layer.\n const username =\n typeof body.username === \"string\" ? body.username : undefined;\n const result = await config.server.authenticateBegin({\n sessionId,\n ...(username !== undefined ? { username } : {}),\n });\n return jsonResponse(result, 200);\n }\n\n case \"authenticate-finish\": {\n const result = await config.server.authenticateFinish({\n sessionId,\n body,\n });\n const extra = await config.onAuthSuccess?.(result.user, request);\n return jsonResponse({ ...result, ...(extra ?? {}) }, 200);\n }\n\n default:\n return jsonResponse({ error: \"unknown_action\" }, 404);\n }\n } catch (err) {\n if (err instanceof GlideServerError) {\n return jsonResponse({ error: err.code, message: err.message }, 400);\n }\n console.error(\"[glide] unexpected error\", err);\n return jsonResponse({ error: \"internal\" }, 500);\n }\n };\n}\n"]}
|
package/package.json
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@glydi/passkey-server",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"publishConfig": {
|
|
5
|
+
"access": "public"
|
|
6
|
+
},
|
|
7
|
+
"description": "Lightweight, framework-agnostic WebAuthn challenge + verification handlers for Glide. Built on @simplewebauthn/server.",
|
|
8
|
+
"type": "module",
|
|
9
|
+
"exports": {
|
|
10
|
+
".": {
|
|
11
|
+
"types": "./dist/index.d.ts",
|
|
12
|
+
"import": "./dist/index.js"
|
|
13
|
+
}
|
|
14
|
+
},
|
|
15
|
+
"main": "./dist/index.js",
|
|
16
|
+
"module": "./dist/index.js",
|
|
17
|
+
"types": "./dist/index.d.ts",
|
|
18
|
+
"files": [
|
|
19
|
+
"dist"
|
|
20
|
+
],
|
|
21
|
+
"dependencies": {
|
|
22
|
+
"@simplewebauthn/server": "^13.0.0"
|
|
23
|
+
},
|
|
24
|
+
"devDependencies": {
|
|
25
|
+
"@types/node": "^20.0.0",
|
|
26
|
+
"tsup": "^8.0.0",
|
|
27
|
+
"typescript": "^5.4.0"
|
|
28
|
+
},
|
|
29
|
+
"license": "MIT",
|
|
30
|
+
"scripts": {
|
|
31
|
+
"build": "tsup",
|
|
32
|
+
"dev": "tsup --watch",
|
|
33
|
+
"typecheck": "tsc --noEmit",
|
|
34
|
+
"clean": "rm -rf dist .turbo"
|
|
35
|
+
}
|
|
36
|
+
}
|