@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.
@@ -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
+ }
@@ -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
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,10 @@
1
+ {
2
+ "extends": "../../tsconfig.base.json",
3
+ "compilerOptions": {
4
+ "rootDir": "src",
5
+ "outDir": "dist",
6
+ "noEmit": true,
7
+ "types": ["@cloudflare/workers-types"]
8
+ },
9
+ "include": ["src/**/*.ts"]
10
+ }