@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,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Recovery-code lifecycle: generate, hash, store, redeem.
|
|
3
|
+
*
|
|
4
|
+
* Format on display (one per line): `XXXX-XXXX-XXXX` where X is a base32
|
|
5
|
+
* letter from the unambiguous alphabet `23456789ABCDEFGHJKMNPQRSTUVWXYZ`
|
|
6
|
+
* (no 0/O, 1/I/L). 12 chars = 60 bits of entropy. We generate 8 codes per
|
|
7
|
+
* `generate` call — past industry standard (GitHub, Google, etc).
|
|
8
|
+
*
|
|
9
|
+
* Storage: only `(salt_hex, code_hash)` ever lands in D1. Plaintext is shown
|
|
10
|
+
* to the user exactly once, then dropped from memory.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const ALPHABET = '23456789ABCDEFGHJKMNPQRSTUVWXYZ'
|
|
14
|
+
const CODE_LEN = 12 // characters, formatted as 4-4-4
|
|
15
|
+
const CODE_COUNT = 8
|
|
16
|
+
|
|
17
|
+
const randomChars = (n: number): string => {
|
|
18
|
+
const bytes = new Uint8Array(n)
|
|
19
|
+
crypto.getRandomValues(bytes)
|
|
20
|
+
// Modulo-bias against 30 chars is negligible at 8-bit input.
|
|
21
|
+
let s = ''
|
|
22
|
+
for (let i = 0; i < n; i++) s += ALPHABET.charAt((bytes[i] ?? 0) % ALPHABET.length)
|
|
23
|
+
return s
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const formatCode = (raw: string): string =>
|
|
27
|
+
`${raw.slice(0, 4)}-${raw.slice(4, 8)}-${raw.slice(8, 12)}`
|
|
28
|
+
|
|
29
|
+
const stripFormat = (input: string): string => input.replace(/[\s-]/g, '').toUpperCase()
|
|
30
|
+
|
|
31
|
+
const toHex = (buf: ArrayBuffer): string =>
|
|
32
|
+
[...new Uint8Array(buf)].map((b) => b.toString(16).padStart(2, '0')).join('')
|
|
33
|
+
|
|
34
|
+
const fromHex = (hex: string): Uint8Array => {
|
|
35
|
+
const out = new Uint8Array(hex.length / 2)
|
|
36
|
+
for (let i = 0; i < out.length; i++) out[i] = Number.parseInt(hex.slice(i * 2, i * 2 + 2), 16)
|
|
37
|
+
return out
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Per-row salt + PBKDF2-SHA256 single round. Plenty of cost for 60-bit
|
|
41
|
+
* unguessable inputs; not Argon2 because we're tight on Worker CPU time. */
|
|
42
|
+
const hashCode = async (plaintext: string, saltHex: string): Promise<string> => {
|
|
43
|
+
const enc = new TextEncoder()
|
|
44
|
+
const key = await crypto.subtle.importKey(
|
|
45
|
+
'raw',
|
|
46
|
+
enc.encode(plaintext),
|
|
47
|
+
{ name: 'PBKDF2' },
|
|
48
|
+
false,
|
|
49
|
+
['deriveBits'],
|
|
50
|
+
)
|
|
51
|
+
const bits = await crypto.subtle.deriveBits(
|
|
52
|
+
{
|
|
53
|
+
name: 'PBKDF2',
|
|
54
|
+
hash: 'SHA-256',
|
|
55
|
+
salt: fromHex(saltHex) as BufferSource,
|
|
56
|
+
iterations: 1,
|
|
57
|
+
},
|
|
58
|
+
key,
|
|
59
|
+
256,
|
|
60
|
+
)
|
|
61
|
+
return toHex(bits)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const randomSaltHex = (): string => {
|
|
65
|
+
const bytes = new Uint8Array(32)
|
|
66
|
+
crypto.getRandomValues(bytes)
|
|
67
|
+
return [...bytes].map((b) => b.toString(16).padStart(2, '0')).join('')
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export interface GeneratedSet {
|
|
71
|
+
/** Plaintext codes — display to user, never persisted. */
|
|
72
|
+
readonly codes: ReadonlyArray<string>
|
|
73
|
+
readonly count: number
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Generate a fresh set, replacing any prior unused codes for the user. */
|
|
77
|
+
export const regenerateRecoveryCodes = async (
|
|
78
|
+
db: D1Database,
|
|
79
|
+
userId: string,
|
|
80
|
+
): Promise<GeneratedSet> => {
|
|
81
|
+
// Wipe prior codes — regeneration is total, no partial overlap with old.
|
|
82
|
+
await db.prepare('DELETE FROM recovery_codes WHERE user_id = ?').bind(userId).run()
|
|
83
|
+
const now = Math.floor(Date.now() / 1000)
|
|
84
|
+
const plaintextCodes: string[] = []
|
|
85
|
+
const inserts: Array<{ saltHex: string; hash: string }> = []
|
|
86
|
+
for (let i = 0; i < CODE_COUNT; i++) {
|
|
87
|
+
const raw = randomChars(CODE_LEN)
|
|
88
|
+
const saltHex = randomSaltHex()
|
|
89
|
+
const hash = await hashCode(raw, saltHex)
|
|
90
|
+
plaintextCodes.push(formatCode(raw))
|
|
91
|
+
inserts.push({ saltHex, hash })
|
|
92
|
+
}
|
|
93
|
+
const stmt = db.prepare(
|
|
94
|
+
'INSERT INTO recovery_codes (user_id, salt_hex, code_hash, used_at, created_at) VALUES (?, ?, ?, NULL, ?)',
|
|
95
|
+
)
|
|
96
|
+
await db.batch(inserts.map((r) => stmt.bind(userId, r.saltHex, r.hash, now)))
|
|
97
|
+
return { codes: plaintextCodes, count: plaintextCodes.length }
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export interface RedeemResult {
|
|
101
|
+
readonly ok: boolean
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Try every unused code-row for the user; constant-time compare not needed
|
|
105
|
+
* (the candidate hash already incorporates a per-row salt). */
|
|
106
|
+
export const redeemRecoveryCode = async (
|
|
107
|
+
db: D1Database,
|
|
108
|
+
userId: string,
|
|
109
|
+
candidatePlaintext: string,
|
|
110
|
+
): Promise<RedeemResult> => {
|
|
111
|
+
const candidate = stripFormat(candidatePlaintext)
|
|
112
|
+
if (candidate.length !== CODE_LEN) return { ok: false }
|
|
113
|
+
// biome-ignore lint/style/useNamingConvention: D1 raw columns
|
|
114
|
+
type Row = { salt_hex: string; code_hash: string }
|
|
115
|
+
const { results } = await db
|
|
116
|
+
.prepare('SELECT salt_hex, code_hash FROM recovery_codes WHERE user_id = ? AND used_at IS NULL')
|
|
117
|
+
.bind(userId)
|
|
118
|
+
.all<Row>()
|
|
119
|
+
for (const row of results) {
|
|
120
|
+
const candidateHash = await hashCode(candidate, row.salt_hex)
|
|
121
|
+
if (candidateHash === row.code_hash) {
|
|
122
|
+
const now = Math.floor(Date.now() / 1000)
|
|
123
|
+
await db
|
|
124
|
+
.prepare('UPDATE recovery_codes SET used_at = ? WHERE user_id = ? AND code_hash = ?')
|
|
125
|
+
.bind(now, userId, row.code_hash)
|
|
126
|
+
.run()
|
|
127
|
+
return { ok: true }
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
return { ok: false }
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export const countUnusedCodes = async (db: D1Database, userId: string): Promise<number> => {
|
|
134
|
+
const row = await db
|
|
135
|
+
.prepare('SELECT COUNT(*) AS n FROM recovery_codes WHERE user_id = ? AND used_at IS NULL')
|
|
136
|
+
.bind(userId)
|
|
137
|
+
.first<{ n: number }>()
|
|
138
|
+
return row?.n ?? 0
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Wipe ALL passkeys for a user — fired on successful recovery-code redeem
|
|
142
|
+
* because the redeeming party may not be the legitimate user. */
|
|
143
|
+
export const invalidateAllPasskeysForUser = async (
|
|
144
|
+
db: D1Database,
|
|
145
|
+
userId: string,
|
|
146
|
+
): Promise<void> => {
|
|
147
|
+
await db.prepare('DELETE FROM passkeys WHERE user_id = ?').bind(userId).run()
|
|
148
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { uuidV7 } from '@pya-platform/shared'
|
|
2
|
+
import { ForbiddenError } from '@pya-platform/shared'
|
|
3
|
+
import { Hono } from 'hono'
|
|
4
|
+
import { setCookie } from 'hono/cookie'
|
|
5
|
+
import { newSessionId, writeSession } from '../store/session-store.ts'
|
|
6
|
+
|
|
7
|
+
// Optional sweep hook the host app can inject (e.g. PyaEats wires up its
|
|
8
|
+
// stale-order cancel sweep). The router accepts undefined and skips
|
|
9
|
+
// /cron-sweep when not provided.
|
|
10
|
+
type DevSweepFn = (env: Env) => Promise<Record<string, unknown>>
|
|
11
|
+
|
|
12
|
+
const COOKIE_NAME = 'pya_sid'
|
|
13
|
+
const CSRF_COOKIE = 'pya_csrf'
|
|
14
|
+
const SESSION_LIFETIME_SEC = 60 * 60 * 24 * 30
|
|
15
|
+
|
|
16
|
+
/** Dev-only "magic login" — creates a fake customer session.
|
|
17
|
+
* Disabled in production (ENVIRONMENT==='production' returns 403). */
|
|
18
|
+
export const createDevBypassRoutes = (
|
|
19
|
+
opts: { readonly onCronSweep?: DevSweepFn } = {},
|
|
20
|
+
): Hono<{ Bindings: Env }> => {
|
|
21
|
+
const app = new Hono<{ Bindings: Env }>()
|
|
22
|
+
|
|
23
|
+
app.post('/login', async (c) => {
|
|
24
|
+
if (c.env.ENVIRONMENT === 'production') {
|
|
25
|
+
throw new ForbiddenError({ required: 'non-production environment' })
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const email = c.req.query('email') ?? `dev+${Date.now()}@pya.local`
|
|
29
|
+
const role = (c.req.query('role') ?? 'customer') as 'customer' | 'store_owner' | 'admin'
|
|
30
|
+
const now = Math.floor(Date.now() / 1000)
|
|
31
|
+
|
|
32
|
+
// Reuse the existing user when the email is already registered — otherwise
|
|
33
|
+
// we'd write a session keyed on a userId that points to nothing in `users`.
|
|
34
|
+
const existing = await c.env.DB.prepare(
|
|
35
|
+
"SELECT id FROM users WHERE email = ? AND status != 'deleted'",
|
|
36
|
+
)
|
|
37
|
+
.bind(email)
|
|
38
|
+
.first<{ id: string }>()
|
|
39
|
+
const userId = existing?.id ?? uuidV7()
|
|
40
|
+
if (existing === null) {
|
|
41
|
+
await c.env.DB.prepare(
|
|
42
|
+
`INSERT INTO users (id, email, email_verified, display_name, locale, created_at, status)
|
|
43
|
+
VALUES (?, ?, 1, ?, 'es-PY', ?, 'active')`,
|
|
44
|
+
)
|
|
45
|
+
.bind(userId, email, `Dev ${role}`, now)
|
|
46
|
+
.run()
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const sid = newSessionId()
|
|
50
|
+
const ipHash = await sha256(
|
|
51
|
+
(c.req.header('CF-Connecting-IP') ?? '') + (c.env.SESSION_PEPPER ?? ''),
|
|
52
|
+
)
|
|
53
|
+
const uaHash = await sha256(c.req.header('User-Agent') ?? '')
|
|
54
|
+
|
|
55
|
+
await writeSession(c.env.SESSIONS, sid, {
|
|
56
|
+
userId,
|
|
57
|
+
roles: [role],
|
|
58
|
+
storeIds: [],
|
|
59
|
+
iat: now,
|
|
60
|
+
lastSeen: now,
|
|
61
|
+
ipHash,
|
|
62
|
+
uaHash,
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
// Preview/staging serves site and api from different eTLD+1 (pages.dev vs workers.dev),
|
|
66
|
+
// so cookies must be SameSite=None to ride cross-origin fetches.
|
|
67
|
+
// Note: production branch was thrown above, so ENVIRONMENT is narrowed to 'preview' | 'staging'.
|
|
68
|
+
const sameSite = 'None' as const
|
|
69
|
+
const csrfToken = newSessionId()
|
|
70
|
+
setCookie(c, COOKIE_NAME, sid, {
|
|
71
|
+
path: '/',
|
|
72
|
+
httpOnly: true,
|
|
73
|
+
secure: true,
|
|
74
|
+
sameSite,
|
|
75
|
+
maxAge: SESSION_LIFETIME_SEC,
|
|
76
|
+
})
|
|
77
|
+
setCookie(c, CSRF_COOKIE, csrfToken, {
|
|
78
|
+
path: '/',
|
|
79
|
+
secure: true,
|
|
80
|
+
sameSite,
|
|
81
|
+
maxAge: SESSION_LIFETIME_SEC,
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
// Return sessionToken + csrfToken in body so cross-origin clients (preview deploys
|
|
85
|
+
// where third-party cookies are blocked) can hold them and authenticate via
|
|
86
|
+
// Authorization: Bearer + X-CSRF-Token headers on subsequent requests.
|
|
87
|
+
return c.json({ ok: true, userId, email, role, sessionToken: sid, csrfToken })
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
const sha256 = async (s: string): Promise<string> => {
|
|
91
|
+
const buf = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(s))
|
|
92
|
+
return [...new Uint8Array(buf)].map((b) => b.toString(16).padStart(2, '0')).join('')
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Dev-only manual trigger for a host-supplied sweep job. CF cron fires it
|
|
96
|
+
* on its own; this lets us verify the logic on demand from tests. */
|
|
97
|
+
if (opts.onCronSweep !== undefined) {
|
|
98
|
+
const sweep = opts.onCronSweep
|
|
99
|
+
app.post('/cron-sweep', async (c) => {
|
|
100
|
+
if (c.env.ENVIRONMENT === 'production') {
|
|
101
|
+
throw new ForbiddenError({ required: 'non-production environment' })
|
|
102
|
+
}
|
|
103
|
+
const result = await sweep(c.env)
|
|
104
|
+
return c.json({ ok: true, ...result })
|
|
105
|
+
})
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return app
|
|
109
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { type OAuthProvider, OAuthProviderSchema, type OAuthState } from '@pya-platform/shared'
|
|
2
|
+
import { ValidationError } from '@pya-platform/shared'
|
|
3
|
+
import { Hono } from 'hono'
|
|
4
|
+
import { deleteCookie, getCookie, setCookie } from 'hono/cookie'
|
|
5
|
+
import * as v from 'valibot'
|
|
6
|
+
import { buildRedirectUri, handleOAuthCallback } from '../oauth-callback.ts'
|
|
7
|
+
import { revokeSession } from '../session.ts'
|
|
8
|
+
|
|
9
|
+
const STATE_TTL_SEC = 600
|
|
10
|
+
const REDIRECT_ALLOWLIST: ReadonlyArray<RegExp> = [
|
|
11
|
+
/^\/$/,
|
|
12
|
+
/^\/stores\/[a-z0-9-]+$/,
|
|
13
|
+
/^\/cart$/,
|
|
14
|
+
/^\/checkout$/,
|
|
15
|
+
/^\/orders\/[a-z0-9-]+$/,
|
|
16
|
+
/^\/profile$/,
|
|
17
|
+
]
|
|
18
|
+
|
|
19
|
+
const app = new Hono<{ Bindings: Env }>()
|
|
20
|
+
|
|
21
|
+
app.get('/start', async (c) => {
|
|
22
|
+
const providerRaw = c.req.query('provider')
|
|
23
|
+
const parsed = v.safeParse(OAuthProviderSchema, providerRaw)
|
|
24
|
+
if (!parsed.success) {
|
|
25
|
+
throw new ValidationError({ issues: [{ path: 'provider', message: 'Unknown provider' }] })
|
|
26
|
+
}
|
|
27
|
+
const provider = parsed.output
|
|
28
|
+
|
|
29
|
+
const redirectAfter = c.req.query('redirect') ?? '/'
|
|
30
|
+
const safeRedirect = REDIRECT_ALLOWLIST.some((re) => re.test(redirectAfter)) ? redirectAfter : '/'
|
|
31
|
+
|
|
32
|
+
const state = randomToken(32)
|
|
33
|
+
const verifier = randomToken(64)
|
|
34
|
+
const challenge = await s256Challenge(verifier)
|
|
35
|
+
const nonce = randomToken(16)
|
|
36
|
+
|
|
37
|
+
const stateRecord: OAuthState = {
|
|
38
|
+
verifier,
|
|
39
|
+
provider,
|
|
40
|
+
redirectAfter: safeRedirect,
|
|
41
|
+
nonce,
|
|
42
|
+
intent: 'login',
|
|
43
|
+
}
|
|
44
|
+
await c.env.OAUTH_STATE.put(`oauth:state:${state}`, JSON.stringify(stateRecord), {
|
|
45
|
+
expirationTtl: STATE_TTL_SEC,
|
|
46
|
+
})
|
|
47
|
+
setCookie(c, 'pya_oauth_state', state, {
|
|
48
|
+
path: '/api/auth',
|
|
49
|
+
httpOnly: true,
|
|
50
|
+
secure: true,
|
|
51
|
+
sameSite: 'Lax',
|
|
52
|
+
maxAge: STATE_TTL_SEC,
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
const authUrl = buildProviderAuthUrl(provider, c.env, state, challenge, nonce)
|
|
56
|
+
return c.redirect(authUrl, 302)
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
app.get('/callback/:provider', handleOAuthCallback)
|
|
60
|
+
|
|
61
|
+
app.post('/logout', async (c) => {
|
|
62
|
+
const sid = getCookie(c, 'pya_sid')
|
|
63
|
+
if (sid !== undefined) await revokeSession(c, deleteCookie, sid, false)
|
|
64
|
+
return c.json({ ok: true })
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
const randomToken = (bytes: number): string => {
|
|
68
|
+
const buf = new Uint8Array(bytes)
|
|
69
|
+
crypto.getRandomValues(buf)
|
|
70
|
+
return btoa(String.fromCharCode(...buf))
|
|
71
|
+
.replaceAll('+', '-')
|
|
72
|
+
.replaceAll('/', '_')
|
|
73
|
+
.replaceAll('=', '')
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const s256Challenge = async (verifier: string): Promise<string> => {
|
|
77
|
+
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(verifier))
|
|
78
|
+
return btoa(String.fromCharCode(...new Uint8Array(digest)))
|
|
79
|
+
.replaceAll('+', '-')
|
|
80
|
+
.replaceAll('/', '_')
|
|
81
|
+
.replaceAll('=', '')
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const buildProviderAuthUrl = (
|
|
85
|
+
provider: OAuthProvider,
|
|
86
|
+
env: Env,
|
|
87
|
+
state: string,
|
|
88
|
+
challenge: string,
|
|
89
|
+
nonce: string,
|
|
90
|
+
): string => {
|
|
91
|
+
const redirectUri = buildRedirectUri(env, provider)
|
|
92
|
+
const common = {
|
|
93
|
+
response_type: 'code',
|
|
94
|
+
state,
|
|
95
|
+
code_challenge: challenge,
|
|
96
|
+
code_challenge_method: 'S256',
|
|
97
|
+
redirect_uri: redirectUri,
|
|
98
|
+
scope: 'openid email profile',
|
|
99
|
+
nonce,
|
|
100
|
+
}
|
|
101
|
+
const params = new URLSearchParams(common)
|
|
102
|
+
switch (provider) {
|
|
103
|
+
case 'google':
|
|
104
|
+
params.set('client_id', env.GOOGLE_OAUTH_CLIENT_ID ?? '')
|
|
105
|
+
return `${env.GOOGLE_AUTH_URL ?? 'https://accounts.google.com/o/oauth2/v2/auth'}?${params}`
|
|
106
|
+
case 'facebook':
|
|
107
|
+
params.set('client_id', env.FACEBOOK_APP_ID ?? '')
|
|
108
|
+
return `https://www.facebook.com/v18.0/dialog/oauth?${params}`
|
|
109
|
+
case 'apple':
|
|
110
|
+
return `${env.SITE_ORIGIN}/login?error=apple_not_implemented`
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export { app as oauthRoutes }
|