passkey-magic 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 ADDED
@@ -0,0 +1,302 @@
1
+ # passkey-magic
2
+
3
+ Passkey-first authentication with QR cross-device login and magic link fallback.
4
+
5
+ - **Passkeys (WebAuthn)** — Register and sign in with biometrics, security keys, or platform authenticators
6
+ - **QR Cross-Device** — Scan a QR code on your phone to log in on desktop
7
+ - **Magic Links** — Email-based passwordless fallback
8
+ - **Framework Agnostic** — Works with any JavaScript runtime (Node.js, Bun, Deno, Cloudflare Workers)
9
+ - **better-auth Plugin** — Drop-in integration with [better-auth](https://github.com/better-auth/better-auth)
10
+
11
+ ## Install
12
+
13
+ ```bash
14
+ npm install passkey-magic
15
+ ```
16
+
17
+ ## Quick Start
18
+
19
+ ### Server
20
+
21
+ ```ts
22
+ import { createAuth } from 'passkey-magic/server'
23
+ import { memoryAdapter } from 'passkey-magic/adapters/memory'
24
+
25
+ const auth = createAuth({
26
+ rpName: 'My App',
27
+ rpID: 'example.com',
28
+ origin: 'https://example.com',
29
+ storage: memoryAdapter(),
30
+ })
31
+
32
+ // Use as a Web Standard Request handler
33
+ export default {
34
+ fetch: auth.createHandler({ pathPrefix: '/auth' })
35
+ }
36
+ ```
37
+
38
+ ### Client
39
+
40
+ ```ts
41
+ import { createClient } from 'passkey-magic/client'
42
+
43
+ const auth = createClient({
44
+ request: (endpoint, body) =>
45
+ fetch(`/auth${endpoint}`, {
46
+ method: 'POST',
47
+ headers: { 'Content-Type': 'application/json' },
48
+ body: body ? JSON.stringify(body) : undefined,
49
+ }).then(r => r.json()),
50
+ })
51
+
52
+ // Register a passkey
53
+ const { user, session } = await auth.registerPasskey({ email: 'user@example.com' })
54
+
55
+ // Sign in
56
+ const result = await auth.signInWithPasskey()
57
+ ```
58
+
59
+ ## Features
60
+
61
+ ### Passkey Authentication
62
+
63
+ ```ts
64
+ // Server: generate options, verify response
65
+ const { options, userId } = await auth.generateRegistrationOptions({ email: 'user@example.com' })
66
+ const result = await auth.verifyRegistration({ userId, response: browserResponse })
67
+
68
+ // Authentication
69
+ const { options } = await auth.generateAuthenticationOptions()
70
+ const { user, session } = await auth.verifyAuthentication({ response: browserResponse })
71
+ ```
72
+
73
+ ### QR Cross-Device Login
74
+
75
+ ```ts
76
+ // Desktop: create session and display QR code
77
+ const { sessionId } = await auth.createQRSession()
78
+ const qrSvg = client.renderQR(`https://example.com/auth/qr/${sessionId}`)
79
+
80
+ // Desktop: poll for completion
81
+ for await (const status of client.pollQRSession(sessionId)) {
82
+ if (status.state === 'authenticated') {
83
+ // User logged in from their phone
84
+ }
85
+ }
86
+
87
+ // Mobile: complete the session
88
+ await client.completeQRSession({ sessionId })
89
+ ```
90
+
91
+ ### Magic Links
92
+
93
+ Enable by providing an email adapter:
94
+
95
+ ```ts
96
+ const auth = createAuth({
97
+ // ...webauthn config
98
+ storage: memoryAdapter(),
99
+ email: {
100
+ async sendMagicLink(email, url, token) {
101
+ await sendEmail({ to: email, subject: 'Login', html: `<a href="${url}">Log in</a>` })
102
+ }
103
+ },
104
+ magicLinkURL: 'https://example.com/auth/verify',
105
+ })
106
+
107
+ // Send a magic link
108
+ await auth.sendMagicLink({ email: 'user@example.com' })
109
+
110
+ // Verify (after user clicks the link)
111
+ const { user, session, isNewUser } = await auth.verifyMagicLink({ token })
112
+ ```
113
+
114
+ ### Passkey Management
115
+
116
+ ```ts
117
+ // Add a passkey to an existing account
118
+ const { options } = await auth.addPasskey({ userId })
119
+ const { credential } = await auth.verifyAddPasskey({ userId, response: browserResponse })
120
+
121
+ // List, update, remove
122
+ const credentials = await auth.getUserCredentials(userId)
123
+ await auth.updateCredential({ credentialId: 'cred_123', label: 'iPhone' })
124
+ await auth.removeCredential('cred_123')
125
+ ```
126
+
127
+ ### Session Management
128
+
129
+ ```ts
130
+ const result = await auth.validateSession(token) // { user, session } | null
131
+ const sessions = await auth.getUserSessions(userId)
132
+ await auth.revokeSession(token)
133
+ await auth.revokeAllSessions(userId)
134
+ ```
135
+
136
+ ### Lifecycle Hooks
137
+
138
+ ```ts
139
+ const auth = createAuth({
140
+ // ...config
141
+ hooks: {
142
+ async beforeRegister({ email }) {
143
+ if (await isBlocked(email)) return false // abort
144
+ },
145
+ async afterAuthenticate({ user, session }) {
146
+ await logLogin(user.id)
147
+ },
148
+ },
149
+ })
150
+ ```
151
+
152
+ ### Events
153
+
154
+ ```ts
155
+ auth.on('session:created', ({ session, user, method }) => { /* ... */ })
156
+ auth.on('credential:created', ({ credential, user }) => { /* ... */ })
157
+ auth.on('user:created', ({ user }) => { /* ... */ })
158
+ ```
159
+
160
+ ## Storage Adapters
161
+
162
+ ### Memory (development)
163
+
164
+ ```ts
165
+ import { memoryAdapter } from 'passkey-magic/adapters/memory'
166
+
167
+ const storage = memoryAdapter()
168
+ ```
169
+
170
+ ### Unstorage (production)
171
+
172
+ Works with any [unstorage](https://unstorage.unjs.io) driver (Redis, Vercel KV, Cloudflare KV, filesystem, etc.):
173
+
174
+ ```ts
175
+ import { unstorageAdapter } from 'passkey-magic/adapters/unstorage'
176
+ import { createStorage } from 'unstorage'
177
+ import redisDriver from 'unstorage/drivers/redis'
178
+
179
+ const storage = unstorageAdapter(
180
+ createStorage({ driver: redisDriver({ url: 'redis://localhost:6379' }) }),
181
+ { base: 'auth' }
182
+ )
183
+ ```
184
+
185
+ ### Custom Adapter
186
+
187
+ Implement the `StorageAdapter` interface for any database:
188
+
189
+ ```ts
190
+ import type { StorageAdapter } from 'passkey-magic/server'
191
+
192
+ const myAdapter: StorageAdapter = {
193
+ createUser(user) { /* ... */ },
194
+ getUserById(id) { /* ... */ },
195
+ // ... see StorageAdapter interface for all methods
196
+ }
197
+ ```
198
+
199
+ ## Integrations
200
+
201
+ ### Nitro
202
+
203
+ ```ts
204
+ import { passkeyMagic, useAuth } from 'passkey-magic/nitro'
205
+
206
+ export default defineNitroPlugin(() => {
207
+ passkeyMagic({
208
+ rpName: 'My App',
209
+ rpID: 'example.com',
210
+ origin: 'https://example.com',
211
+ pathPrefix: '/auth',
212
+ }).setup(nitroApp)
213
+ })
214
+
215
+ // In route handlers:
216
+ const auth = useAuth()
217
+ const session = await auth.validateSession(token)
218
+ ```
219
+
220
+ ### better-auth
221
+
222
+ Use passkey-magic as a [better-auth](https://www.better-auth.com) plugin. All data is stored in better-auth's database, and sessions are unified with better-auth's session system.
223
+
224
+ #### Server
225
+
226
+ ```ts
227
+ import { betterAuth } from 'better-auth'
228
+ import { passkeyMagicPlugin } from 'passkey-magic/better-auth'
229
+
230
+ const auth = betterAuth({
231
+ database: myAdapter,
232
+ plugins: [
233
+ passkeyMagicPlugin({
234
+ rpName: 'My App',
235
+ rpID: 'example.com',
236
+ origin: 'https://example.com',
237
+ }),
238
+ ],
239
+ })
240
+ ```
241
+
242
+ #### Client
243
+
244
+ ```ts
245
+ import { createAuthClient } from 'better-auth/client'
246
+ import { passkeyMagicClientPlugin } from 'passkey-magic/better-auth/client'
247
+
248
+ const auth = createAuthClient({
249
+ plugins: [passkeyMagicClientPlugin()],
250
+ })
251
+
252
+ // All endpoints are type-safe:
253
+ await auth.passkeyMagic.register.options({ email: 'user@example.com' })
254
+ await auth.passkeyMagic.qr.create()
255
+ ```
256
+
257
+ #### Plugin Endpoints
258
+
259
+ All endpoints are prefixed with `/passkey-magic/`:
260
+
261
+ | Endpoint | Method | Auth | Description |
262
+ |---|---|---|---|
263
+ | `/register/options` | POST | No | Generate passkey registration options |
264
+ | `/register/verify` | POST | No | Verify registration and create session |
265
+ | `/authenticate/options` | POST | No | Generate authentication options |
266
+ | `/authenticate/verify` | POST | No | Verify authentication and create session |
267
+ | `/add/options` | POST | Yes | Add passkey to existing account |
268
+ | `/add/verify` | POST | Yes | Verify added passkey |
269
+ | `/credentials` | GET | Yes | List user's passkeys |
270
+ | `/credentials/update` | POST | Yes | Update passkey label |
271
+ | `/credentials/remove` | POST | Yes | Remove a passkey |
272
+ | `/qr/create` | POST | No | Create QR login session |
273
+ | `/qr/status` | GET | No | Poll QR session status |
274
+ | `/qr/scanned` | POST | No | Mark QR session as scanned |
275
+ | `/qr/complete` | POST | No | Complete QR auth and create session |
276
+ | `/magic-link/send` | POST | No | Send magic link email |
277
+ | `/magic-link/verify` | POST | No | Verify magic link and create session |
278
+
279
+ The plugin creates 4 database tables (`passkeyCredential`, `qrSession`, `passkeyChallenge`, `magicLinkToken`) and manages them through better-auth's adapter. Authentication endpoints create proper better-auth sessions with cookies.
280
+
281
+ ## Configuration
282
+
283
+ ```ts
284
+ interface AuthConfig {
285
+ rpName: string // Relying party name (shown in passkey prompts)
286
+ rpID: string // Relying party ID (your domain)
287
+ origin: string | string[] // Expected origin(s) for WebAuthn
288
+ storage: StorageAdapter // Persistence layer
289
+ email?: EmailAdapter // Enables magic links
290
+ magicLinkURL?: string // Base URL for magic link emails
291
+ sessionTTL?: number // Default: 7 days (ms)
292
+ challengeTTL?: number // Default: 60 seconds (ms)
293
+ magicLinkTTL?: number // Default: 15 minutes (ms)
294
+ qrSessionTTL?: number // Default: 5 minutes (ms)
295
+ generateId?: () => string // Default: crypto.randomUUID()
296
+ hooks?: AuthHooks // Lifecycle hooks
297
+ }
298
+ ```
299
+
300
+ ## License
301
+
302
+ MIT
@@ -0,0 +1,10 @@
1
+ import { m as StorageAdapter } from "../types-BjM1f6uu.mjs";
2
+
3
+ //#region src/adapters/memory.d.ts
4
+ /**
5
+ * In-memory storage adapter for development and testing.
6
+ * Data is not persisted across restarts.
7
+ */
8
+ declare function memoryAdapter(): StorageAdapter;
9
+ //#endregion
10
+ export { memoryAdapter };
@@ -0,0 +1,142 @@
1
+ import { i as timingSafeEqual } from "../crypto-KHRNe6EL.mjs";
2
+ //#region src/adapters/memory.ts
3
+ /**
4
+ * In-memory storage adapter for development and testing.
5
+ * Data is not persisted across restarts.
6
+ */
7
+ function memoryAdapter() {
8
+ const users = /* @__PURE__ */ new Map();
9
+ const credentials = /* @__PURE__ */ new Map();
10
+ const sessions = /* @__PURE__ */ new Map();
11
+ const challenges = /* @__PURE__ */ new Map();
12
+ const magicLinks = /* @__PURE__ */ new Map();
13
+ const qrSessions = /* @__PURE__ */ new Map();
14
+ function isExpired(entry) {
15
+ return !entry || Date.now() > entry.expiresAt;
16
+ }
17
+ return {
18
+ async createUser(user) {
19
+ users.set(user.id, { ...user });
20
+ return { ...user };
21
+ },
22
+ async getUserById(id) {
23
+ const user = users.get(id);
24
+ return user ? { ...user } : null;
25
+ },
26
+ async getUserByEmail(email) {
27
+ for (const user of users.values()) if (user.email === email) return { ...user };
28
+ return null;
29
+ },
30
+ async updateUser(id, update) {
31
+ const user = users.get(id);
32
+ if (!user) throw new Error(`User not found: ${id}`);
33
+ const updated = {
34
+ ...user,
35
+ ...update
36
+ };
37
+ users.set(id, updated);
38
+ return { ...updated };
39
+ },
40
+ async deleteUser(id) {
41
+ users.delete(id);
42
+ },
43
+ async createCredential(cred) {
44
+ credentials.set(cred.id, { ...cred });
45
+ return { ...cred };
46
+ },
47
+ async getCredentialById(id) {
48
+ const cred = credentials.get(id);
49
+ return cred ? { ...cred } : null;
50
+ },
51
+ async getCredentialsByUserId(userId) {
52
+ const result = [];
53
+ for (const cred of credentials.values()) if (cred.userId === userId) result.push({ ...cred });
54
+ return result;
55
+ },
56
+ async updateCredential(id, update) {
57
+ const cred = credentials.get(id);
58
+ if (!cred) throw new Error(`Credential not found: ${id}`);
59
+ if (update.counter !== void 0) cred.counter = update.counter;
60
+ if (update.label !== void 0) cred.label = update.label;
61
+ },
62
+ async deleteCredential(id) {
63
+ credentials.delete(id);
64
+ },
65
+ async createSession(session) {
66
+ sessions.set(session.id, { ...session });
67
+ return { ...session };
68
+ },
69
+ async getSessionByToken(token) {
70
+ for (const session of sessions.values()) if (await timingSafeEqual(session.token, token)) {
71
+ if (/* @__PURE__ */ new Date() > session.expiresAt) {
72
+ sessions.delete(session.id);
73
+ return null;
74
+ }
75
+ return { ...session };
76
+ }
77
+ return null;
78
+ },
79
+ async getSessionsByUserId(userId) {
80
+ const result = [];
81
+ for (const session of sessions.values()) if (session.userId === userId) if (/* @__PURE__ */ new Date() > session.expiresAt) sessions.delete(session.id);
82
+ else result.push({ ...session });
83
+ return result;
84
+ },
85
+ async deleteSession(id) {
86
+ sessions.delete(id);
87
+ },
88
+ async deleteSessionsByUserId(userId) {
89
+ for (const [id, session] of sessions) if (session.userId === userId) sessions.delete(id);
90
+ },
91
+ async storeChallenge(key, challenge, ttlMs) {
92
+ challenges.set(key, {
93
+ value: challenge,
94
+ expiresAt: Date.now() + ttlMs
95
+ });
96
+ },
97
+ async getChallenge(key) {
98
+ const entry = challenges.get(key);
99
+ if (isExpired(entry)) {
100
+ challenges.delete(key);
101
+ return null;
102
+ }
103
+ return entry.value;
104
+ },
105
+ async deleteChallenge(key) {
106
+ challenges.delete(key);
107
+ },
108
+ async storeMagicLink(token, email, ttlMs) {
109
+ magicLinks.set(token, {
110
+ value: { email },
111
+ expiresAt: Date.now() + ttlMs
112
+ });
113
+ },
114
+ async getMagicLink(token) {
115
+ const entry = magicLinks.get(token);
116
+ if (isExpired(entry)) {
117
+ magicLinks.delete(token);
118
+ return null;
119
+ }
120
+ return entry.value;
121
+ },
122
+ async deleteMagicLink(token) {
123
+ magicLinks.delete(token);
124
+ },
125
+ async createQRSession(session) {
126
+ qrSessions.set(session.id, { ...session });
127
+ return { ...session };
128
+ },
129
+ async getQRSession(id) {
130
+ const session = qrSessions.get(id);
131
+ if (!session) return null;
132
+ if (/* @__PURE__ */ new Date() > session.expiresAt && session.state === "pending") session.state = "expired";
133
+ return { ...session };
134
+ },
135
+ async updateQRSession(id, update) {
136
+ const session = qrSessions.get(id);
137
+ if (session) Object.assign(session, update);
138
+ }
139
+ };
140
+ }
141
+ //#endregion
142
+ export { memoryAdapter };
@@ -0,0 +1,30 @@
1
+ import { m as StorageAdapter } from "../types-BjM1f6uu.mjs";
2
+ import { Storage } from "unstorage";
3
+
4
+ //#region src/adapters/unstorage.d.ts
5
+ /** Options for the unstorage adapter. */
6
+ interface UnstorageAdapterOptions {
7
+ /** Key prefix for all auth data. Defaults to `"auth"`. */
8
+ base?: string;
9
+ }
10
+ /**
11
+ * Storage adapter backed by [unstorage](https://unstorage.unjs.io).
12
+ * Works with any unstorage driver (memory, redis, fs, cloudflare-kv, etc.).
13
+ *
14
+ * Key schema:
15
+ * ```
16
+ * {base}:user:{id} → User
17
+ * {base}:user-email:{email} → userId (secondary index)
18
+ * {base}:cred:{id} → Credential
19
+ * {base}:user-creds:{userId} → credentialId[] (index)
20
+ * {base}:session:{id} → Session
21
+ * {base}:session-token:{token} → sessionId (secondary index)
22
+ * {base}:user-sessions:{userId} → sessionId[] (index)
23
+ * {base}:challenge:{key} → { challenge, expiresAt }
24
+ * {base}:magic-link:{token} → { email, expiresAt }
25
+ * {base}:qr:{id} → QRSession
26
+ * ```
27
+ */
28
+ declare function unstorageAdapter(storage: Storage, options?: UnstorageAdapterOptions): StorageAdapter;
29
+ //#endregion
30
+ export { UnstorageAdapterOptions, unstorageAdapter };