@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
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# @pya/auth
|
|
2
|
+
|
|
3
|
+
## 0.1.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- a9ca6bf: Initial release of the Pya platform packages. Extracted from `pyaeats-app`, consumed by `pyaeats-app` (food delivery) and `pyaserv` (services classifieds).
|
|
8
|
+
|
|
9
|
+
Each package exposes a Hono router factory (auth/cms/reviews/comments) or a typed helper (email/audit/cf) parameterised over Cloudflare D1 + KV bindings. UI primitives ship as Lit web components on top of `@pya/tokens` (CSS custom properties). See `ROADMAP.md` and `docs/phase-6-rollout.md` for the consumer cutover plan.
|
|
10
|
+
|
|
11
|
+
### Patch Changes
|
|
12
|
+
|
|
13
|
+
- Updated dependencies [a9ca6bf]
|
|
14
|
+
- @pya/shared@0.1.0
|
package/package.json
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@pya-platform/auth",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"private": false,
|
|
5
|
+
"publishConfig": {
|
|
6
|
+
"registry": "https://registry.npmjs.org",
|
|
7
|
+
"access": "public"
|
|
8
|
+
},
|
|
9
|
+
"repository": {
|
|
10
|
+
"type": "git",
|
|
11
|
+
"url": "https://github.com/undeadliner/pya-platform.git"
|
|
12
|
+
},
|
|
13
|
+
"type": "module",
|
|
14
|
+
"description": "Auth engine — passwordless (OTP + magic link), passkey/WebAuthn, OAuth, recovery codes, sessions (KV), CSRF. Hono router factories parameterised over D1/KV bindings.",
|
|
15
|
+
"exports": {
|
|
16
|
+
".": "./src/index.ts",
|
|
17
|
+
"./routes/passwordless": "./src/routes/passwordless.ts",
|
|
18
|
+
"./routes/oauth": "./src/routes/oauth.ts",
|
|
19
|
+
"./routes/dev-bypass": "./src/routes/dev-bypass.ts",
|
|
20
|
+
"./session": "./src/session.ts",
|
|
21
|
+
"./migrations/*": "./src/migrations/*"
|
|
22
|
+
},
|
|
23
|
+
"scripts": {
|
|
24
|
+
"type-check": "tsc --noEmit",
|
|
25
|
+
"test": "echo '@pya/auth has no tests yet'"
|
|
26
|
+
},
|
|
27
|
+
"dependencies": {
|
|
28
|
+
"@pya-platform/shared": "workspace:*",
|
|
29
|
+
"@simplewebauthn/server": "^13.3.0",
|
|
30
|
+
"effect": "^3.10.0",
|
|
31
|
+
"hono": "^4.6.0",
|
|
32
|
+
"jose": "^5.9.0",
|
|
33
|
+
"valibot": "^1.0.0"
|
|
34
|
+
},
|
|
35
|
+
"peerDependencies": {
|
|
36
|
+
"@cloudflare/workers-types": "^4.20240909.0"
|
|
37
|
+
}
|
|
38
|
+
}
|
package/src/env.ts
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// The shape every Pya host worker must supply to `@pya-platform/auth` via `Bindings`.
|
|
2
|
+
// Hosts can extend this with their own bindings — Hono merges via intersection.
|
|
3
|
+
// Keep this interface narrow: only what auth itself touches.
|
|
4
|
+
|
|
5
|
+
export interface PyaAuthBindings {
|
|
6
|
+
/** Primary database (sessions/users/passkeys/recovery_codes/audit) */
|
|
7
|
+
readonly DB: D1Database
|
|
8
|
+
/** Session KV — bound to a separate namespace from cache KVs */
|
|
9
|
+
readonly SESSIONS: KVNamespace
|
|
10
|
+
/** OAuth state KV — short-TTL nonces */
|
|
11
|
+
readonly OAUTH_STATE: KVNamespace
|
|
12
|
+
/** Deployment env — controls SameSite policy and dev-bypass gating */
|
|
13
|
+
readonly ENVIRONMENT: 'development' | 'preview' | 'staging' | 'production'
|
|
14
|
+
/** Customer-facing site origin (cookie domain anchoring) */
|
|
15
|
+
readonly SITE_ORIGIN: string
|
|
16
|
+
/** Admin site origin (separate cookie name) */
|
|
17
|
+
readonly ADMIN_ORIGIN: string
|
|
18
|
+
/** API self origin (for OAuth callback URL construction) */
|
|
19
|
+
readonly API_ORIGIN: string
|
|
20
|
+
|
|
21
|
+
/** Pepper for IP/UA hashes — rotate without invalidating sessions */
|
|
22
|
+
readonly SESSION_PEPPER?: string
|
|
23
|
+
/** CSRF HMAC signing key */
|
|
24
|
+
readonly CSRF_HMAC_KEY?: string
|
|
25
|
+
/** Cloudflare Turnstile secret (bot protection) */
|
|
26
|
+
readonly TURNSTILE_SECRET?: string
|
|
27
|
+
|
|
28
|
+
/** Resend API key (passwordless email) */
|
|
29
|
+
readonly RESEND_API_KEY?: string
|
|
30
|
+
/** Email "from" domain — must be verified in Resend */
|
|
31
|
+
readonly EMAIL_DOMAIN?: string
|
|
32
|
+
|
|
33
|
+
/** WebAuthn relying-party identifier (e.g. `pyaeats.com`) */
|
|
34
|
+
readonly WEBAUTHN_RP_ID?: string
|
|
35
|
+
/** Comma-separated allowed origins for WebAuthn assertions */
|
|
36
|
+
readonly WEBAUTHN_ORIGINS?: string
|
|
37
|
+
|
|
38
|
+
/** OAuth provider secrets — optional, providers without secrets return 501 */
|
|
39
|
+
readonly GOOGLE_OAUTH_CLIENT_ID?: string
|
|
40
|
+
readonly GOOGLE_OAUTH_CLIENT_SECRET?: string
|
|
41
|
+
readonly FACEBOOK_APP_ID?: string
|
|
42
|
+
readonly FACEBOOK_APP_SECRET?: string
|
|
43
|
+
|
|
44
|
+
/** OAuth endpoint overrides (E2E sidecar). Defaults baked in providers/*.ts */
|
|
45
|
+
readonly GOOGLE_AUTH_URL?: string
|
|
46
|
+
readonly GOOGLE_TOKEN_URL?: string
|
|
47
|
+
readonly GOOGLE_JWKS_URL?: string
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Re-exported as global `Env` so the existing code (which references `Env`
|
|
51
|
+
// directly via Hono's `Bindings`) compiles without further edits. Each host
|
|
52
|
+
// worker declares its own `Env` that extends `PyaAuthBindings` plus host-
|
|
53
|
+
// specific fields; that augmented `Env` is what Hono sees at runtime.
|
|
54
|
+
declare global {
|
|
55
|
+
// eslint-disable-next-line @typescript-eslint/no-empty-interface
|
|
56
|
+
interface Env extends PyaAuthBindings {}
|
|
57
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { type ProviderClaims, uuidV7 } from '@pya-platform/shared'
|
|
2
|
+
import { IdentityConflictError } from '@pya-platform/shared'
|
|
3
|
+
|
|
4
|
+
export interface LinkResult {
|
|
5
|
+
readonly userId: string
|
|
6
|
+
readonly created: boolean
|
|
7
|
+
readonly linked: boolean
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const insertIdentity = (db: D1Database, userId: string, claims: ProviderClaims, ts: number) =>
|
|
11
|
+
db
|
|
12
|
+
.prepare(
|
|
13
|
+
`INSERT INTO user_identities (user_id, provider, subject, email_at_link, linked_at)
|
|
14
|
+
VALUES (?, ?, ?, ?, ?)`,
|
|
15
|
+
)
|
|
16
|
+
.bind(userId, claims.provider, claims.subject, claims.email, ts)
|
|
17
|
+
|
|
18
|
+
export const provisionOrLink = async (
|
|
19
|
+
db: D1Database,
|
|
20
|
+
claims: ProviderClaims,
|
|
21
|
+
intent: 'login' | 'link',
|
|
22
|
+
currentUserId: string | undefined,
|
|
23
|
+
): Promise<LinkResult> => {
|
|
24
|
+
const now = Math.floor(Date.now() / 1000)
|
|
25
|
+
|
|
26
|
+
const existing = await db
|
|
27
|
+
.prepare('SELECT user_id FROM user_identities WHERE provider = ? AND subject = ?')
|
|
28
|
+
.bind(claims.provider, claims.subject)
|
|
29
|
+
.first<{ user_id: string }>()
|
|
30
|
+
|
|
31
|
+
if (existing !== null) {
|
|
32
|
+
if (intent === 'link' && currentUserId !== undefined && existing.user_id !== currentUserId) {
|
|
33
|
+
throw new IdentityConflictError({ provider: claims.provider })
|
|
34
|
+
}
|
|
35
|
+
return { userId: existing.user_id, created: false, linked: false }
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (intent === 'link' && currentUserId !== undefined) {
|
|
39
|
+
await insertIdentity(db, currentUserId, claims, now).run()
|
|
40
|
+
return { userId: currentUserId, created: false, linked: true }
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const existingUser = await db
|
|
44
|
+
.prepare("SELECT id FROM users WHERE email = ? AND status != 'deleted'")
|
|
45
|
+
.bind(claims.email)
|
|
46
|
+
.first<{ id: string }>()
|
|
47
|
+
|
|
48
|
+
if (existingUser !== null) {
|
|
49
|
+
await insertIdentity(db, existingUser.id, claims, now).run()
|
|
50
|
+
return { userId: existingUser.id, created: false, linked: true }
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const userId = uuidV7()
|
|
54
|
+
await db.batch([
|
|
55
|
+
db
|
|
56
|
+
.prepare(
|
|
57
|
+
`INSERT INTO users (id, email, email_verified, display_name, locale, created_at, status)
|
|
58
|
+
VALUES (?, ?, 1, ?, ?, ?, 'active')`,
|
|
59
|
+
)
|
|
60
|
+
.bind(userId, claims.email, claims.displayName ?? null, claims.locale ?? 'es-PY', now),
|
|
61
|
+
insertIdentity(db, userId, claims, now),
|
|
62
|
+
])
|
|
63
|
+
return { userId, created: true, linked: false }
|
|
64
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// @pya-platform/auth — public surface.
|
|
2
|
+
//
|
|
3
|
+
// Hono router factories + middleware. Each consumer wires its own Worker
|
|
4
|
+
// like so:
|
|
5
|
+
//
|
|
6
|
+
// const app = new Hono<{ Bindings: Env }>()
|
|
7
|
+
// app.route('/api/auth', passwordlessRoutes)
|
|
8
|
+
// app.route('/api/auth', oauthRoutes)
|
|
9
|
+
// app.route('/api/auth/dev', createDevBypassRoutes({ onCronSweep }))
|
|
10
|
+
// app.use('/v1/*', requireAuth)
|
|
11
|
+
//
|
|
12
|
+
// The `Env` interface is contributed by `./env.ts` (PyaAuthBindings). Hosts
|
|
13
|
+
// extend it with their own bindings — Hono merges via intersection.
|
|
14
|
+
|
|
15
|
+
import './env.ts'
|
|
16
|
+
|
|
17
|
+
export type { PyaAuthBindings } from './env.ts'
|
|
18
|
+
export { requireAuth, issueSession, revokeSession } from './session.ts'
|
|
19
|
+
export { passwordlessRoutes } from './routes/passwordless.ts'
|
|
20
|
+
export { oauthRoutes } from './routes/oauth.ts'
|
|
21
|
+
export { createDevBypassRoutes } from './routes/dev-bypass.ts'
|
|
22
|
+
export { logAuth, type AuthEvent } from './log.ts'
|
|
23
|
+
export { provisionOrLink } from './identity-link.ts'
|
|
24
|
+
export {
|
|
25
|
+
newSessionId,
|
|
26
|
+
readSession,
|
|
27
|
+
touchSession,
|
|
28
|
+
writeSession,
|
|
29
|
+
deleteSession,
|
|
30
|
+
} from './store/session-store.ts'
|
|
31
|
+
// Re-export domain errors so consumers don't need to import @pya-platform/shared
|
|
32
|
+
// separately just for `mapErrorToStatus` / `UnauthorizedError` / etc.
|
|
33
|
+
export {
|
|
34
|
+
UnauthorizedError,
|
|
35
|
+
ForbiddenError,
|
|
36
|
+
NotFoundError,
|
|
37
|
+
ValidationError,
|
|
38
|
+
ConflictError,
|
|
39
|
+
RateLimitedError,
|
|
40
|
+
UpstreamError,
|
|
41
|
+
InvalidTokenError,
|
|
42
|
+
IdentityConflictError,
|
|
43
|
+
ProviderNotEnabledError,
|
|
44
|
+
mapErrorToStatus,
|
|
45
|
+
type DomainError,
|
|
46
|
+
} from '@pya-platform/shared'
|
package/src/log.ts
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { IdentityProvider } from '@pya-platform/shared'
|
|
2
|
+
|
|
3
|
+
export type AuthEvent =
|
|
4
|
+
| 'auth.login.oauth'
|
|
5
|
+
| 'auth.login.email'
|
|
6
|
+
| 'auth.login.passkey'
|
|
7
|
+
| 'auth.login.recovery'
|
|
8
|
+
| 'auth.login.rejected'
|
|
9
|
+
| 'auth.email.send_failed'
|
|
10
|
+
| 'auth.passkey.registered'
|
|
11
|
+
| 'auth.recovery.generated'
|
|
12
|
+
|
|
13
|
+
export type AuthProvider = IdentityProvider | 'recovery'
|
|
14
|
+
|
|
15
|
+
export interface AuthLogEvent {
|
|
16
|
+
readonly event: AuthEvent
|
|
17
|
+
readonly ts: number
|
|
18
|
+
readonly userId?: string
|
|
19
|
+
readonly provider: AuthProvider
|
|
20
|
+
readonly ipHash?: string
|
|
21
|
+
readonly outcome: 'created' | 'linked' | 'reused' | 'rejected' | 'sent'
|
|
22
|
+
readonly reason?: string
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export const logAuth = (event: AuthLogEvent): void => {
|
|
26
|
+
console.log(JSON.stringify({ stream: 'audit', ...event }))
|
|
27
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
-- 0001_init — identity & audit foundations.
|
|
2
|
+
-- Migrations are applied via `wrangler d1 migrations apply` per environment.
|
|
3
|
+
|
|
4
|
+
CREATE TABLE users (
|
|
5
|
+
id TEXT PRIMARY KEY, -- UUID v7
|
|
6
|
+
email TEXT NOT NULL, -- normalised lowercase
|
|
7
|
+
email_verified INTEGER NOT NULL DEFAULT 0, -- 0/1
|
|
8
|
+
display_name TEXT,
|
|
9
|
+
locale TEXT NOT NULL DEFAULT 'es-PY',
|
|
10
|
+
created_at INTEGER NOT NULL, -- unix seconds
|
|
11
|
+
status TEXT NOT NULL DEFAULT 'active' -- active | suspended | deleted
|
|
12
|
+
);
|
|
13
|
+
|
|
14
|
+
CREATE UNIQUE INDEX ux_users_email_active ON users(email) WHERE status != 'deleted';
|
|
15
|
+
|
|
16
|
+
CREATE TABLE user_identities (
|
|
17
|
+
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
18
|
+
provider TEXT NOT NULL, -- google | apple | facebook
|
|
19
|
+
subject TEXT NOT NULL, -- provider 'sub' claim
|
|
20
|
+
email_at_link TEXT,
|
|
21
|
+
linked_at INTEGER NOT NULL,
|
|
22
|
+
PRIMARY KEY (provider, subject)
|
|
23
|
+
);
|
|
24
|
+
|
|
25
|
+
CREATE INDEX ix_identities_user ON user_identities(user_id);
|
|
26
|
+
|
|
27
|
+
CREATE TABLE roles (
|
|
28
|
+
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
29
|
+
role TEXT NOT NULL, -- customer | store_owner | store_staff | courier | admin | super_admin
|
|
30
|
+
store_id TEXT, -- NULL for global roles
|
|
31
|
+
granted_by TEXT REFERENCES users(id),
|
|
32
|
+
granted_at INTEGER NOT NULL,
|
|
33
|
+
PRIMARY KEY (user_id, role, store_id)
|
|
34
|
+
);
|
|
35
|
+
|
|
36
|
+
CREATE INDEX ix_roles_user ON roles(user_id);
|
|
37
|
+
CREATE INDEX ix_roles_store ON roles(store_id, role);
|
|
38
|
+
|
|
39
|
+
CREATE TABLE audit_log (
|
|
40
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
41
|
+
ts INTEGER NOT NULL,
|
|
42
|
+
actor_user_id TEXT,
|
|
43
|
+
actor_role TEXT NOT NULL,
|
|
44
|
+
actor_ip_hash TEXT, -- HMAC-SHA256(ip, SESSION_PEPPER)
|
|
45
|
+
action TEXT NOT NULL,
|
|
46
|
+
target_type TEXT,
|
|
47
|
+
target_id TEXT,
|
|
48
|
+
store_id TEXT,
|
|
49
|
+
details_json TEXT,
|
|
50
|
+
prev_hash TEXT NOT NULL,
|
|
51
|
+
row_hash TEXT NOT NULL
|
|
52
|
+
);
|
|
53
|
+
|
|
54
|
+
CREATE INDEX ix_audit_ts ON audit_log(ts);
|
|
55
|
+
CREATE INDEX ix_audit_actor ON audit_log(actor_user_id, ts);
|
|
56
|
+
|
|
57
|
+
-- RUM events from web-vitals beacons.
|
|
58
|
+
CREATE TABLE rum_events (
|
|
59
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
60
|
+
name TEXT NOT NULL,
|
|
61
|
+
value REAL NOT NULL,
|
|
62
|
+
rating TEXT,
|
|
63
|
+
url TEXT NOT NULL,
|
|
64
|
+
ua TEXT,
|
|
65
|
+
ts INTEGER NOT NULL,
|
|
66
|
+
session_id TEXT
|
|
67
|
+
);
|
|
68
|
+
|
|
69
|
+
CREATE INDEX ix_rum_name_ts ON rum_events(name, ts);
|
|
70
|
+
CREATE INDEX ix_rum_url ON rum_events(url, ts);
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
-- 0004_passkeys — WebAuthn credential storage for spec 011.
|
|
2
|
+
-- Applied via `wrangler d1 migrations apply pyaeats-preview --remote`.
|
|
3
|
+
|
|
4
|
+
CREATE TABLE passkeys (
|
|
5
|
+
credential_id TEXT PRIMARY KEY, -- base64url
|
|
6
|
+
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
7
|
+
public_key TEXT NOT NULL, -- base64url-encoded COSE key bytes
|
|
8
|
+
sign_count INTEGER NOT NULL DEFAULT 0,
|
|
9
|
+
transports TEXT, -- JSON array, e.g. ["internal","hybrid"]
|
|
10
|
+
label TEXT, -- user-friendly device label
|
|
11
|
+
created_at INTEGER NOT NULL, -- unix seconds
|
|
12
|
+
last_used_at INTEGER NOT NULL,
|
|
13
|
+
backup_eligible INTEGER NOT NULL DEFAULT 0, -- 0/1
|
|
14
|
+
backup_state INTEGER NOT NULL DEFAULT 0 -- 0/1
|
|
15
|
+
);
|
|
16
|
+
|
|
17
|
+
CREATE INDEX ix_passkeys_user ON passkeys(user_id);
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
-- Recovery codes — one-time fallback when both passkey AND email OTP fail
|
|
2
|
+
-- (lost device + lost mailbox + lost recovery key on the third device, etc).
|
|
3
|
+
--
|
|
4
|
+
-- A user enrolling a passkey for the first time gets 8 codes generated and
|
|
5
|
+
-- displayed ONCE. They store them outside the app (password manager, paper).
|
|
6
|
+
-- Redeeming a code:
|
|
7
|
+
-- • marks it used (`used_at`)
|
|
8
|
+
-- • invalidates ALL existing passkeys for that user (security best practice —
|
|
9
|
+
-- a leaked code means the account is compromised; force re-enrolment)
|
|
10
|
+
-- • mints a session
|
|
11
|
+
--
|
|
12
|
+
-- We store only the hash, never the plaintext. PBKDF2-SHA256 1-round w/
|
|
13
|
+
-- per-row salt is enough for short-lived (~years) high-entropy (64 bits)
|
|
14
|
+
-- secrets; we don't need Argon2 cost overhead. Salt + hash are both 32 bytes,
|
|
15
|
+
-- encoded as 64-char hex.
|
|
16
|
+
CREATE TABLE recovery_codes (
|
|
17
|
+
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
18
|
+
salt_hex TEXT NOT NULL, -- 32 bytes hex = 64 chars
|
|
19
|
+
code_hash TEXT NOT NULL, -- 32 bytes hex = 64 chars
|
|
20
|
+
used_at INTEGER, -- unix-seconds when redeemed
|
|
21
|
+
created_at INTEGER NOT NULL,
|
|
22
|
+
PRIMARY KEY (user_id, code_hash)
|
|
23
|
+
);
|
|
24
|
+
CREATE INDEX ix_recovery_codes_user ON recovery_codes(user_id, used_at);
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type OAuthProvider,
|
|
3
|
+
OAuthProviderSchema,
|
|
4
|
+
type OAuthState,
|
|
5
|
+
OAuthStateSchema,
|
|
6
|
+
type ProviderClaims,
|
|
7
|
+
} from '@pya-platform/shared'
|
|
8
|
+
import { UnauthorizedError } from '@pya-platform/shared'
|
|
9
|
+
import type { Context } from 'hono'
|
|
10
|
+
import { deleteCookie, getCookie, setCookie } from 'hono/cookie'
|
|
11
|
+
import * as v from 'valibot'
|
|
12
|
+
import { provisionOrLink } from './identity-link.ts'
|
|
13
|
+
import { logAuth } from './log.ts'
|
|
14
|
+
import { exchangeAndVerifyApple } from './providers/apple.ts'
|
|
15
|
+
import { exchangeAndVerifyFacebook } from './providers/facebook.ts'
|
|
16
|
+
import { exchangeAndVerifyGoogle } from './providers/google.ts'
|
|
17
|
+
import { issueSession } from './session.ts'
|
|
18
|
+
|
|
19
|
+
export const buildRedirectUri = (env: Env, provider: OAuthProvider): string =>
|
|
20
|
+
`${env.API_ORIGIN}/api/auth/callback/${provider}`
|
|
21
|
+
|
|
22
|
+
const exchangeForProvider = (
|
|
23
|
+
env: Env,
|
|
24
|
+
provider: OAuthProvider,
|
|
25
|
+
redirectUri: string,
|
|
26
|
+
code: string,
|
|
27
|
+
state: OAuthState,
|
|
28
|
+
): Promise<ProviderClaims> => {
|
|
29
|
+
switch (provider) {
|
|
30
|
+
case 'google':
|
|
31
|
+
return exchangeAndVerifyGoogle(env, redirectUri, code, state.verifier, state.nonce)
|
|
32
|
+
case 'facebook':
|
|
33
|
+
return exchangeAndVerifyFacebook(env, redirectUri, code, state.verifier, state.nonce)
|
|
34
|
+
case 'apple':
|
|
35
|
+
return exchangeAndVerifyApple(env, redirectUri, code, state.verifier, state.nonce)
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const sha256Hex = async (input: string): Promise<string> => {
|
|
40
|
+
const buf = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(input))
|
|
41
|
+
return [...new Uint8Array(buf)].map((b) => b.toString(16).padStart(2, '0')).join('')
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const outcomeOf = (created: boolean, linked: boolean): 'created' | 'linked' | 'reused' =>
|
|
45
|
+
created ? 'created' : linked ? 'linked' : 'reused'
|
|
46
|
+
|
|
47
|
+
export const handleOAuthCallback = async (c: Context<{ Bindings: Env }>): Promise<Response> => {
|
|
48
|
+
const provider = v.parse(OAuthProviderSchema, c.req.param('provider'))
|
|
49
|
+
|
|
50
|
+
const code = c.req.query('code')
|
|
51
|
+
const stateQ = c.req.query('state')
|
|
52
|
+
const cookieState = getCookie(c, 'pya_oauth_state')
|
|
53
|
+
deleteCookie(c, 'pya_oauth_state', { path: '/api/auth' })
|
|
54
|
+
|
|
55
|
+
if (
|
|
56
|
+
code === undefined ||
|
|
57
|
+
stateQ === undefined ||
|
|
58
|
+
cookieState === undefined ||
|
|
59
|
+
stateQ !== cookieState
|
|
60
|
+
) {
|
|
61
|
+
throw new UnauthorizedError({ reason: 'invalid state' })
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const raw = await c.env.OAUTH_STATE.get(`oauth:state:${stateQ}`, { type: 'json' })
|
|
65
|
+
if (raw === null) {
|
|
66
|
+
throw new UnauthorizedError({ reason: 'expired or replayed state' })
|
|
67
|
+
}
|
|
68
|
+
await c.env.OAUTH_STATE.delete(`oauth:state:${stateQ}`)
|
|
69
|
+
|
|
70
|
+
const stateRecord = v.parse(OAuthStateSchema, raw)
|
|
71
|
+
if (stateRecord.provider !== provider) {
|
|
72
|
+
throw new UnauthorizedError({ reason: 'provider mismatch' })
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const redirectUri = buildRedirectUri(c.env, provider)
|
|
76
|
+
const claims = await exchangeForProvider(c.env, provider, redirectUri, code, stateRecord)
|
|
77
|
+
|
|
78
|
+
const link = await provisionOrLink(
|
|
79
|
+
c.env.DB,
|
|
80
|
+
claims,
|
|
81
|
+
stateRecord.intent ?? 'login',
|
|
82
|
+
stateRecord.currentUserId,
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
const ip = c.req.header('CF-Connecting-IP') ?? ''
|
|
86
|
+
logAuth({
|
|
87
|
+
event: 'auth.login.oauth',
|
|
88
|
+
ts: Math.floor(Date.now() / 1000),
|
|
89
|
+
userId: link.userId,
|
|
90
|
+
provider,
|
|
91
|
+
ipHash: await sha256Hex(ip + (c.env.SESSION_PEPPER ?? '')),
|
|
92
|
+
outcome: outcomeOf(link.created, link.linked),
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
await issueSession(c, setCookie, false, {
|
|
96
|
+
userId: link.userId,
|
|
97
|
+
roles: ['customer'],
|
|
98
|
+
storeIds: [],
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
return c.redirect(stateRecord.redirectAfter, 302)
|
|
102
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { ProviderClaims } from '@pya-platform/shared'
|
|
2
|
+
import { ProviderNotEnabledError } from '@pya-platform/shared'
|
|
3
|
+
|
|
4
|
+
export const exchangeAndVerifyApple = async (
|
|
5
|
+
_env: Env,
|
|
6
|
+
_redirectUri: string,
|
|
7
|
+
_code: string,
|
|
8
|
+
_verifier: string,
|
|
9
|
+
_nonce: string,
|
|
10
|
+
): Promise<ProviderClaims> => {
|
|
11
|
+
throw new ProviderNotEnabledError({ provider: 'apple' })
|
|
12
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { ProviderClaims } from '@pya-platform/shared'
|
|
2
|
+
import { ProviderNotEnabledError } from '@pya-platform/shared'
|
|
3
|
+
|
|
4
|
+
export const exchangeAndVerifyFacebook = async (
|
|
5
|
+
_env: Env,
|
|
6
|
+
_redirectUri: string,
|
|
7
|
+
_code: string,
|
|
8
|
+
_verifier: string,
|
|
9
|
+
_nonce: string,
|
|
10
|
+
): Promise<ProviderClaims> => {
|
|
11
|
+
throw new ProviderNotEnabledError({ provider: 'facebook' })
|
|
12
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { type ProviderClaims, ProviderClaimsSchema } from '@pya-platform/shared'
|
|
2
|
+
import { InvalidTokenError, UpstreamError } from '@pya-platform/shared'
|
|
3
|
+
import { type JSONWebKeySet, createLocalJWKSet, jwtVerify } from 'jose'
|
|
4
|
+
import * as v from 'valibot'
|
|
5
|
+
|
|
6
|
+
const DEFAULT_TOKEN_URL = 'https://oauth2.googleapis.com/token'
|
|
7
|
+
const DEFAULT_JWKS_URL = 'https://www.googleapis.com/oauth2/v3/certs'
|
|
8
|
+
const ISSUERS: ReadonlySet<string> = new Set(['accounts.google.com', 'https://accounts.google.com'])
|
|
9
|
+
|
|
10
|
+
interface TokenResponse {
|
|
11
|
+
readonly id_token?: string
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const buildBody = (
|
|
15
|
+
code: string,
|
|
16
|
+
clientId: string,
|
|
17
|
+
clientSecret: string,
|
|
18
|
+
redirectUri: string,
|
|
19
|
+
verifier: string,
|
|
20
|
+
): URLSearchParams =>
|
|
21
|
+
new URLSearchParams({
|
|
22
|
+
code,
|
|
23
|
+
client_id: clientId,
|
|
24
|
+
client_secret: clientSecret,
|
|
25
|
+
redirect_uri: redirectUri,
|
|
26
|
+
grant_type: 'authorization_code',
|
|
27
|
+
code_verifier: verifier,
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
interface JwksEntry {
|
|
31
|
+
readonly jwks: JSONWebKeySet
|
|
32
|
+
readonly fetchedAt: number
|
|
33
|
+
}
|
|
34
|
+
const jwksCache = new Map<string, JwksEntry>()
|
|
35
|
+
const JWKS_TTL_MS = 6 * 60 * 60 * 1000
|
|
36
|
+
|
|
37
|
+
const fetchJwks = async (url: string): Promise<JSONWebKeySet> => {
|
|
38
|
+
const cached = jwksCache.get(url)
|
|
39
|
+
if (cached !== undefined && Date.now() - cached.fetchedAt < JWKS_TTL_MS) {
|
|
40
|
+
return cached.jwks
|
|
41
|
+
}
|
|
42
|
+
const res = await fetch(url)
|
|
43
|
+
if (!res.ok) throw new UpstreamError({ provider: 'google', status: res.status })
|
|
44
|
+
const jwks = (await res.json()) as JSONWebKeySet
|
|
45
|
+
jwksCache.set(url, { jwks, fetchedAt: Date.now() })
|
|
46
|
+
return jwks
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Test-only: clear JWKS cache. */
|
|
50
|
+
export const __resetJwksCache = (): void => {
|
|
51
|
+
jwksCache.clear()
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export const exchangeAndVerifyGoogle = async (
|
|
55
|
+
env: Env,
|
|
56
|
+
redirectUri: string,
|
|
57
|
+
code: string,
|
|
58
|
+
verifier: string,
|
|
59
|
+
nonce: string,
|
|
60
|
+
): Promise<ProviderClaims> => {
|
|
61
|
+
const clientId = env.GOOGLE_OAUTH_CLIENT_ID ?? ''
|
|
62
|
+
const clientSecret = env.GOOGLE_OAUTH_CLIENT_SECRET ?? ''
|
|
63
|
+
const tokenUrl = env.GOOGLE_TOKEN_URL ?? DEFAULT_TOKEN_URL
|
|
64
|
+
const jwksUrl = env.GOOGLE_JWKS_URL ?? DEFAULT_JWKS_URL
|
|
65
|
+
|
|
66
|
+
const res = await fetch(tokenUrl, {
|
|
67
|
+
method: 'POST',
|
|
68
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
69
|
+
body: buildBody(code, clientId, clientSecret, redirectUri, verifier),
|
|
70
|
+
})
|
|
71
|
+
if (!res.ok) throw new UpstreamError({ provider: 'google', status: res.status })
|
|
72
|
+
|
|
73
|
+
const tokenJson = (await res.json()) as TokenResponse
|
|
74
|
+
const idToken = tokenJson.id_token
|
|
75
|
+
if (idToken === undefined) {
|
|
76
|
+
throw new InvalidTokenError({ reason: 'missing id_token' })
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const jwksSet = createLocalJWKSet(await fetchJwks(jwksUrl))
|
|
80
|
+
const verified = await verifyOrThrow(idToken, jwksSet, clientId)
|
|
81
|
+
const payload = verified.payload
|
|
82
|
+
|
|
83
|
+
if (typeof payload.iss !== 'string' || !ISSUERS.has(payload.iss)) {
|
|
84
|
+
throw new InvalidTokenError({ reason: 'bad iss' })
|
|
85
|
+
}
|
|
86
|
+
if (payload.nonce !== nonce) {
|
|
87
|
+
throw new InvalidTokenError({ reason: 'nonce mismatch' })
|
|
88
|
+
}
|
|
89
|
+
if (payload.email_verified !== true) {
|
|
90
|
+
throw new InvalidTokenError({ reason: 'email_unverified' })
|
|
91
|
+
}
|
|
92
|
+
if (typeof payload.sub !== 'string' || typeof payload.email !== 'string') {
|
|
93
|
+
throw new InvalidTokenError({ reason: 'missing sub/email' })
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return v.parse(ProviderClaimsSchema, {
|
|
97
|
+
provider: 'google',
|
|
98
|
+
subject: payload.sub,
|
|
99
|
+
email: payload.email,
|
|
100
|
+
emailVerified: true,
|
|
101
|
+
displayName: typeof payload.name === 'string' ? payload.name : undefined,
|
|
102
|
+
locale: typeof payload.locale === 'string' ? payload.locale : undefined,
|
|
103
|
+
})
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const verifyOrThrow = async (
|
|
107
|
+
idToken: string,
|
|
108
|
+
jwks: ReturnType<typeof createLocalJWKSet>,
|
|
109
|
+
audience: string,
|
|
110
|
+
): Promise<Awaited<ReturnType<typeof jwtVerify>>> => {
|
|
111
|
+
try {
|
|
112
|
+
return await jwtVerify(idToken, jwks, { audience })
|
|
113
|
+
} catch (err) {
|
|
114
|
+
const reason = err instanceof Error ? err.message : 'verify failed'
|
|
115
|
+
throw new InvalidTokenError({ reason })
|
|
116
|
+
}
|
|
117
|
+
}
|