@mrgnw/anahtar 0.0.6
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/LICENSE +21 -0
- package/README.md +78 -0
- package/dist/components/AuthFlow.svelte +351 -0
- package/dist/components/AuthFlow.svelte.d.ts +7 -0
- package/dist/components/OtpInput.svelte +102 -0
- package/dist/components/OtpInput.svelte.d.ts +11 -0
- package/dist/components/PasskeyPrompt.svelte +168 -0
- package/dist/components/PasskeyPrompt.svelte.d.ts +8 -0
- package/dist/components/index.d.ts +4 -0
- package/dist/components/index.js +4 -0
- package/dist/config.d.ts +2 -0
- package/dist/config.js +15 -0
- package/dist/db/d1.d.ts +22 -0
- package/dist/db/d1.js +202 -0
- package/dist/db/postgres.d.ts +12 -0
- package/dist/db/postgres.js +204 -0
- package/dist/db/sqlite.d.ts +7 -0
- package/dist/db/sqlite.js +187 -0
- package/dist/device.d.ts +1 -0
- package/dist/device.js +36 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.js +14 -0
- package/dist/kit/handle.d.ts +3 -0
- package/dist/kit/handle.js +18 -0
- package/dist/kit/handlers.d.ts +8 -0
- package/dist/kit/handlers.js +183 -0
- package/dist/otp.d.ts +6 -0
- package/dist/otp.js +35 -0
- package/dist/passkey.d.ts +20 -0
- package/dist/passkey.js +112 -0
- package/dist/session.d.ts +16 -0
- package/dist/session.js +31 -0
- package/dist/types.d.ts +93 -0
- package/dist/types.js +1 -0
- package/package.json +92 -0
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
export function sqliteAdapter(db, options = {}) {
|
|
3
|
+
const p = options.tablePrefix ?? 'auth_';
|
|
4
|
+
const t = {
|
|
5
|
+
users: `${p}users`,
|
|
6
|
+
sessions: `${p}sessions`,
|
|
7
|
+
otpCodes: `${p}otp_codes`,
|
|
8
|
+
passkeys: `${p}passkeys`,
|
|
9
|
+
challenges: `${p}challenges`
|
|
10
|
+
};
|
|
11
|
+
return {
|
|
12
|
+
init() {
|
|
13
|
+
db.pragma('journal_mode = WAL');
|
|
14
|
+
db.pragma('foreign_keys = ON');
|
|
15
|
+
db.exec(`
|
|
16
|
+
CREATE TABLE IF NOT EXISTS ${t.users} (
|
|
17
|
+
id TEXT PRIMARY KEY,
|
|
18
|
+
email TEXT UNIQUE NOT NULL,
|
|
19
|
+
skip_passkey_prompt INTEGER DEFAULT 0,
|
|
20
|
+
created_at INTEGER DEFAULT (unixepoch())
|
|
21
|
+
);
|
|
22
|
+
CREATE TABLE IF NOT EXISTS ${t.sessions} (
|
|
23
|
+
id TEXT PRIMARY KEY,
|
|
24
|
+
user_id TEXT NOT NULL REFERENCES ${t.users}(id),
|
|
25
|
+
expires_at INTEGER NOT NULL,
|
|
26
|
+
created_at INTEGER DEFAULT (unixepoch())
|
|
27
|
+
);
|
|
28
|
+
CREATE TABLE IF NOT EXISTS ${t.otpCodes} (
|
|
29
|
+
id TEXT PRIMARY KEY,
|
|
30
|
+
email TEXT NOT NULL,
|
|
31
|
+
code TEXT NOT NULL,
|
|
32
|
+
attempts INTEGER NOT NULL DEFAULT 0,
|
|
33
|
+
expires_at INTEGER NOT NULL,
|
|
34
|
+
created_at INTEGER DEFAULT (unixepoch())
|
|
35
|
+
);
|
|
36
|
+
CREATE TABLE IF NOT EXISTS ${t.passkeys} (
|
|
37
|
+
id TEXT PRIMARY KEY,
|
|
38
|
+
user_id TEXT NOT NULL REFERENCES ${t.users}(id),
|
|
39
|
+
credential_id TEXT UNIQUE NOT NULL,
|
|
40
|
+
public_key BLOB NOT NULL,
|
|
41
|
+
counter INTEGER NOT NULL DEFAULT 0,
|
|
42
|
+
transports TEXT,
|
|
43
|
+
name TEXT,
|
|
44
|
+
created_at INTEGER DEFAULT (unixepoch())
|
|
45
|
+
);
|
|
46
|
+
CREATE TABLE IF NOT EXISTS ${t.challenges} (
|
|
47
|
+
challenge TEXT PRIMARY KEY,
|
|
48
|
+
user_id TEXT NOT NULL,
|
|
49
|
+
expires_at INTEGER NOT NULL,
|
|
50
|
+
created_at INTEGER DEFAULT (unixepoch())
|
|
51
|
+
);
|
|
52
|
+
`);
|
|
53
|
+
},
|
|
54
|
+
getUserByEmail(email) {
|
|
55
|
+
const row = db
|
|
56
|
+
.prepare(`SELECT id, email, skip_passkey_prompt, created_at FROM ${t.users} WHERE email = ?`)
|
|
57
|
+
.get(email);
|
|
58
|
+
if (!row)
|
|
59
|
+
return null;
|
|
60
|
+
return {
|
|
61
|
+
id: row.id,
|
|
62
|
+
email: row.email,
|
|
63
|
+
skipPasskeyPrompt: row.skip_passkey_prompt === 1,
|
|
64
|
+
createdAt: row.created_at
|
|
65
|
+
};
|
|
66
|
+
},
|
|
67
|
+
createUser(email) {
|
|
68
|
+
const id = randomUUID();
|
|
69
|
+
db.prepare(`INSERT INTO ${t.users} (id, email) VALUES (?, ?)`).run(id, email);
|
|
70
|
+
return {
|
|
71
|
+
id,
|
|
72
|
+
email,
|
|
73
|
+
skipPasskeyPrompt: false,
|
|
74
|
+
createdAt: Math.floor(Date.now() / 1000)
|
|
75
|
+
};
|
|
76
|
+
},
|
|
77
|
+
setSkipPasskeyPrompt(userId, skip) {
|
|
78
|
+
db.prepare(`UPDATE ${t.users} SET skip_passkey_prompt = ? WHERE id = ?`).run(skip ? 1 : 0, userId);
|
|
79
|
+
},
|
|
80
|
+
createSession(tokenHash, userId, expiresAt) {
|
|
81
|
+
db.prepare(`INSERT INTO ${t.sessions} (id, user_id, expires_at) VALUES (?, ?, ?)`).run(tokenHash, userId, expiresAt);
|
|
82
|
+
},
|
|
83
|
+
getSession(tokenHash) {
|
|
84
|
+
const row = db
|
|
85
|
+
.prepare(`SELECT s.id, s.user_id, s.expires_at, u.email
|
|
86
|
+
FROM ${t.sessions} s
|
|
87
|
+
JOIN ${t.users} u ON u.id = s.user_id
|
|
88
|
+
WHERE s.id = ?`)
|
|
89
|
+
.get(tokenHash);
|
|
90
|
+
if (!row)
|
|
91
|
+
return null;
|
|
92
|
+
return {
|
|
93
|
+
id: row.id,
|
|
94
|
+
userId: row.user_id,
|
|
95
|
+
expiresAt: row.expires_at,
|
|
96
|
+
email: row.email
|
|
97
|
+
};
|
|
98
|
+
},
|
|
99
|
+
deleteSession(tokenHash) {
|
|
100
|
+
db.prepare(`DELETE FROM ${t.sessions} WHERE id = ?`).run(tokenHash);
|
|
101
|
+
},
|
|
102
|
+
storeOTP(email, id, code, expiresAt) {
|
|
103
|
+
db.prepare(`INSERT INTO ${t.otpCodes} (id, email, code, expires_at) VALUES (?, ?, ?, ?)`).run(id, email, code, expiresAt);
|
|
104
|
+
},
|
|
105
|
+
getLatestOTP(email) {
|
|
106
|
+
const row = db
|
|
107
|
+
.prepare(`SELECT id, email, code, attempts, expires_at FROM ${t.otpCodes} WHERE email = ? ORDER BY created_at DESC LIMIT 1`)
|
|
108
|
+
.get(email);
|
|
109
|
+
if (!row)
|
|
110
|
+
return null;
|
|
111
|
+
return {
|
|
112
|
+
id: row.id,
|
|
113
|
+
email: row.email,
|
|
114
|
+
code: row.code,
|
|
115
|
+
attempts: row.attempts,
|
|
116
|
+
expiresAt: row.expires_at
|
|
117
|
+
};
|
|
118
|
+
},
|
|
119
|
+
updateOTPAttempts(id, attempts) {
|
|
120
|
+
db.prepare(`UPDATE ${t.otpCodes} SET attempts = ? WHERE id = ?`).run(attempts, id);
|
|
121
|
+
},
|
|
122
|
+
deleteOTP(id) {
|
|
123
|
+
db.prepare(`DELETE FROM ${t.otpCodes} WHERE id = ?`).run(id);
|
|
124
|
+
},
|
|
125
|
+
deleteOTPsForEmail(email) {
|
|
126
|
+
db.prepare(`DELETE FROM ${t.otpCodes} WHERE email = ?`).run(email);
|
|
127
|
+
},
|
|
128
|
+
storeChallenge(challenge, userId, expiresAt) {
|
|
129
|
+
db.prepare(`DELETE FROM ${t.challenges} WHERE expires_at < ?`).run(Date.now());
|
|
130
|
+
db.prepare(`INSERT INTO ${t.challenges} (challenge, user_id, expires_at) VALUES (?, ?, ?)`).run(challenge, userId, expiresAt);
|
|
131
|
+
},
|
|
132
|
+
consumeChallenge(challenge) {
|
|
133
|
+
const row = db.prepare(`SELECT user_id, expires_at FROM ${t.challenges} WHERE challenge = ?`).get(challenge);
|
|
134
|
+
if (!row)
|
|
135
|
+
return null;
|
|
136
|
+
db.prepare(`DELETE FROM ${t.challenges} WHERE challenge = ?`).run(challenge);
|
|
137
|
+
if (row.expires_at < Date.now())
|
|
138
|
+
return null;
|
|
139
|
+
return { userId: row.user_id };
|
|
140
|
+
},
|
|
141
|
+
getPasskeyByCredentialId(credentialId) {
|
|
142
|
+
const row = db
|
|
143
|
+
.prepare(`SELECT p.id, p.user_id, p.credential_id, p.public_key, p.counter, p.transports, p.name, p.created_at, u.email
|
|
144
|
+
FROM ${t.passkeys} p
|
|
145
|
+
JOIN ${t.users} u ON u.id = p.user_id
|
|
146
|
+
WHERE p.credential_id = ?`)
|
|
147
|
+
.get(credentialId);
|
|
148
|
+
if (!row)
|
|
149
|
+
return null;
|
|
150
|
+
return {
|
|
151
|
+
id: row.id,
|
|
152
|
+
userId: row.user_id,
|
|
153
|
+
credentialId: row.credential_id,
|
|
154
|
+
publicKey: new Uint8Array(row.public_key),
|
|
155
|
+
counter: row.counter,
|
|
156
|
+
transports: row.transports,
|
|
157
|
+
name: row.name,
|
|
158
|
+
createdAt: row.created_at,
|
|
159
|
+
email: row.email
|
|
160
|
+
};
|
|
161
|
+
},
|
|
162
|
+
getUserPasskeys(userId) {
|
|
163
|
+
const rows = db
|
|
164
|
+
.prepare(`SELECT id, credential_id, public_key, counter, transports, name, created_at FROM ${t.passkeys} WHERE user_id = ?`)
|
|
165
|
+
.all(userId);
|
|
166
|
+
return rows.map((row) => ({
|
|
167
|
+
id: row.id,
|
|
168
|
+
credentialId: row.credential_id,
|
|
169
|
+
publicKey: new Uint8Array(row.public_key),
|
|
170
|
+
counter: row.counter,
|
|
171
|
+
transports: row.transports,
|
|
172
|
+
name: row.name,
|
|
173
|
+
createdAt: row.created_at
|
|
174
|
+
}));
|
|
175
|
+
},
|
|
176
|
+
storePasskey(passkey) {
|
|
177
|
+
db.prepare(`INSERT INTO ${t.passkeys} (id, user_id, credential_id, public_key, counter, transports, name) VALUES (?, ?, ?, ?, ?, ?, ?)`).run(passkey.id, passkey.userId, passkey.credentialId, Buffer.from(passkey.publicKey), passkey.counter, passkey.transports, passkey.name);
|
|
178
|
+
},
|
|
179
|
+
updatePasskeyCounter(id, counter) {
|
|
180
|
+
db.prepare(`UPDATE ${t.passkeys} SET counter = ? WHERE id = ?`).run(counter, id);
|
|
181
|
+
},
|
|
182
|
+
deletePasskey(id, userId) {
|
|
183
|
+
const result = db.prepare(`DELETE FROM ${t.passkeys} WHERE id = ? AND user_id = ?`).run(id, userId);
|
|
184
|
+
return result.changes > 0;
|
|
185
|
+
}
|
|
186
|
+
};
|
|
187
|
+
}
|
package/dist/device.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function guessDeviceName(ua?: string): string;
|
package/dist/device.js
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
export function guessDeviceName(ua) {
|
|
2
|
+
const s = ua ?? (typeof navigator !== 'undefined' ? navigator.userAgent : '');
|
|
3
|
+
if (!s)
|
|
4
|
+
return 'Passkey';
|
|
5
|
+
const browser = /Edg\//i.test(s)
|
|
6
|
+
? 'Edge'
|
|
7
|
+
: /OPR\//i.test(s)
|
|
8
|
+
? 'Opera'
|
|
9
|
+
: /Chrome\//i.test(s)
|
|
10
|
+
? 'Chrome'
|
|
11
|
+
: /Safari\//i.test(s) && !/Chrome/i.test(s)
|
|
12
|
+
? 'Safari'
|
|
13
|
+
: /Firefox\//i.test(s)
|
|
14
|
+
? 'Firefox'
|
|
15
|
+
: null;
|
|
16
|
+
const os = /Windows/i.test(s)
|
|
17
|
+
? 'Windows'
|
|
18
|
+
: /Mac OS|Macintosh/i.test(s)
|
|
19
|
+
? 'macOS'
|
|
20
|
+
: /Android/i.test(s)
|
|
21
|
+
? 'Android'
|
|
22
|
+
: /iPhone|iPad|iPod/i.test(s)
|
|
23
|
+
? 'iOS'
|
|
24
|
+
: /Linux/i.test(s)
|
|
25
|
+
? 'Linux'
|
|
26
|
+
: /CrOS/i.test(s)
|
|
27
|
+
? 'ChromeOS'
|
|
28
|
+
: null;
|
|
29
|
+
if (browser && os)
|
|
30
|
+
return `${browser} on ${os}`;
|
|
31
|
+
if (browser)
|
|
32
|
+
return browser;
|
|
33
|
+
if (os)
|
|
34
|
+
return os;
|
|
35
|
+
return 'Passkey';
|
|
36
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { AuthConfig } from './types.js';
|
|
2
|
+
export { resolveConfig } from './config.js';
|
|
3
|
+
export { guessDeviceName } from './device.js';
|
|
4
|
+
export type { AuthConfig, AuthDB, AuthUser, FullPasskeyRecord, MaybePromise, NewPasskey, OTPRecord, OtpResult, PasskeyRecord, ResolvedConfig, SessionRecord } from './types.js';
|
|
5
|
+
export declare function createAuth(config: AuthConfig): Promise<{
|
|
6
|
+
handle: import("@sveltejs/kit").Handle;
|
|
7
|
+
handlers: {
|
|
8
|
+
GET: (event: import("@sveltejs/kit").RequestEvent) => Promise<Response>;
|
|
9
|
+
POST: (event: import("@sveltejs/kit").RequestEvent) => Promise<Response>;
|
|
10
|
+
};
|
|
11
|
+
config: import("./types.js").ResolvedConfig;
|
|
12
|
+
}>;
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { resolveConfig } from './config.js';
|
|
2
|
+
import { createHandle } from './kit/handle.js';
|
|
3
|
+
import { createHandlers } from './kit/handlers.js';
|
|
4
|
+
export { resolveConfig } from './config.js';
|
|
5
|
+
export { guessDeviceName } from './device.js';
|
|
6
|
+
export async function createAuth(config) {
|
|
7
|
+
const resolved = resolveConfig(config);
|
|
8
|
+
await config.db.init();
|
|
9
|
+
return {
|
|
10
|
+
handle: createHandle(resolved),
|
|
11
|
+
handlers: createHandlers(resolved),
|
|
12
|
+
config: resolved
|
|
13
|
+
};
|
|
14
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { validateSession } from '../session.js';
|
|
2
|
+
export function createHandle(config) {
|
|
3
|
+
return async ({ event, resolve }) => {
|
|
4
|
+
const token = event.cookies.get(config.cookie);
|
|
5
|
+
if (!token) {
|
|
6
|
+
event.locals.user = null;
|
|
7
|
+
return resolve(event);
|
|
8
|
+
}
|
|
9
|
+
const result = await validateSession(config.db, token);
|
|
10
|
+
if (!result) {
|
|
11
|
+
event.cookies.delete(config.cookie, { path: '/' });
|
|
12
|
+
event.locals.user = null;
|
|
13
|
+
return resolve(event);
|
|
14
|
+
}
|
|
15
|
+
event.locals.user = result.user;
|
|
16
|
+
return resolve(event);
|
|
17
|
+
};
|
|
18
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { RequestEvent } from '@sveltejs/kit';
|
|
2
|
+
import type { ResolvedConfig } from '../types.js';
|
|
3
|
+
type RouteHandler = (event: RequestEvent) => Promise<Response>;
|
|
4
|
+
export declare function createHandlers(config: ResolvedConfig): {
|
|
5
|
+
GET: RouteHandler;
|
|
6
|
+
POST: RouteHandler;
|
|
7
|
+
};
|
|
8
|
+
export {};
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import { json } from '@sveltejs/kit';
|
|
2
|
+
import { generateOTP, verifyOTP } from '../otp.js';
|
|
3
|
+
import { generateAuthenticationChallenge, generateRegistrationChallenge, removePasskey, verifyAuthenticationResponse, verifyRegistrationResponse } from '../passkey.js';
|
|
4
|
+
import { createSession, invalidateSession, validateSession } from '../session.js';
|
|
5
|
+
const SESSION_MAX_AGE_SECONDS = 30 * 24 * 60 * 60;
|
|
6
|
+
function requireAuth(event) {
|
|
7
|
+
const user = event.locals.user;
|
|
8
|
+
if (!user)
|
|
9
|
+
return json({ error: 'Not authenticated' }, { status: 401 });
|
|
10
|
+
return user;
|
|
11
|
+
}
|
|
12
|
+
export function createHandlers(config) {
|
|
13
|
+
const maxAge = Math.floor(config.sessionDuration / 1000);
|
|
14
|
+
function cookieOpts(event) {
|
|
15
|
+
return {
|
|
16
|
+
httpOnly: true,
|
|
17
|
+
secure: event.url.protocol === 'https:',
|
|
18
|
+
sameSite: 'lax',
|
|
19
|
+
path: '/',
|
|
20
|
+
maxAge
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
const routes = {
|
|
24
|
+
start: {
|
|
25
|
+
method: 'POST',
|
|
26
|
+
handler: async (event) => {
|
|
27
|
+
const body = await event.request.json().catch(() => null);
|
|
28
|
+
if (!body || typeof body.email !== 'string' || !body.email.includes('@')) {
|
|
29
|
+
return json({ error: 'Invalid email' }, { status: 400 });
|
|
30
|
+
}
|
|
31
|
+
const { code } = await generateOTP(config.db, body.email, config);
|
|
32
|
+
await config.onSendOTP(body.email, code);
|
|
33
|
+
return json({ success: true });
|
|
34
|
+
}
|
|
35
|
+
},
|
|
36
|
+
verify: {
|
|
37
|
+
method: 'POST',
|
|
38
|
+
handler: async (event) => {
|
|
39
|
+
const body = await event.request.json().catch(() => null);
|
|
40
|
+
if (!body || typeof body.email !== 'string' || typeof body.code !== 'string') {
|
|
41
|
+
return json({ error: 'Invalid input' }, { status: 400 });
|
|
42
|
+
}
|
|
43
|
+
const otp = await verifyOTP(config.db, body.email, body.code, config);
|
|
44
|
+
if (!otp.ok) {
|
|
45
|
+
const messages = {
|
|
46
|
+
invalid: 'Invalid code. Please try again.',
|
|
47
|
+
expired: 'Code expired. Please request a new one.',
|
|
48
|
+
rate_limited: 'Too many attempts. Please request a new code.'
|
|
49
|
+
};
|
|
50
|
+
return json({ error: messages[otp.error] }, { status: otp.error === 'rate_limited' ? 429 : 400 });
|
|
51
|
+
}
|
|
52
|
+
let user = await config.db.getUserByEmail(body.email);
|
|
53
|
+
if (!user) {
|
|
54
|
+
user = await config.db.createUser(body.email);
|
|
55
|
+
}
|
|
56
|
+
const session = await createSession(config.db, user.id, config);
|
|
57
|
+
event.cookies.set(config.cookie, session.sessionToken, cookieOpts(event));
|
|
58
|
+
const passkeys = await config.db.getUserPasskeys(user.id);
|
|
59
|
+
return json({
|
|
60
|
+
user: { id: user.id, email: user.email },
|
|
61
|
+
hasPasskey: passkeys.length > 0,
|
|
62
|
+
skipPasskeyPrompt: user.skipPasskeyPrompt
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
},
|
|
66
|
+
logout: {
|
|
67
|
+
method: 'POST',
|
|
68
|
+
handler: async (event) => {
|
|
69
|
+
const token = event.cookies.get(config.cookie);
|
|
70
|
+
if (token) {
|
|
71
|
+
const result = await validateSession(config.db, token);
|
|
72
|
+
if (result) {
|
|
73
|
+
await invalidateSession(config.db, result.session.id);
|
|
74
|
+
}
|
|
75
|
+
event.cookies.delete(config.cookie, { path: '/' });
|
|
76
|
+
}
|
|
77
|
+
return json({ ok: true });
|
|
78
|
+
}
|
|
79
|
+
},
|
|
80
|
+
'passkey/login-start': {
|
|
81
|
+
method: 'GET',
|
|
82
|
+
handler: async (event) => {
|
|
83
|
+
const options = await generateAuthenticationChallenge(config.db, event.url);
|
|
84
|
+
return json(options);
|
|
85
|
+
}
|
|
86
|
+
},
|
|
87
|
+
'passkey/login-finish': {
|
|
88
|
+
method: 'POST',
|
|
89
|
+
handler: async (event) => {
|
|
90
|
+
const body = await event.request.json().catch(() => null);
|
|
91
|
+
if (!body)
|
|
92
|
+
return json({ error: 'Invalid input' }, { status: 400 });
|
|
93
|
+
const result = await verifyAuthenticationResponse(config.db, body, event.url);
|
|
94
|
+
if (!result)
|
|
95
|
+
return json({ error: 'Authentication failed' }, { status: 401 });
|
|
96
|
+
const session = await createSession(config.db, result.user.id, config);
|
|
97
|
+
event.cookies.set(config.cookie, session.sessionToken, cookieOpts(event));
|
|
98
|
+
return json({ user: result.user });
|
|
99
|
+
}
|
|
100
|
+
},
|
|
101
|
+
'passkey/register-start': {
|
|
102
|
+
method: 'POST',
|
|
103
|
+
handler: async (event) => {
|
|
104
|
+
const user = requireAuth(event);
|
|
105
|
+
if (user instanceof Response)
|
|
106
|
+
return user;
|
|
107
|
+
const options = await generateRegistrationChallenge(config.db, user, event.url, config);
|
|
108
|
+
return json(options);
|
|
109
|
+
}
|
|
110
|
+
},
|
|
111
|
+
'passkey/register-finish': {
|
|
112
|
+
method: 'POST',
|
|
113
|
+
handler: async (event) => {
|
|
114
|
+
const user = requireAuth(event);
|
|
115
|
+
if (user instanceof Response)
|
|
116
|
+
return user;
|
|
117
|
+
const body = await event.request.json().catch(() => null);
|
|
118
|
+
if (!body)
|
|
119
|
+
return json({ error: 'Invalid input' }, { status: 400 });
|
|
120
|
+
const { name, ...response } = body;
|
|
121
|
+
const passkeyName = typeof name === 'string' && name.trim() ? name.trim() : null;
|
|
122
|
+
const success = await verifyRegistrationResponse(config.db, user.id, response, event.url, passkeyName);
|
|
123
|
+
if (!success)
|
|
124
|
+
return json({ error: 'Passkey registration failed' }, { status: 400 });
|
|
125
|
+
return json({ success: true });
|
|
126
|
+
}
|
|
127
|
+
},
|
|
128
|
+
'passkey/remove': {
|
|
129
|
+
method: 'POST',
|
|
130
|
+
handler: async (event) => {
|
|
131
|
+
const user = requireAuth(event);
|
|
132
|
+
if (user instanceof Response)
|
|
133
|
+
return user;
|
|
134
|
+
const body = await event.request.json().catch(() => null);
|
|
135
|
+
if (!body || typeof body.passkeyId !== 'string') {
|
|
136
|
+
return json({ error: 'Invalid input' }, { status: 400 });
|
|
137
|
+
}
|
|
138
|
+
const success = await removePasskey(config.db, body.passkeyId, user.id);
|
|
139
|
+
if (!success)
|
|
140
|
+
return json({ error: 'Passkey not found' }, { status: 404 });
|
|
141
|
+
return json({ success: true });
|
|
142
|
+
}
|
|
143
|
+
},
|
|
144
|
+
'skip-passkey': {
|
|
145
|
+
method: 'POST',
|
|
146
|
+
handler: async (event) => {
|
|
147
|
+
const user = requireAuth(event);
|
|
148
|
+
if (user instanceof Response)
|
|
149
|
+
return user;
|
|
150
|
+
await config.db.setSkipPasskeyPrompt(user.id, true);
|
|
151
|
+
return json({ success: true });
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
};
|
|
155
|
+
function getRoute(event) {
|
|
156
|
+
const path = event.params.path;
|
|
157
|
+
if (typeof path === 'string')
|
|
158
|
+
return path;
|
|
159
|
+
return null;
|
|
160
|
+
}
|
|
161
|
+
return {
|
|
162
|
+
GET: async (event) => {
|
|
163
|
+
const path = getRoute(event);
|
|
164
|
+
if (!path)
|
|
165
|
+
return json({ error: 'Not found' }, { status: 404 });
|
|
166
|
+
const route = routes[path];
|
|
167
|
+
if (!route || route.method !== 'GET') {
|
|
168
|
+
return json({ error: 'Not found' }, { status: 404 });
|
|
169
|
+
}
|
|
170
|
+
return route.handler(event);
|
|
171
|
+
},
|
|
172
|
+
POST: async (event) => {
|
|
173
|
+
const path = getRoute(event);
|
|
174
|
+
if (!path)
|
|
175
|
+
return json({ error: 'Not found' }, { status: 404 });
|
|
176
|
+
const route = routes[path];
|
|
177
|
+
if (!route || route.method !== 'POST') {
|
|
178
|
+
return json({ error: 'Not found' }, { status: 404 });
|
|
179
|
+
}
|
|
180
|
+
return route.handler(event);
|
|
181
|
+
}
|
|
182
|
+
};
|
|
183
|
+
}
|
package/dist/otp.d.ts
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { AuthDB, OtpResult, ResolvedConfig } from './types.js';
|
|
2
|
+
export declare function generateOTP(db: AuthDB, email: string, config: ResolvedConfig): Promise<{
|
|
3
|
+
id: string;
|
|
4
|
+
code: string;
|
|
5
|
+
}>;
|
|
6
|
+
export declare function verifyOTP(db: AuthDB, email: string, code: string, config: ResolvedConfig): Promise<OtpResult>;
|
package/dist/otp.js
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { randomInt, randomUUID } from 'node:crypto';
|
|
2
|
+
export async function generateOTP(db, email, config) {
|
|
3
|
+
await db.deleteOTPsForEmail(email);
|
|
4
|
+
const id = randomUUID();
|
|
5
|
+
const max = 10 ** config.otpLength;
|
|
6
|
+
const min = 10 ** (config.otpLength - 1);
|
|
7
|
+
const code = String(randomInt(min, max));
|
|
8
|
+
const expiresAt = Date.now() + config.otpExpiry;
|
|
9
|
+
await db.storeOTP(email, id, code, expiresAt);
|
|
10
|
+
return { id, code };
|
|
11
|
+
}
|
|
12
|
+
export async function verifyOTP(db, email, code, config) {
|
|
13
|
+
const row = await db.getLatestOTP(email);
|
|
14
|
+
if (!row)
|
|
15
|
+
return { ok: false, error: 'invalid' };
|
|
16
|
+
if (row.expiresAt < Date.now()) {
|
|
17
|
+
await db.deleteOTP(row.id);
|
|
18
|
+
return { ok: false, error: 'expired' };
|
|
19
|
+
}
|
|
20
|
+
if (row.attempts >= config.otpMaxAttempts) {
|
|
21
|
+
await db.deleteOTP(row.id);
|
|
22
|
+
return { ok: false, error: 'rate_limited', attemptsLeft: 0 };
|
|
23
|
+
}
|
|
24
|
+
if (row.code !== code) {
|
|
25
|
+
const newAttempts = row.attempts + 1;
|
|
26
|
+
await db.updateOTPAttempts(row.id, newAttempts);
|
|
27
|
+
if (newAttempts >= config.otpMaxAttempts) {
|
|
28
|
+
await db.deleteOTP(row.id);
|
|
29
|
+
return { ok: false, error: 'rate_limited', attemptsLeft: 0 };
|
|
30
|
+
}
|
|
31
|
+
return { ok: false, error: 'invalid' };
|
|
32
|
+
}
|
|
33
|
+
await db.deleteOTP(row.id);
|
|
34
|
+
return { ok: true };
|
|
35
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { AuthenticationResponseJSON, RegistrationResponseJSON } from '@simplewebauthn/server';
|
|
2
|
+
import type { AuthDB, ResolvedConfig } from './types.js';
|
|
3
|
+
export declare function getWebAuthnConfig(requestUrl: URL): {
|
|
4
|
+
rpID: string;
|
|
5
|
+
origin: string;
|
|
6
|
+
};
|
|
7
|
+
export declare function generateRegistrationChallenge(db: AuthDB, user: {
|
|
8
|
+
id: string;
|
|
9
|
+
email: string;
|
|
10
|
+
}, requestUrl: URL, config: ResolvedConfig): Promise<import("@simplewebauthn/server").PublicKeyCredentialCreationOptionsJSON>;
|
|
11
|
+
export declare function verifyRegistrationResponse(db: AuthDB, userId: string, response: RegistrationResponseJSON, requestUrl: URL, name?: string | null): Promise<boolean>;
|
|
12
|
+
export declare function generateAuthenticationChallenge(db: AuthDB, requestUrl: URL): Promise<import("@simplewebauthn/server").PublicKeyCredentialRequestOptionsJSON>;
|
|
13
|
+
export declare function verifyAuthenticationResponse(db: AuthDB, response: AuthenticationResponseJSON, requestUrl: URL): Promise<{
|
|
14
|
+
user: {
|
|
15
|
+
id: string;
|
|
16
|
+
email: string;
|
|
17
|
+
};
|
|
18
|
+
} | null>;
|
|
19
|
+
export declare function getUserPasskeys(db: AuthDB, userId: string): Promise<import("./types.js").PasskeyRecord[]>;
|
|
20
|
+
export declare function removePasskey(db: AuthDB, passkeyId: string, userId: string): Promise<boolean>;
|
package/dist/passkey.js
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { generateAuthenticationOptions, generateRegistrationOptions, verifyAuthenticationResponse as verifyAuthResponse, verifyRegistrationResponse as verifyRegResponse } from '@simplewebauthn/server';
|
|
3
|
+
const CHALLENGE_EXPIRY_MS = 5 * 60 * 1000;
|
|
4
|
+
export function getWebAuthnConfig(requestUrl) {
|
|
5
|
+
const envOrigin = process.env.ORIGIN;
|
|
6
|
+
if (envOrigin) {
|
|
7
|
+
const url = new URL(envOrigin);
|
|
8
|
+
return { rpID: url.hostname, origin: url.origin };
|
|
9
|
+
}
|
|
10
|
+
return { rpID: requestUrl.hostname, origin: requestUrl.origin };
|
|
11
|
+
}
|
|
12
|
+
export async function generateRegistrationChallenge(db, user, requestUrl, config) {
|
|
13
|
+
const { rpID } = getWebAuthnConfig(requestUrl);
|
|
14
|
+
const existingPasskeys = await db.getUserPasskeys(user.id);
|
|
15
|
+
const excludeCredentials = existingPasskeys.map((pk) => ({
|
|
16
|
+
id: pk.credentialId,
|
|
17
|
+
transports: pk.transports ? JSON.parse(pk.transports) : undefined
|
|
18
|
+
}));
|
|
19
|
+
const options = await generateRegistrationOptions({
|
|
20
|
+
rpName: config.rpName,
|
|
21
|
+
rpID,
|
|
22
|
+
userName: user.email,
|
|
23
|
+
userID: new TextEncoder().encode(user.id),
|
|
24
|
+
authenticatorSelection: {
|
|
25
|
+
residentKey: 'required',
|
|
26
|
+
userVerification: 'preferred'
|
|
27
|
+
},
|
|
28
|
+
excludeCredentials
|
|
29
|
+
});
|
|
30
|
+
await db.storeChallenge(options.challenge, user.id, Date.now() + CHALLENGE_EXPIRY_MS);
|
|
31
|
+
return options;
|
|
32
|
+
}
|
|
33
|
+
export async function verifyRegistrationResponse(db, userId, response, requestUrl, name = null) {
|
|
34
|
+
const { rpID, origin } = getWebAuthnConfig(requestUrl);
|
|
35
|
+
const challenge = JSON.parse(Buffer.from(response.response.clientDataJSON, 'base64url').toString()).challenge;
|
|
36
|
+
const stored = await db.consumeChallenge(challenge);
|
|
37
|
+
if (!stored || stored.userId !== userId)
|
|
38
|
+
return false;
|
|
39
|
+
try {
|
|
40
|
+
const verification = await verifyRegResponse({
|
|
41
|
+
response,
|
|
42
|
+
expectedChallenge: challenge,
|
|
43
|
+
expectedOrigin: origin,
|
|
44
|
+
expectedRPID: rpID
|
|
45
|
+
});
|
|
46
|
+
if (!verification.verified || !verification.registrationInfo)
|
|
47
|
+
return false;
|
|
48
|
+
const { credential } = verification.registrationInfo;
|
|
49
|
+
await db.storePasskey({
|
|
50
|
+
id: randomUUID(),
|
|
51
|
+
userId,
|
|
52
|
+
credentialId: credential.id,
|
|
53
|
+
publicKey: new Uint8Array(credential.publicKey),
|
|
54
|
+
counter: credential.counter,
|
|
55
|
+
transports: response.response.transports ? JSON.stringify(response.response.transports) : null,
|
|
56
|
+
name
|
|
57
|
+
});
|
|
58
|
+
return true;
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
return false;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
export async function generateAuthenticationChallenge(db, requestUrl) {
|
|
65
|
+
const { rpID } = getWebAuthnConfig(requestUrl);
|
|
66
|
+
const options = await generateAuthenticationOptions({
|
|
67
|
+
rpID,
|
|
68
|
+
allowCredentials: [],
|
|
69
|
+
userVerification: 'preferred'
|
|
70
|
+
});
|
|
71
|
+
await db.storeChallenge(options.challenge, 'anonymous', Date.now() + CHALLENGE_EXPIRY_MS);
|
|
72
|
+
return options;
|
|
73
|
+
}
|
|
74
|
+
export async function verifyAuthenticationResponse(db, response, requestUrl) {
|
|
75
|
+
const { rpID, origin } = getWebAuthnConfig(requestUrl);
|
|
76
|
+
const passkey = await db.getPasskeyByCredentialId(response.id);
|
|
77
|
+
if (!passkey)
|
|
78
|
+
return null;
|
|
79
|
+
const challenge = JSON.parse(Buffer.from(response.response.clientDataJSON, 'base64url').toString()).challenge;
|
|
80
|
+
const stored = await db.consumeChallenge(challenge);
|
|
81
|
+
if (!stored)
|
|
82
|
+
return null;
|
|
83
|
+
try {
|
|
84
|
+
const verification = await verifyAuthResponse({
|
|
85
|
+
response,
|
|
86
|
+
expectedChallenge: challenge,
|
|
87
|
+
expectedOrigin: origin,
|
|
88
|
+
expectedRPID: rpID,
|
|
89
|
+
credential: {
|
|
90
|
+
id: passkey.credentialId,
|
|
91
|
+
publicKey: new Uint8Array(passkey.publicKey),
|
|
92
|
+
counter: passkey.counter,
|
|
93
|
+
transports: passkey.transports ? JSON.parse(passkey.transports) : undefined
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
if (!verification.verified)
|
|
97
|
+
return null;
|
|
98
|
+
await db.updatePasskeyCounter(passkey.id, verification.authenticationInfo.newCounter);
|
|
99
|
+
return {
|
|
100
|
+
user: { id: passkey.userId, email: passkey.email }
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
catch {
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
export async function getUserPasskeys(db, userId) {
|
|
108
|
+
return db.getUserPasskeys(userId);
|
|
109
|
+
}
|
|
110
|
+
export async function removePasskey(db, passkeyId, userId) {
|
|
111
|
+
return db.deletePasskey(passkeyId, userId);
|
|
112
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { AuthDB, ResolvedConfig } from './types.js';
|
|
2
|
+
export declare function createSession(db: AuthDB, userId: string, config: ResolvedConfig): Promise<{
|
|
3
|
+
sessionToken: string;
|
|
4
|
+
expiresAt: number;
|
|
5
|
+
}>;
|
|
6
|
+
export declare function validateSession(db: AuthDB, token: string): Promise<{
|
|
7
|
+
user: {
|
|
8
|
+
id: string;
|
|
9
|
+
email: string;
|
|
10
|
+
};
|
|
11
|
+
session: {
|
|
12
|
+
id: string;
|
|
13
|
+
expiresAt: number;
|
|
14
|
+
};
|
|
15
|
+
} | null>;
|
|
16
|
+
export declare function invalidateSession(db: AuthDB, sessionId: string): Promise<void>;
|