@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,168 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { onDestroy } from 'svelte';
|
|
3
|
+
|
|
4
|
+
interface Props {
|
|
5
|
+
countdownSeconds?: number;
|
|
6
|
+
onRegister: () => Promise<void>;
|
|
7
|
+
onSkip: () => void;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
let { countdownSeconds = 3, onRegister, onSkip }: Props = $props();
|
|
11
|
+
|
|
12
|
+
let countdown = $state(countdownSeconds);
|
|
13
|
+
let failed = $state(false);
|
|
14
|
+
let registering = $state(false);
|
|
15
|
+
|
|
16
|
+
let interval = setInterval(() => {
|
|
17
|
+
countdown -= 1;
|
|
18
|
+
if (countdown <= 0) {
|
|
19
|
+
clearInterval(interval);
|
|
20
|
+
triggerRegistration();
|
|
21
|
+
}
|
|
22
|
+
}, 1000);
|
|
23
|
+
|
|
24
|
+
onDestroy(() => {
|
|
25
|
+
if (interval) clearInterval(interval);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
async function triggerRegistration() {
|
|
29
|
+
if (registering) return;
|
|
30
|
+
registering = true;
|
|
31
|
+
try {
|
|
32
|
+
await onRegister();
|
|
33
|
+
} catch {
|
|
34
|
+
failed = true;
|
|
35
|
+
} finally {
|
|
36
|
+
registering = false;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
let circumference = 2 * Math.PI * 40;
|
|
41
|
+
let dashOffset = $derived(circumference * (1 - countdown / countdownSeconds));
|
|
42
|
+
</script>
|
|
43
|
+
|
|
44
|
+
<div class="anahtar-passkey-prompt">
|
|
45
|
+
<div class="anahtar-passkey-ring">
|
|
46
|
+
<svg viewBox="0 0 100 100" class="anahtar-passkey-ring-svg">
|
|
47
|
+
<circle cx="50" cy="50" r="40" fill="none" stroke="var(--anahtar-border, #d1d5db)" stroke-width="4" />
|
|
48
|
+
<circle
|
|
49
|
+
cx="50" cy="50" r="40"
|
|
50
|
+
fill="none"
|
|
51
|
+
stroke="var(--anahtar-primary, #3b82f6)"
|
|
52
|
+
stroke-width="4"
|
|
53
|
+
stroke-linecap="round"
|
|
54
|
+
stroke-dasharray={circumference}
|
|
55
|
+
stroke-dashoffset={dashOffset}
|
|
56
|
+
class="anahtar-passkey-progress"
|
|
57
|
+
/>
|
|
58
|
+
</svg>
|
|
59
|
+
<div class="anahtar-passkey-icon">
|
|
60
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" class="anahtar-key-pulse">
|
|
61
|
+
<circle cx="7.5" cy="15.5" r="5.5"/>
|
|
62
|
+
<path d="m11.5 12 4-4"/>
|
|
63
|
+
<path d="m15 7 2 2"/>
|
|
64
|
+
<path d="m17.5 4.5 2 2"/>
|
|
65
|
+
</svg>
|
|
66
|
+
</div>
|
|
67
|
+
</div>
|
|
68
|
+
|
|
69
|
+
{#if !failed}
|
|
70
|
+
<p class="anahtar-passkey-title">Making you a passkey</p>
|
|
71
|
+
<p class="anahtar-passkey-subtitle">for easier login</p>
|
|
72
|
+
<button onclick={onSkip} class="anahtar-passkey-skip">Skip</button>
|
|
73
|
+
{:else}
|
|
74
|
+
<p class="anahtar-passkey-title">Set up a passkey?</p>
|
|
75
|
+
<button onclick={triggerRegistration} class="anahtar-passkey-add" disabled={registering}>
|
|
76
|
+
Add passkey
|
|
77
|
+
</button>
|
|
78
|
+
<button onclick={onSkip} class="anahtar-passkey-skip">Maybe later</button>
|
|
79
|
+
{/if}
|
|
80
|
+
</div>
|
|
81
|
+
|
|
82
|
+
<style>
|
|
83
|
+
.anahtar-passkey-prompt {
|
|
84
|
+
display: flex;
|
|
85
|
+
flex-direction: column;
|
|
86
|
+
align-items: center;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
.anahtar-passkey-ring {
|
|
90
|
+
position: relative;
|
|
91
|
+
width: 7rem;
|
|
92
|
+
height: 7rem;
|
|
93
|
+
margin-bottom: 1.5rem;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
.anahtar-passkey-ring-svg {
|
|
97
|
+
width: 100%;
|
|
98
|
+
height: 100%;
|
|
99
|
+
transform: rotate(-90deg);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
.anahtar-passkey-progress {
|
|
103
|
+
transition: stroke-dashoffset 1s linear;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
.anahtar-passkey-icon {
|
|
107
|
+
position: absolute;
|
|
108
|
+
inset: 0;
|
|
109
|
+
display: flex;
|
|
110
|
+
align-items: center;
|
|
111
|
+
justify-content: center;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
@keyframes key-pulse {
|
|
115
|
+
0%, 100% { filter: drop-shadow(0 0 0 transparent); opacity: 0.8; }
|
|
116
|
+
50% { filter: drop-shadow(0 0 8px var(--anahtar-primary, #3b82f6)); opacity: 1; }
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
.anahtar-key-pulse {
|
|
120
|
+
animation: key-pulse 2s ease-in-out infinite;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
.anahtar-passkey-title {
|
|
124
|
+
font-size: 0.875rem;
|
|
125
|
+
font-weight: 500;
|
|
126
|
+
margin-bottom: 0.25rem;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
.anahtar-passkey-subtitle {
|
|
130
|
+
font-size: 0.875rem;
|
|
131
|
+
opacity: 0.6;
|
|
132
|
+
margin-bottom: 1.5rem;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
.anahtar-passkey-add {
|
|
136
|
+
width: 100%;
|
|
137
|
+
padding: 0.5rem;
|
|
138
|
+
font-size: 0.875rem;
|
|
139
|
+
font-weight: 500;
|
|
140
|
+
border-radius: 0.375rem;
|
|
141
|
+
background: var(--anahtar-primary, #3b82f6);
|
|
142
|
+
color: var(--anahtar-primary-fg, #fff);
|
|
143
|
+
border: none;
|
|
144
|
+
cursor: pointer;
|
|
145
|
+
margin-bottom: 0.75rem;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
.anahtar-passkey-add:hover {
|
|
149
|
+
opacity: 0.9;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
.anahtar-passkey-add:disabled {
|
|
153
|
+
opacity: 0.5;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
.anahtar-passkey-skip {
|
|
157
|
+
font-size: 0.75rem;
|
|
158
|
+
opacity: 0.6;
|
|
159
|
+
background: none;
|
|
160
|
+
border: none;
|
|
161
|
+
cursor: pointer;
|
|
162
|
+
color: inherit;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
.anahtar-passkey-skip:hover {
|
|
166
|
+
opacity: 1;
|
|
167
|
+
}
|
|
168
|
+
</style>
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
interface Props {
|
|
2
|
+
countdownSeconds?: number;
|
|
3
|
+
onRegister: () => Promise<void>;
|
|
4
|
+
onSkip: () => void;
|
|
5
|
+
}
|
|
6
|
+
declare const PasskeyPrompt: import("svelte").Component<Props, {}, "">;
|
|
7
|
+
type PasskeyPrompt = ReturnType<typeof PasskeyPrompt>;
|
|
8
|
+
export default PasskeyPrompt;
|
package/dist/config.d.ts
ADDED
package/dist/config.js
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
const DEFAULTS = {
|
|
2
|
+
tablePrefix: 'auth_',
|
|
3
|
+
cookie: 'session',
|
|
4
|
+
sessionDuration: 30 * 24 * 60 * 60 * 1000,
|
|
5
|
+
otpExpiry: 30 * 60 * 1000,
|
|
6
|
+
otpLength: 5,
|
|
7
|
+
otpMaxAttempts: 5,
|
|
8
|
+
rpName: 'anahtar',
|
|
9
|
+
};
|
|
10
|
+
export function resolveConfig(config) {
|
|
11
|
+
return {
|
|
12
|
+
...DEFAULTS,
|
|
13
|
+
...config,
|
|
14
|
+
};
|
|
15
|
+
}
|
package/dist/db/d1.d.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { AuthDB } from '../types.js';
|
|
2
|
+
interface D1Database {
|
|
3
|
+
prepare(sql: string): D1PreparedStatement;
|
|
4
|
+
exec(sql: string): Promise<unknown>;
|
|
5
|
+
}
|
|
6
|
+
interface D1PreparedStatement {
|
|
7
|
+
bind(...values: unknown[]): D1PreparedStatement;
|
|
8
|
+
first<T = Record<string, unknown>>(): Promise<T | null>;
|
|
9
|
+
all<T = Record<string, unknown>>(): Promise<{
|
|
10
|
+
results: T[];
|
|
11
|
+
}>;
|
|
12
|
+
run(): Promise<{
|
|
13
|
+
meta: {
|
|
14
|
+
changes: number;
|
|
15
|
+
};
|
|
16
|
+
}>;
|
|
17
|
+
}
|
|
18
|
+
interface D1AdapterOptions {
|
|
19
|
+
tablePrefix?: string;
|
|
20
|
+
}
|
|
21
|
+
export declare function d1Adapter(db: D1Database, options?: D1AdapterOptions): AuthDB;
|
|
22
|
+
export {};
|
package/dist/db/d1.js
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
export function d1Adapter(db, options = {}) {
|
|
2
|
+
const p = options.tablePrefix ?? 'auth_';
|
|
3
|
+
const t = {
|
|
4
|
+
users: `${p}users`,
|
|
5
|
+
sessions: `${p}sessions`,
|
|
6
|
+
otpCodes: `${p}otp_codes`,
|
|
7
|
+
passkeys: `${p}passkeys`,
|
|
8
|
+
challenges: `${p}challenges`
|
|
9
|
+
};
|
|
10
|
+
return {
|
|
11
|
+
async init() {
|
|
12
|
+
await db
|
|
13
|
+
.prepare(`CREATE TABLE IF NOT EXISTS ${t.users} (
|
|
14
|
+
id TEXT PRIMARY KEY, email TEXT UNIQUE NOT NULL,
|
|
15
|
+
skip_passkey_prompt INTEGER DEFAULT 0, created_at INTEGER DEFAULT (unixepoch())
|
|
16
|
+
)`)
|
|
17
|
+
.run();
|
|
18
|
+
await db
|
|
19
|
+
.prepare(`CREATE TABLE IF NOT EXISTS ${t.sessions} (
|
|
20
|
+
id TEXT PRIMARY KEY, user_id TEXT NOT NULL REFERENCES ${t.users}(id),
|
|
21
|
+
expires_at INTEGER NOT NULL, created_at INTEGER DEFAULT (unixepoch())
|
|
22
|
+
)`)
|
|
23
|
+
.run();
|
|
24
|
+
await db
|
|
25
|
+
.prepare(`CREATE TABLE IF NOT EXISTS ${t.otpCodes} (
|
|
26
|
+
id TEXT PRIMARY KEY, email TEXT NOT NULL, code TEXT NOT NULL,
|
|
27
|
+
attempts INTEGER NOT NULL DEFAULT 0, expires_at INTEGER NOT NULL,
|
|
28
|
+
created_at INTEGER DEFAULT (unixepoch())
|
|
29
|
+
)`)
|
|
30
|
+
.run();
|
|
31
|
+
await db
|
|
32
|
+
.prepare(`CREATE TABLE IF NOT EXISTS ${t.passkeys} (
|
|
33
|
+
id TEXT PRIMARY KEY, user_id TEXT NOT NULL REFERENCES ${t.users}(id),
|
|
34
|
+
credential_id TEXT UNIQUE NOT NULL, public_key BLOB NOT NULL,
|
|
35
|
+
counter INTEGER NOT NULL DEFAULT 0, transports TEXT, name TEXT,
|
|
36
|
+
created_at INTEGER DEFAULT (unixepoch())
|
|
37
|
+
)`)
|
|
38
|
+
.run();
|
|
39
|
+
await db
|
|
40
|
+
.prepare(`CREATE TABLE IF NOT EXISTS ${t.challenges} (
|
|
41
|
+
challenge TEXT PRIMARY KEY, user_id TEXT NOT NULL,
|
|
42
|
+
expires_at INTEGER NOT NULL, created_at INTEGER DEFAULT (unixepoch())
|
|
43
|
+
)`)
|
|
44
|
+
.run();
|
|
45
|
+
},
|
|
46
|
+
async getUserByEmail(email) {
|
|
47
|
+
const row = await db
|
|
48
|
+
.prepare(`SELECT id, email, skip_passkey_prompt, created_at FROM ${t.users} WHERE email = ?`)
|
|
49
|
+
.bind(email)
|
|
50
|
+
.first();
|
|
51
|
+
if (!row)
|
|
52
|
+
return null;
|
|
53
|
+
return {
|
|
54
|
+
id: row.id,
|
|
55
|
+
email: row.email,
|
|
56
|
+
skipPasskeyPrompt: row.skip_passkey_prompt === 1,
|
|
57
|
+
createdAt: row.created_at
|
|
58
|
+
};
|
|
59
|
+
},
|
|
60
|
+
async createUser(email) {
|
|
61
|
+
const id = crypto.randomUUID();
|
|
62
|
+
await db.prepare(`INSERT INTO ${t.users} (id, email) VALUES (?, ?)`).bind(id, email).run();
|
|
63
|
+
return {
|
|
64
|
+
id,
|
|
65
|
+
email,
|
|
66
|
+
skipPasskeyPrompt: false,
|
|
67
|
+
createdAt: Math.floor(Date.now() / 1000)
|
|
68
|
+
};
|
|
69
|
+
},
|
|
70
|
+
async setSkipPasskeyPrompt(userId, skip) {
|
|
71
|
+
await db
|
|
72
|
+
.prepare(`UPDATE ${t.users} SET skip_passkey_prompt = ? WHERE id = ?`)
|
|
73
|
+
.bind(skip ? 1 : 0, userId)
|
|
74
|
+
.run();
|
|
75
|
+
},
|
|
76
|
+
async createSession(tokenHash, userId, expiresAt) {
|
|
77
|
+
await db
|
|
78
|
+
.prepare(`INSERT INTO ${t.sessions} (id, user_id, expires_at) VALUES (?, ?, ?)`)
|
|
79
|
+
.bind(tokenHash, userId, expiresAt)
|
|
80
|
+
.run();
|
|
81
|
+
},
|
|
82
|
+
async getSession(tokenHash) {
|
|
83
|
+
const row = await db
|
|
84
|
+
.prepare(`SELECT s.id, s.user_id, s.expires_at, u.email
|
|
85
|
+
FROM ${t.sessions} s
|
|
86
|
+
JOIN ${t.users} u ON u.id = s.user_id
|
|
87
|
+
WHERE s.id = ?`)
|
|
88
|
+
.bind(tokenHash)
|
|
89
|
+
.first();
|
|
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
|
+
async deleteSession(tokenHash) {
|
|
100
|
+
await db.prepare(`DELETE FROM ${t.sessions} WHERE id = ?`).bind(tokenHash).run();
|
|
101
|
+
},
|
|
102
|
+
async storeOTP(email, id, code, expiresAt) {
|
|
103
|
+
await db
|
|
104
|
+
.prepare(`INSERT INTO ${t.otpCodes} (id, email, code, expires_at) VALUES (?, ?, ?, ?)`)
|
|
105
|
+
.bind(id, email, code, expiresAt)
|
|
106
|
+
.run();
|
|
107
|
+
},
|
|
108
|
+
async getLatestOTP(email) {
|
|
109
|
+
const row = await db
|
|
110
|
+
.prepare(`SELECT id, email, code, attempts, expires_at FROM ${t.otpCodes} WHERE email = ? ORDER BY created_at DESC LIMIT 1`)
|
|
111
|
+
.bind(email)
|
|
112
|
+
.first();
|
|
113
|
+
if (!row)
|
|
114
|
+
return null;
|
|
115
|
+
return {
|
|
116
|
+
id: row.id,
|
|
117
|
+
email: row.email,
|
|
118
|
+
code: row.code,
|
|
119
|
+
attempts: row.attempts,
|
|
120
|
+
expiresAt: row.expires_at
|
|
121
|
+
};
|
|
122
|
+
},
|
|
123
|
+
async updateOTPAttempts(id, attempts) {
|
|
124
|
+
await db.prepare(`UPDATE ${t.otpCodes} SET attempts = ? WHERE id = ?`).bind(attempts, id).run();
|
|
125
|
+
},
|
|
126
|
+
async deleteOTP(id) {
|
|
127
|
+
await db.prepare(`DELETE FROM ${t.otpCodes} WHERE id = ?`).bind(id).run();
|
|
128
|
+
},
|
|
129
|
+
async deleteOTPsForEmail(email) {
|
|
130
|
+
await db.prepare(`DELETE FROM ${t.otpCodes} WHERE email = ?`).bind(email).run();
|
|
131
|
+
},
|
|
132
|
+
async storeChallenge(challenge, userId, expiresAt) {
|
|
133
|
+
await db.prepare(`DELETE FROM ${t.challenges} WHERE expires_at < ?`).bind(Date.now()).run();
|
|
134
|
+
await db
|
|
135
|
+
.prepare(`INSERT INTO ${t.challenges} (challenge, user_id, expires_at) VALUES (?, ?, ?)`)
|
|
136
|
+
.bind(challenge, userId, expiresAt)
|
|
137
|
+
.run();
|
|
138
|
+
},
|
|
139
|
+
async consumeChallenge(challenge) {
|
|
140
|
+
const row = await db
|
|
141
|
+
.prepare(`SELECT user_id, expires_at FROM ${t.challenges} WHERE challenge = ?`)
|
|
142
|
+
.bind(challenge)
|
|
143
|
+
.first();
|
|
144
|
+
if (!row)
|
|
145
|
+
return null;
|
|
146
|
+
await db.prepare(`DELETE FROM ${t.challenges} WHERE challenge = ?`).bind(challenge).run();
|
|
147
|
+
if (row.expires_at < Date.now())
|
|
148
|
+
return null;
|
|
149
|
+
return { userId: row.user_id };
|
|
150
|
+
},
|
|
151
|
+
async getPasskeyByCredentialId(credentialId) {
|
|
152
|
+
const row = await db
|
|
153
|
+
.prepare(`SELECT p.id, p.user_id, p.credential_id, p.public_key, p.counter, p.transports, p.name, p.created_at, u.email
|
|
154
|
+
FROM ${t.passkeys} p
|
|
155
|
+
JOIN ${t.users} u ON u.id = p.user_id
|
|
156
|
+
WHERE p.credential_id = ?`)
|
|
157
|
+
.bind(credentialId)
|
|
158
|
+
.first();
|
|
159
|
+
if (!row)
|
|
160
|
+
return null;
|
|
161
|
+
return {
|
|
162
|
+
id: row.id,
|
|
163
|
+
userId: row.user_id,
|
|
164
|
+
credentialId: row.credential_id,
|
|
165
|
+
publicKey: new Uint8Array(row.public_key),
|
|
166
|
+
counter: row.counter,
|
|
167
|
+
transports: row.transports,
|
|
168
|
+
name: row.name,
|
|
169
|
+
createdAt: row.created_at,
|
|
170
|
+
email: row.email
|
|
171
|
+
};
|
|
172
|
+
},
|
|
173
|
+
async getUserPasskeys(userId) {
|
|
174
|
+
const { results } = await db
|
|
175
|
+
.prepare(`SELECT id, credential_id, public_key, counter, transports, name, created_at FROM ${t.passkeys} WHERE user_id = ?`)
|
|
176
|
+
.bind(userId)
|
|
177
|
+
.all();
|
|
178
|
+
return results.map((row) => ({
|
|
179
|
+
id: row.id,
|
|
180
|
+
credentialId: row.credential_id,
|
|
181
|
+
publicKey: new Uint8Array(row.public_key),
|
|
182
|
+
counter: row.counter,
|
|
183
|
+
transports: row.transports,
|
|
184
|
+
name: row.name,
|
|
185
|
+
createdAt: row.created_at
|
|
186
|
+
}));
|
|
187
|
+
},
|
|
188
|
+
async storePasskey(passkey) {
|
|
189
|
+
await db
|
|
190
|
+
.prepare(`INSERT INTO ${t.passkeys} (id, user_id, credential_id, public_key, counter, transports, name) VALUES (?, ?, ?, ?, ?, ?, ?)`)
|
|
191
|
+
.bind(passkey.id, passkey.userId, passkey.credentialId, passkey.publicKey, passkey.counter, passkey.transports, passkey.name)
|
|
192
|
+
.run();
|
|
193
|
+
},
|
|
194
|
+
async updatePasskeyCounter(id, counter) {
|
|
195
|
+
await db.prepare(`UPDATE ${t.passkeys} SET counter = ? WHERE id = ?`).bind(counter, id).run();
|
|
196
|
+
},
|
|
197
|
+
async deletePasskey(id, userId) {
|
|
198
|
+
const result = await db.prepare(`DELETE FROM ${t.passkeys} WHERE id = ? AND user_id = ?`).bind(id, userId).run();
|
|
199
|
+
return result.meta.changes > 0;
|
|
200
|
+
}
|
|
201
|
+
};
|
|
202
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { AuthDB } from '../types.js';
|
|
2
|
+
interface PgPool {
|
|
3
|
+
query(text: string, values?: unknown[]): Promise<{
|
|
4
|
+
rows: Record<string, unknown>[];
|
|
5
|
+
rowCount: number | null;
|
|
6
|
+
}>;
|
|
7
|
+
}
|
|
8
|
+
interface PostgresAdapterOptions {
|
|
9
|
+
tablePrefix?: string;
|
|
10
|
+
}
|
|
11
|
+
export declare function postgresAdapter(pool: PgPool, options?: PostgresAdapterOptions): AuthDB;
|
|
12
|
+
export {};
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
export function postgresAdapter(pool, 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
|
+
async function queryOne(text, values) {
|
|
12
|
+
const { rows } = await pool.query(text, values);
|
|
13
|
+
return rows[0];
|
|
14
|
+
}
|
|
15
|
+
async function queryAll(text, values) {
|
|
16
|
+
const { rows } = await pool.query(text, values);
|
|
17
|
+
return rows;
|
|
18
|
+
}
|
|
19
|
+
return {
|
|
20
|
+
async init() {
|
|
21
|
+
await pool.query(`
|
|
22
|
+
CREATE TABLE IF NOT EXISTS ${t.users} (
|
|
23
|
+
id TEXT PRIMARY KEY,
|
|
24
|
+
email TEXT UNIQUE NOT NULL,
|
|
25
|
+
skip_passkey_prompt BOOLEAN DEFAULT FALSE,
|
|
26
|
+
created_at TIMESTAMPTZ DEFAULT NOW()
|
|
27
|
+
);
|
|
28
|
+
CREATE TABLE IF NOT EXISTS ${t.sessions} (
|
|
29
|
+
id TEXT PRIMARY KEY,
|
|
30
|
+
user_id TEXT NOT NULL REFERENCES ${t.users}(id),
|
|
31
|
+
expires_at BIGINT NOT NULL,
|
|
32
|
+
created_at TIMESTAMPTZ DEFAULT NOW()
|
|
33
|
+
);
|
|
34
|
+
CREATE TABLE IF NOT EXISTS ${t.otpCodes} (
|
|
35
|
+
id TEXT PRIMARY KEY,
|
|
36
|
+
email TEXT NOT NULL,
|
|
37
|
+
code TEXT NOT NULL,
|
|
38
|
+
attempts INTEGER NOT NULL DEFAULT 0,
|
|
39
|
+
expires_at BIGINT NOT NULL,
|
|
40
|
+
created_at TIMESTAMPTZ DEFAULT NOW()
|
|
41
|
+
);
|
|
42
|
+
CREATE TABLE IF NOT EXISTS ${t.passkeys} (
|
|
43
|
+
id TEXT PRIMARY KEY,
|
|
44
|
+
user_id TEXT NOT NULL REFERENCES ${t.users}(id),
|
|
45
|
+
credential_id TEXT UNIQUE NOT NULL,
|
|
46
|
+
public_key BYTEA NOT NULL,
|
|
47
|
+
counter INTEGER NOT NULL DEFAULT 0,
|
|
48
|
+
transports TEXT,
|
|
49
|
+
name TEXT,
|
|
50
|
+
created_at TIMESTAMPTZ DEFAULT NOW()
|
|
51
|
+
);
|
|
52
|
+
CREATE TABLE IF NOT EXISTS ${t.challenges} (
|
|
53
|
+
challenge TEXT PRIMARY KEY,
|
|
54
|
+
user_id TEXT NOT NULL,
|
|
55
|
+
expires_at BIGINT NOT NULL,
|
|
56
|
+
created_at TIMESTAMPTZ DEFAULT NOW()
|
|
57
|
+
);
|
|
58
|
+
`);
|
|
59
|
+
},
|
|
60
|
+
async getUserByEmail(email) {
|
|
61
|
+
const row = await queryOne(`SELECT id, email, skip_passkey_prompt, created_at FROM ${t.users} WHERE email = $1`, [email]);
|
|
62
|
+
if (!row)
|
|
63
|
+
return null;
|
|
64
|
+
return {
|
|
65
|
+
id: row.id,
|
|
66
|
+
email: row.email,
|
|
67
|
+
skipPasskeyPrompt: row.skip_passkey_prompt,
|
|
68
|
+
createdAt: new Date(row.created_at).getTime()
|
|
69
|
+
};
|
|
70
|
+
},
|
|
71
|
+
async createUser(email) {
|
|
72
|
+
const id = randomUUID();
|
|
73
|
+
await pool.query(`INSERT INTO ${t.users} (id, email) VALUES ($1, $2)`, [id, email]);
|
|
74
|
+
return {
|
|
75
|
+
id,
|
|
76
|
+
email,
|
|
77
|
+
skipPasskeyPrompt: false,
|
|
78
|
+
createdAt: Date.now()
|
|
79
|
+
};
|
|
80
|
+
},
|
|
81
|
+
async setSkipPasskeyPrompt(userId, skip) {
|
|
82
|
+
await pool.query(`UPDATE ${t.users} SET skip_passkey_prompt = $1 WHERE id = $2`, [skip, userId]);
|
|
83
|
+
},
|
|
84
|
+
async createSession(tokenHash, userId, expiresAt) {
|
|
85
|
+
await pool.query(`INSERT INTO ${t.sessions} (id, user_id, expires_at) VALUES ($1, $2, $3)`, [
|
|
86
|
+
tokenHash,
|
|
87
|
+
userId,
|
|
88
|
+
expiresAt
|
|
89
|
+
]);
|
|
90
|
+
},
|
|
91
|
+
async getSession(tokenHash) {
|
|
92
|
+
const row = await queryOne(`SELECT s.id, s.user_id, s.expires_at, u.email
|
|
93
|
+
FROM ${t.sessions} s
|
|
94
|
+
JOIN ${t.users} u ON u.id = s.user_id
|
|
95
|
+
WHERE s.id = $1`, [tokenHash]);
|
|
96
|
+
if (!row)
|
|
97
|
+
return null;
|
|
98
|
+
return {
|
|
99
|
+
id: row.id,
|
|
100
|
+
userId: row.user_id,
|
|
101
|
+
expiresAt: Number(row.expires_at),
|
|
102
|
+
email: row.email
|
|
103
|
+
};
|
|
104
|
+
},
|
|
105
|
+
async deleteSession(tokenHash) {
|
|
106
|
+
await pool.query(`DELETE FROM ${t.sessions} WHERE id = $1`, [tokenHash]);
|
|
107
|
+
},
|
|
108
|
+
async storeOTP(email, id, code, expiresAt) {
|
|
109
|
+
await pool.query(`INSERT INTO ${t.otpCodes} (id, email, code, expires_at) VALUES ($1, $2, $3, $4)`, [
|
|
110
|
+
id,
|
|
111
|
+
email,
|
|
112
|
+
code,
|
|
113
|
+
expiresAt
|
|
114
|
+
]);
|
|
115
|
+
},
|
|
116
|
+
async getLatestOTP(email) {
|
|
117
|
+
const row = await queryOne(`SELECT id, email, code, attempts, expires_at FROM ${t.otpCodes} WHERE email = $1 ORDER BY created_at DESC LIMIT 1`, [email]);
|
|
118
|
+
if (!row)
|
|
119
|
+
return null;
|
|
120
|
+
return {
|
|
121
|
+
id: row.id,
|
|
122
|
+
email: row.email,
|
|
123
|
+
code: row.code,
|
|
124
|
+
attempts: row.attempts,
|
|
125
|
+
expiresAt: Number(row.expires_at)
|
|
126
|
+
};
|
|
127
|
+
},
|
|
128
|
+
async updateOTPAttempts(id, attempts) {
|
|
129
|
+
await pool.query(`UPDATE ${t.otpCodes} SET attempts = $1 WHERE id = $2`, [attempts, id]);
|
|
130
|
+
},
|
|
131
|
+
async deleteOTP(id) {
|
|
132
|
+
await pool.query(`DELETE FROM ${t.otpCodes} WHERE id = $1`, [id]);
|
|
133
|
+
},
|
|
134
|
+
async deleteOTPsForEmail(email) {
|
|
135
|
+
await pool.query(`DELETE FROM ${t.otpCodes} WHERE email = $1`, [email]);
|
|
136
|
+
},
|
|
137
|
+
async storeChallenge(challenge, userId, expiresAt) {
|
|
138
|
+
await pool.query(`DELETE FROM ${t.challenges} WHERE expires_at < $1`, [Date.now()]);
|
|
139
|
+
await pool.query(`INSERT INTO ${t.challenges} (challenge, user_id, expires_at) VALUES ($1, $2, $3)`, [
|
|
140
|
+
challenge,
|
|
141
|
+
userId,
|
|
142
|
+
expiresAt
|
|
143
|
+
]);
|
|
144
|
+
},
|
|
145
|
+
async consumeChallenge(challenge) {
|
|
146
|
+
const row = await queryOne(`SELECT user_id, expires_at FROM ${t.challenges} WHERE challenge = $1`, [challenge]);
|
|
147
|
+
if (!row)
|
|
148
|
+
return null;
|
|
149
|
+
await pool.query(`DELETE FROM ${t.challenges} WHERE challenge = $1`, [challenge]);
|
|
150
|
+
if (Number(row.expires_at) < Date.now())
|
|
151
|
+
return null;
|
|
152
|
+
return { userId: row.user_id };
|
|
153
|
+
},
|
|
154
|
+
async getPasskeyByCredentialId(credentialId) {
|
|
155
|
+
const row = await queryOne(`SELECT p.id, p.user_id, p.credential_id, p.public_key, p.counter, p.transports, p.name, p.created_at, u.email
|
|
156
|
+
FROM ${t.passkeys} p
|
|
157
|
+
JOIN ${t.users} u ON u.id = p.user_id
|
|
158
|
+
WHERE p.credential_id = $1`, [credentialId]);
|
|
159
|
+
if (!row)
|
|
160
|
+
return null;
|
|
161
|
+
return {
|
|
162
|
+
id: row.id,
|
|
163
|
+
userId: row.user_id,
|
|
164
|
+
credentialId: row.credential_id,
|
|
165
|
+
publicKey: new Uint8Array(row.public_key),
|
|
166
|
+
counter: row.counter,
|
|
167
|
+
transports: row.transports,
|
|
168
|
+
name: row.name,
|
|
169
|
+
createdAt: new Date(row.created_at).getTime(),
|
|
170
|
+
email: row.email
|
|
171
|
+
};
|
|
172
|
+
},
|
|
173
|
+
async getUserPasskeys(userId) {
|
|
174
|
+
const rows = await queryAll(`SELECT id, credential_id, public_key, counter, transports, name, created_at FROM ${t.passkeys} WHERE user_id = $1`, [userId]);
|
|
175
|
+
return rows.map((row) => ({
|
|
176
|
+
id: row.id,
|
|
177
|
+
credentialId: row.credential_id,
|
|
178
|
+
publicKey: new Uint8Array(row.public_key),
|
|
179
|
+
counter: row.counter,
|
|
180
|
+
transports: row.transports,
|
|
181
|
+
name: row.name,
|
|
182
|
+
createdAt: new Date(row.created_at).getTime()
|
|
183
|
+
}));
|
|
184
|
+
},
|
|
185
|
+
async storePasskey(passkey) {
|
|
186
|
+
await pool.query(`INSERT INTO ${t.passkeys} (id, user_id, credential_id, public_key, counter, transports, name) VALUES ($1, $2, $3, $4, $5, $6, $7)`, [
|
|
187
|
+
passkey.id,
|
|
188
|
+
passkey.userId,
|
|
189
|
+
passkey.credentialId,
|
|
190
|
+
Buffer.from(passkey.publicKey),
|
|
191
|
+
passkey.counter,
|
|
192
|
+
passkey.transports,
|
|
193
|
+
passkey.name
|
|
194
|
+
]);
|
|
195
|
+
},
|
|
196
|
+
async updatePasskeyCounter(id, counter) {
|
|
197
|
+
await pool.query(`UPDATE ${t.passkeys} SET counter = $1 WHERE id = $2`, [counter, id]);
|
|
198
|
+
},
|
|
199
|
+
async deletePasskey(id, userId) {
|
|
200
|
+
const result = await pool.query(`DELETE FROM ${t.passkeys} WHERE id = $1 AND user_id = $2`, [id, userId]);
|
|
201
|
+
return (result.rowCount ?? 0) > 0;
|
|
202
|
+
}
|
|
203
|
+
};
|
|
204
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type Database from 'better-sqlite3';
|
|
2
|
+
import type { AuthDB } from '../types.js';
|
|
3
|
+
interface SqliteAdapterOptions {
|
|
4
|
+
tablePrefix?: string;
|
|
5
|
+
}
|
|
6
|
+
export declare function sqliteAdapter(db: Database.Database, options?: SqliteAdapterOptions): AuthDB;
|
|
7
|
+
export {};
|