@pya-platform/auth 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/CHANGELOG.md +14 -0
- package/package.json +38 -0
- package/src/env.ts +57 -0
- package/src/identity-link.ts +64 -0
- package/src/index.ts +46 -0
- package/src/log.ts +27 -0
- package/src/migrations/0001_users_sessions.sql +70 -0
- package/src/migrations/0002_passkeys.sql +17 -0
- package/src/migrations/0003_recovery_codes.sql +24 -0
- package/src/oauth-callback.ts +102 -0
- package/src/providers/apple.ts +12 -0
- package/src/providers/facebook.ts +12 -0
- package/src/providers/google.ts +117 -0
- package/src/recovery-codes.ts +148 -0
- package/src/routes/dev-bypass.ts +109 -0
- package/src/routes/oauth.ts +114 -0
- package/src/routes/passwordless.ts +419 -0
- package/src/session.ts +109 -0
- package/src/store/otp-store.ts +156 -0
- package/src/store/passkey-store.ts +117 -0
- package/src/store/session-store.ts +50 -0
- package/src/webauthn.ts +112 -0
- package/tsconfig.json +10 -0
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
export interface PasskeyRow {
|
|
2
|
+
readonly credentialId: string
|
|
3
|
+
readonly userId: string
|
|
4
|
+
readonly publicKey: string
|
|
5
|
+
readonly signCount: number
|
|
6
|
+
readonly transports: ReadonlyArray<string>
|
|
7
|
+
readonly label: string | undefined
|
|
8
|
+
readonly createdAt: number
|
|
9
|
+
readonly lastUsedAt: number
|
|
10
|
+
readonly backupEligible: boolean
|
|
11
|
+
readonly backupState: boolean
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
interface RawRow {
|
|
15
|
+
credential_id: string
|
|
16
|
+
user_id: string
|
|
17
|
+
public_key: string
|
|
18
|
+
sign_count: number
|
|
19
|
+
transports: string | null
|
|
20
|
+
label: string | null
|
|
21
|
+
created_at: number
|
|
22
|
+
last_used_at: number
|
|
23
|
+
backup_eligible: number
|
|
24
|
+
backup_state: number
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const fromRow = (r: RawRow): PasskeyRow => ({
|
|
28
|
+
credentialId: r.credential_id,
|
|
29
|
+
userId: r.user_id,
|
|
30
|
+
publicKey: r.public_key,
|
|
31
|
+
signCount: r.sign_count,
|
|
32
|
+
transports: r.transports === null ? [] : (JSON.parse(r.transports) as string[]),
|
|
33
|
+
label: r.label ?? undefined,
|
|
34
|
+
createdAt: r.created_at,
|
|
35
|
+
lastUsedAt: r.last_used_at,
|
|
36
|
+
backupEligible: r.backup_eligible === 1,
|
|
37
|
+
backupState: r.backup_state === 1,
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
export const findPasskeysByUser = async (
|
|
41
|
+
db: D1Database,
|
|
42
|
+
userId: string,
|
|
43
|
+
): Promise<ReadonlyArray<PasskeyRow>> => {
|
|
44
|
+
const { results } = await db
|
|
45
|
+
.prepare('SELECT * FROM passkeys WHERE user_id = ?')
|
|
46
|
+
.bind(userId)
|
|
47
|
+
.all<RawRow>()
|
|
48
|
+
return results.map(fromRow)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export const findPasskeyByCredentialId = async (
|
|
52
|
+
db: D1Database,
|
|
53
|
+
credentialId: string,
|
|
54
|
+
): Promise<PasskeyRow | undefined> => {
|
|
55
|
+
const r = await db
|
|
56
|
+
.prepare('SELECT * FROM passkeys WHERE credential_id = ?')
|
|
57
|
+
.bind(credentialId)
|
|
58
|
+
.first<RawRow>()
|
|
59
|
+
return r === null ? undefined : fromRow(r)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export const insertPasskey = async (
|
|
63
|
+
db: D1Database,
|
|
64
|
+
row: Omit<PasskeyRow, 'createdAt' | 'lastUsedAt'>,
|
|
65
|
+
): Promise<void> => {
|
|
66
|
+
const now = Math.floor(Date.now() / 1000)
|
|
67
|
+
await db
|
|
68
|
+
.prepare(
|
|
69
|
+
`INSERT INTO passkeys
|
|
70
|
+
(credential_id, user_id, public_key, sign_count, transports, label, created_at, last_used_at, backup_eligible, backup_state)
|
|
71
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
72
|
+
)
|
|
73
|
+
.bind(
|
|
74
|
+
row.credentialId,
|
|
75
|
+
row.userId,
|
|
76
|
+
row.publicKey,
|
|
77
|
+
row.signCount,
|
|
78
|
+
JSON.stringify(row.transports),
|
|
79
|
+
row.label ?? null,
|
|
80
|
+
now,
|
|
81
|
+
now,
|
|
82
|
+
row.backupEligible ? 1 : 0,
|
|
83
|
+
row.backupState ? 1 : 0,
|
|
84
|
+
)
|
|
85
|
+
.run()
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export const updatePasskeyUse = async (
|
|
89
|
+
db: D1Database,
|
|
90
|
+
credentialId: string,
|
|
91
|
+
newSignCount: number,
|
|
92
|
+
): Promise<void> => {
|
|
93
|
+
const now = Math.floor(Date.now() / 1000)
|
|
94
|
+
await db
|
|
95
|
+
.prepare('UPDATE passkeys SET sign_count = ?, last_used_at = ? WHERE credential_id = ?')
|
|
96
|
+
.bind(newSignCount, now, credentialId)
|
|
97
|
+
.run()
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export const deletePasskey = async (
|
|
101
|
+
db: D1Database,
|
|
102
|
+
credentialId: string,
|
|
103
|
+
userId: string,
|
|
104
|
+
): Promise<void> => {
|
|
105
|
+
await db
|
|
106
|
+
.prepare('DELETE FROM passkeys WHERE credential_id = ? AND user_id = ?')
|
|
107
|
+
.bind(credentialId, userId)
|
|
108
|
+
.run()
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export const countPasskeysByUser = async (db: D1Database, userId: string): Promise<number> => {
|
|
112
|
+
const r = await db
|
|
113
|
+
.prepare('SELECT COUNT(*) AS n FROM passkeys WHERE user_id = ?')
|
|
114
|
+
.bind(userId)
|
|
115
|
+
.first<{ n: number }>()
|
|
116
|
+
return r?.n ?? 0
|
|
117
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { type SessionRecord, SessionRecordSchema } from '@pya-platform/shared'
|
|
2
|
+
import * as v from 'valibot'
|
|
3
|
+
|
|
4
|
+
const SESSION_TTL_SEC = 60 * 60 * 24 * 30
|
|
5
|
+
const SLIDING_LAST_SEEN_MIN_SEC = 60
|
|
6
|
+
|
|
7
|
+
/** Generate an opaque session id (32 random bytes, base64url). */
|
|
8
|
+
export const newSessionId = (): string => {
|
|
9
|
+
const bytes = new Uint8Array(32)
|
|
10
|
+
crypto.getRandomValues(bytes)
|
|
11
|
+
return btoa(String.fromCharCode(...bytes))
|
|
12
|
+
.replaceAll('+', '-')
|
|
13
|
+
.replaceAll('/', '_')
|
|
14
|
+
.replaceAll('=', '')
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export const writeSession = async (
|
|
18
|
+
kv: KVNamespace,
|
|
19
|
+
sid: string,
|
|
20
|
+
record: SessionRecord,
|
|
21
|
+
): Promise<void> => {
|
|
22
|
+
await kv.put(`sess:${sid}`, JSON.stringify(record), {
|
|
23
|
+
expirationTtl: SESSION_TTL_SEC,
|
|
24
|
+
})
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export const readSession = async (
|
|
28
|
+
kv: KVNamespace,
|
|
29
|
+
sid: string,
|
|
30
|
+
): Promise<SessionRecord | undefined> => {
|
|
31
|
+
const raw = await kv.get(`sess:${sid}`, { type: 'json' })
|
|
32
|
+
if (raw === null) return undefined
|
|
33
|
+
const parsed = v.safeParse(SessionRecordSchema, raw)
|
|
34
|
+
return parsed.success ? parsed.output : undefined
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export const touchSession = async (
|
|
38
|
+
kv: KVNamespace,
|
|
39
|
+
sid: string,
|
|
40
|
+
record: SessionRecord,
|
|
41
|
+
): Promise<void> => {
|
|
42
|
+
const now = Math.floor(Date.now() / 1000)
|
|
43
|
+
const shouldWrite = now - record.lastSeen >= SLIDING_LAST_SEEN_MIN_SEC
|
|
44
|
+
if (!shouldWrite) return
|
|
45
|
+
await writeSession(kv, sid, { ...record, lastSeen: now })
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export const deleteSession = async (kv: KVNamespace, sid: string): Promise<void> => {
|
|
49
|
+
await kv.delete(`sess:${sid}`)
|
|
50
|
+
}
|
package/src/webauthn.ts
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type AuthenticationResponseJSON,
|
|
3
|
+
type AuthenticatorTransportFuture,
|
|
4
|
+
type PublicKeyCredentialCreationOptionsJSON,
|
|
5
|
+
type PublicKeyCredentialRequestOptionsJSON,
|
|
6
|
+
type RegistrationResponseJSON,
|
|
7
|
+
type VerifiedAuthenticationResponse,
|
|
8
|
+
type VerifiedRegistrationResponse,
|
|
9
|
+
generateAuthenticationOptions,
|
|
10
|
+
generateRegistrationOptions,
|
|
11
|
+
verifyAuthenticationResponse,
|
|
12
|
+
verifyRegistrationResponse,
|
|
13
|
+
} from '@simplewebauthn/server'
|
|
14
|
+
import type { PasskeyRow } from './store/passkey-store.ts'
|
|
15
|
+
|
|
16
|
+
const rpName = 'PyaEats'
|
|
17
|
+
|
|
18
|
+
const expectedOrigins = (env: Env): ReadonlyArray<string> =>
|
|
19
|
+
(env.WEBAUTHN_ORIGINS ?? '')
|
|
20
|
+
.split(',')
|
|
21
|
+
.map((s) => s.trim())
|
|
22
|
+
.filter((s) => s.length > 0)
|
|
23
|
+
|
|
24
|
+
const rpID = (env: Env): string => env.WEBAUTHN_RP_ID ?? 'pyaeats-site.pages.dev'
|
|
25
|
+
|
|
26
|
+
const base64urlToUint8 = (s: string): Uint8Array<ArrayBuffer> => {
|
|
27
|
+
const padded = s
|
|
28
|
+
.replace(/-/g, '+')
|
|
29
|
+
.replace(/_/g, '/')
|
|
30
|
+
.padEnd(s.length + ((4 - (s.length % 4)) % 4), '=')
|
|
31
|
+
const bin = atob(padded)
|
|
32
|
+
const ab = new ArrayBuffer(bin.length)
|
|
33
|
+
const buf = new Uint8Array(ab)
|
|
34
|
+
for (let i = 0; i < bin.length; i += 1) buf[i] = bin.charCodeAt(i)
|
|
35
|
+
return buf
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export const genAuthOptions = async (
|
|
39
|
+
env: Env,
|
|
40
|
+
passkeys: ReadonlyArray<PasskeyRow>,
|
|
41
|
+
): Promise<PublicKeyCredentialRequestOptionsJSON> =>
|
|
42
|
+
generateAuthenticationOptions({
|
|
43
|
+
rpID: rpID(env),
|
|
44
|
+
allowCredentials: passkeys.map((p) => ({
|
|
45
|
+
id: p.credentialId,
|
|
46
|
+
transports: p.transports as AuthenticatorTransportFuture[],
|
|
47
|
+
})),
|
|
48
|
+
userVerification: 'preferred',
|
|
49
|
+
timeout: 60_000,
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
export const genRegOptions = async (
|
|
53
|
+
env: Env,
|
|
54
|
+
userId: string,
|
|
55
|
+
userEmail: string,
|
|
56
|
+
existing: ReadonlyArray<PasskeyRow>,
|
|
57
|
+
): Promise<PublicKeyCredentialCreationOptionsJSON> =>
|
|
58
|
+
generateRegistrationOptions({
|
|
59
|
+
rpName,
|
|
60
|
+
rpID: rpID(env),
|
|
61
|
+
userID: new TextEncoder().encode(userId) as Uint8Array<ArrayBuffer>,
|
|
62
|
+
userName: userEmail,
|
|
63
|
+
excludeCredentials: existing.map((p) => ({
|
|
64
|
+
id: p.credentialId,
|
|
65
|
+
transports: p.transports as AuthenticatorTransportFuture[],
|
|
66
|
+
})),
|
|
67
|
+
authenticatorSelection: {
|
|
68
|
+
residentKey: 'preferred',
|
|
69
|
+
userVerification: 'preferred',
|
|
70
|
+
},
|
|
71
|
+
timeout: 60_000,
|
|
72
|
+
attestationType: 'none',
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
export const verifyAuth = async (
|
|
76
|
+
env: Env,
|
|
77
|
+
assertion: AuthenticationResponseJSON,
|
|
78
|
+
expectedChallenge: string,
|
|
79
|
+
passkey: PasskeyRow,
|
|
80
|
+
): Promise<VerifiedAuthenticationResponse> =>
|
|
81
|
+
verifyAuthenticationResponse({
|
|
82
|
+
response: assertion,
|
|
83
|
+
expectedChallenge,
|
|
84
|
+
expectedOrigin: expectedOrigins(env) as string[],
|
|
85
|
+
expectedRPID: rpID(env),
|
|
86
|
+
credential: {
|
|
87
|
+
id: passkey.credentialId,
|
|
88
|
+
publicKey: base64urlToUint8(passkey.publicKey),
|
|
89
|
+
counter: passkey.signCount,
|
|
90
|
+
transports: passkey.transports as AuthenticatorTransportFuture[],
|
|
91
|
+
},
|
|
92
|
+
requireUserVerification: false,
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
export const verifyReg = async (
|
|
96
|
+
env: Env,
|
|
97
|
+
attestation: RegistrationResponseJSON,
|
|
98
|
+
expectedChallenge: string,
|
|
99
|
+
): Promise<VerifiedRegistrationResponse> =>
|
|
100
|
+
verifyRegistrationResponse({
|
|
101
|
+
response: attestation,
|
|
102
|
+
expectedChallenge,
|
|
103
|
+
expectedOrigin: expectedOrigins(env) as string[],
|
|
104
|
+
expectedRPID: rpID(env),
|
|
105
|
+
requireUserVerification: false,
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
export const uint8ToBase64url = (bytes: Uint8Array): string => {
|
|
109
|
+
let bin = ''
|
|
110
|
+
for (const b of bytes) bin += String.fromCharCode(b)
|
|
111
|
+
return btoa(bin).replaceAll('+', '-').replaceAll('/', '_').replaceAll('=', '')
|
|
112
|
+
}
|