From 19e7211a37af65880dcd72c51112462dd981cccf Mon Sep 17 00:00:00 2001 From: Makisuo Date: Sat, 10 Oct 2026 23:44:34 +0200 Subject: [PATCH 1/4] feat(auth): Hazel auth on @yielded/auth with Postgres storage GitHub and Google sign-in, passkeys and stateful sessions, stored in new auth_* tables (Drizzle schema in packages/db) with users as the auth subject. Auth storage runs on a SqlClient routed to the current request's connection, so it works with the api Worker's per-request sockets. Co-Authored-By: Claude Opus 5.5 --- bun.lock | 83 +++- packages/auth/package.json | 18 +- packages/auth/src/yielded/access-token.ts | 66 ++++ packages/auth/src/yielded/auth.ts | 68 ++++ packages/auth/src/yielded/contract.ts | 52 +++ packages/auth/src/yielded/hazel-auth.test.ts | 374 +++++++++++++++++++ packages/auth/src/yielded/index.ts | 17 + packages/auth/src/yielded/policy.ts | 127 +++++++ packages/auth/src/yielded/server.ts | 188 ++++++++++ packages/auth/src/yielded/sql.ts | 89 +++++ packages/auth/src/yielded/storage.ts | 251 +++++++++++++ packages/auth/src/yielded/tables.ts | 213 +++++++++++ packages/db/src/schema/auth.ts | 185 +++++++++ packages/db/src/schema/index.ts | 1 + packages/db/src/schema/users.ts | 4 + 15 files changed, 1732 insertions(+), 4 deletions(-) create mode 100644 packages/auth/src/yielded/access-token.ts create mode 100644 packages/auth/src/yielded/auth.ts create mode 100644 packages/auth/src/yielded/contract.ts create mode 100644 packages/auth/src/yielded/hazel-auth.test.ts create mode 100644 packages/auth/src/yielded/index.ts create mode 100644 packages/auth/src/yielded/policy.ts create mode 100644 packages/auth/src/yielded/server.ts create mode 100644 packages/auth/src/yielded/sql.ts create mode 100644 packages/auth/src/yielded/storage.ts create mode 100644 packages/auth/src/yielded/tables.ts create mode 100644 packages/db/src/schema/auth.ts diff --git a/bun.lock b/bun.lock index 7539135be..b8dfd696e 100644 --- a/bun.lock +++ b/bun.lock @@ -504,14 +504,23 @@ "version": "0.0.0", "dependencies": { "@clerk/backend": "^3.3.0", + "@effect/sql-pg": "catalog:effect", "@hazel/db": "workspace:*", "@hazel/domain": "workspace:*", "@hazel/effect-bun": "workspace:*", "@hazel/schema": "workspace:*", + "@simplewebauthn/server": "^14.0.1", + "@yielded/auth": "0.1.0-beta.32", + "@yielded/auth-persistence": "0.1.0-beta.32", + "@yielded/auth-simplewebauthn": "0.1.0-beta.32", + "@yielded/crypto": "0.1.0-beta.32", + "@yielded/jose": "0.1.0-beta.32", "effect": "catalog:effect", "jose": "^6.1.3", + "tldts": "^7.4.11", }, "devDependencies": { + "@effect/platform-node": "catalog:effect", "@types/bun": "1.3.9", "typescript": "^5.9.3", }, @@ -1260,6 +1269,8 @@ "@heroicons/react": ["@heroicons/react@2.2.0", "", { "peerDependencies": { "react": ">= 16 || ^19.0.0-rc" } }, "sha512-LMcepvRaS9LYHJGsF0zzmgKCUim/X3N/DQKc4jepAXJ7l8QxJ1PmxJzqplF2Z3FE4PqBAIGyJAQ/w4B5dsqbtQ=="], + "@hexagon/base64": ["@hexagon/base64@1.1.28", "", {}, "sha512-lhqDEAvWixy3bZ+UOYbPwUbBkwBq5C1LAJ/xPC8Oi+lL54oyakv/npbA0aU2hgCsx/1NUd4IBvV03+aUBWxerw=="], + "@hono/node-server": ["@hono/node-server@1.19.9", "", { "peerDependencies": { "hono": "^4" } }, "sha512-vHL6w3ecZsky+8P5MD+eFfaGTyCeOHUIFYMGpQGbrBTSmNNoxv0if69rEZ5giu36weC5saFuznL411gRX7bJDw=="], "@hono/standard-validator": ["@hono/standard-validator@0.1.5", "", { "peerDependencies": { "@standard-schema/spec": "1.0.0", "hono": ">=3.9.0" } }, "sha512-EIyZPPwkyLn6XKwFj5NBEWHXhXbgmnVh2ceIFo5GO7gKI9WmzTjPDKnppQB0KrqKeAkq3kpoW4SIbu5X1dgx3w=="], @@ -1372,6 +1383,8 @@ "@legendapp/list": ["@legendapp/list@3.0.0-beta.41", "", { "dependencies": { "use-sync-external-store": "^1.5.0" }, "peerDependencies": { "react": "*" } }, "sha512-m/wxzotZDE2z9Wh/l8LkLpKNydbc+nOgXm2Ao6yxYv3Rml8go+IdaPzHz4PsLplzWdSS3fOz5omKIxnBC6B9Cw=="], + "@levischuck/tiny-cbor": ["@levischuck/tiny-cbor@0.2.11", "", {}, "sha512-llBRm4dT4Z89aRsm6u2oEZ8tfwL/2l6BwpZ7JcyieouniDECM5AqNgr/y08zalEIvW3RSK4upYyybDcmjXqAow=="], + "@libsql/client": ["@libsql/client@0.17.4", "", { "dependencies": { "@libsql/core": "^0.17.4", "@libsql/hrana-client": "^0.10.0", "js-base64": "^3.7.5", "libsql": "^0.5.28", "promise-limit": "^2.7.0" } }, "sha512-lYayFWasDV78A+TjlEhr6ubb3odBV6OHjb+wdp8VQcyWWAEIjuwbCHaraEUS4m4yWoo0BvZo96It4VdzZRmRWw=="], "@libsql/core": ["@libsql/core@0.17.4", "", { "dependencies": { "js-base64": "^3.7.5" } }, "sha512-LqF9gIvnJ38nmAH1y/ChizHqDO/MO1wLgA96XrraulEEbqXxLjleSH92YWTolbuJKgPUmGu4aJk9W3UnAcxLOQ=="], @@ -1590,6 +1603,36 @@ "@paper-design/shaders-react": ["@paper-design/shaders-react@0.0.71", "", { "dependencies": { "@paper-design/shaders": "0.0.71" }, "peerDependencies": { "@types/react": "^18 || ^19", "react": "^18 || ^19" }, "optionalPeers": ["@types/react"] }, "sha512-kTqjIlyZcpkwqJie+3ldEDscTtx1oOi8eRBD5QgWKI21GaNn/SSg26092M5zzqr3e8dVANv0ktS2ICSjbMFKbw=="], + "@peculiar/asn1-android": ["@peculiar/asn1-android@2.10.0", "", { "dependencies": { "@peculiar/asn1-schema": "^2.10.0", "asn1js": "^3.0.10", "tslib": "^2.8.1" } }, "sha512-IHKdYL5MRW76DOQCv2HSE/vUMOf2jqH4E7yhtQYe/xx+RxpPiXfkUdhex0N4/s5crsnXCGftRNzy4RJn1s6CZA=="], + + "@peculiar/asn1-asym-key": ["@peculiar/asn1-asym-key@2.10.0", "", { "dependencies": { "@peculiar/asn1-pkcs8": "^2.10.0", "@peculiar/asn1-schema": "^2.10.0", "@peculiar/asn1-x509": "^2.10.0", "asn1js": "^3.0.10", "tslib": "^2.8.1" } }, "sha512-1R8xUTeqcUTSMLFTTCzJyFuKiq3y25EQKs9DezUetJ8qMqbJyxo3XkzykiwwT/Xzlt5/vRzS/sW4M1r+pq1FTg=="], + + "@peculiar/asn1-cms": ["@peculiar/asn1-cms@2.10.0", "", { "dependencies": { "@peculiar/asn1-schema": "^2.10.0", "@peculiar/asn1-x509": "^2.10.0", "@peculiar/asn1-x509-attr": "^2.10.0", "asn1js": "^3.0.10", "tslib": "^2.8.1" } }, "sha512-CkX0H4NCIOMHOU3rh2xXZywinYZ/EnJlaOlJiGI0e5L4XjFqHf4iH30LMbXWChVppdA9ljbanjtzQFOhDNfTWg=="], + + "@peculiar/asn1-csr": ["@peculiar/asn1-csr@2.10.0", "", { "dependencies": { "@peculiar/asn1-schema": "^2.10.0", "@peculiar/asn1-x509": "^2.10.0", "asn1js": "^3.0.10", "tslib": "^2.8.1" } }, "sha512-jTPTr/9rxKM+niQLMF3jiAMb0lWHUhUHIuSOhdGCIfpB4FAQYf9SoiXOgxP8bS/68IB+bj+1OHvVnDVCQfVUZw=="], + + "@peculiar/asn1-ecc": ["@peculiar/asn1-ecc@2.10.0", "", { "dependencies": { "@peculiar/asn1-schema": "^2.10.0", "@peculiar/asn1-x509": "^2.10.0", "asn1js": "^3.0.10", "tslib": "^2.8.1" } }, "sha512-GFd3iOjFrWX+QWH2R2dO5QSJyyRGv9CIBKtRlGlPNCvb2RvmCbBDexe91MJgV76c69d998Xsa+CvuQ98seoiPg=="], + + "@peculiar/asn1-pfx": ["@peculiar/asn1-pfx@2.10.0", "", { "dependencies": { "@peculiar/asn1-cms": "^2.10.0", "@peculiar/asn1-pkcs8": "^2.10.0", "@peculiar/asn1-rsa": "^2.10.0", "@peculiar/asn1-schema": "^2.10.0", "asn1js": "^3.0.10", "tslib": "^2.8.1" } }, "sha512-1y3QK9ZH1IPleAMmRoJlPS10eGkAWnULETW8zDFNUK1ffqpG7zmDY+kYBMYQgwlcQZ2iitLm2z2EBP0xzRXpaA=="], + + "@peculiar/asn1-pkcs8": ["@peculiar/asn1-pkcs8@2.10.0", "", { "dependencies": { "@peculiar/asn1-schema": "^2.10.0", "@peculiar/asn1-x509": "^2.10.0", "asn1js": "^3.0.10", "tslib": "^2.8.1" } }, "sha512-Ri+BZT9bnwqlHWmhs7lvWjvJRxKev2hA2+4EbZajgeYWCbR8hyv0znF4DhsFouOhMj2nSDV/iGJ6DMQtwITnCw=="], + + "@peculiar/asn1-pkcs9": ["@peculiar/asn1-pkcs9@2.10.0", "", { "dependencies": { "@peculiar/asn1-cms": "^2.10.0", "@peculiar/asn1-pfx": "^2.10.0", "@peculiar/asn1-pkcs8": "^2.10.0", "@peculiar/asn1-schema": "^2.10.0", "@peculiar/asn1-x509": "^2.10.0", "@peculiar/asn1-x509-attr": "^2.10.0", "asn1js": "^3.0.10", "tslib": "^2.8.1" } }, "sha512-XIXsbDQFezYk6fudczkuvawkRD4GNpISGCqfYhdkJlvcGF/RFknU+0DDJagOaJqBf+PdPzk4J9oMqDGcvfR9HQ=="], + + "@peculiar/asn1-rsa": ["@peculiar/asn1-rsa@2.10.0", "", { "dependencies": { "@peculiar/asn1-schema": "^2.10.0", "@peculiar/asn1-x509": "^2.10.0", "asn1js": "^3.0.10", "tslib": "^2.8.1" } }, "sha512-4Jvmwlh3gZAhNZ4/u7JSyLuce9hofOFZ4o3PYKAccCImGnyaT5CMym/ATgvAvqpOYFNW531oPZvJlEBhJy9VfA=="], + + "@peculiar/asn1-schema": ["@peculiar/asn1-schema@2.10.0", "", { "dependencies": { "@peculiar/utils": "^2.0.2", "asn1js": "^3.0.10", "tslib": "^2.8.1" } }, "sha512-GhokD41lV4gQrrLYm3wCkHfBOnJrnhDMgt4XeMW8gzfE1UdJqIuSwsE+ggf82XBjUXRJylcm+KIGQFa4utIVLw=="], + + "@peculiar/asn1-x509": ["@peculiar/asn1-x509@2.10.0", "", { "dependencies": { "@peculiar/asn1-schema": "^2.10.0", "@peculiar/utils": "^2.0.2", "asn1js": "^3.0.10", "tslib": "^2.8.1" } }, "sha512-ucNVg8+ANveTpMN3fy9lA2alryONdXc2A4cEG2hMniWbvQt+YOZoe8BI80YYNO8FBcuDY1qbDQ4uQGQVraHxGA=="], + + "@peculiar/asn1-x509-attr": ["@peculiar/asn1-x509-attr@2.10.0", "", { "dependencies": { "@peculiar/asn1-schema": "^2.10.0", "@peculiar/asn1-x509": "^2.10.0", "asn1js": "^3.0.10", "tslib": "^2.8.1" } }, "sha512-/85GtKOKmgvuSJNlaFfwGWNdRSZZ+hpF02NyM01XiCdzpaZONiBltDyfluFPvFx966CR+ZHNSG1jniwpy07oGg=="], + + "@peculiar/asn1-x509-post-quantum": ["@peculiar/asn1-x509-post-quantum@2.10.0", "", { "dependencies": { "@peculiar/asn1-asym-key": "^2.10.0", "@peculiar/asn1-schema": "^2.10.0", "@peculiar/asn1-x509": "^2.10.0", "asn1js": "^3.0.10", "tslib": "^2.8.1" } }, "sha512-cXKcZ30wKbUkRK5RK4Z3W1imewXvHAB9apEAuIqJ0OOkOQCpJrwLOuN38QlsEXqOI+3gtUv8Kf3UZohUmCbf3w=="], + + "@peculiar/utils": ["@peculiar/utils@2.0.3", "", { "dependencies": { "tslib": "^2.8.1" } }, "sha512-+oL3HPFRIZ1St2K50lWCXiioIgSoxzz7R1J3uF6neO2yl1sgmpgY6XXJH4BdpoDkMWznQTeYF6oWNDZLCdQ4eQ=="], + + "@peculiar/x509": ["@peculiar/x509@2.1.0", "", { "dependencies": { "@peculiar/asn1-cms": "^2.9.4", "@peculiar/asn1-csr": "^2.9.4", "@peculiar/asn1-ecc": "^2.9.4", "@peculiar/asn1-pkcs9": "^2.9.4", "@peculiar/asn1-rsa": "^2.9.4", "@peculiar/asn1-schema": "^2.9.4", "@peculiar/asn1-x509": "^2.9.4", "@peculiar/asn1-x509-post-quantum": "^2.9.4", "pvtsutils": "^1.3.6", "tslib": "^2.8.1", "tsyringe": "^4.10.0" } }, "sha512-IYbg1R03CSQGWwl24kGyqrdVtixNSbRaDvBg1r5wyYjTP+VwPQkka1BzTgU5+vxiuwqg04OxdvdJ1xYYFIdUSA=="], + "@pinojs/redact": ["@pinojs/redact@0.4.0", "", {}, "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg=="], "@pkgjs/parseargs": ["@pkgjs/parseargs@0.11.0", "", {}, "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg=="], @@ -2070,6 +2113,8 @@ "@shikijs/vscode-textmate": ["@shikijs/vscode-textmate@10.0.2", "", {}, "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg=="], + "@simplewebauthn/server": ["@simplewebauthn/server@14.0.3", "", { "dependencies": { "@hexagon/base64": "^1.1.27", "@levischuck/tiny-cbor": "^0.2.2", "@peculiar/asn1-android": "^2.6.0", "@peculiar/asn1-ecc": "^2.6.1", "@peculiar/asn1-rsa": "^2.6.1", "@peculiar/asn1-schema": "^2.6.0", "@peculiar/asn1-x509": "^2.6.1", "@peculiar/asn1-x509-post-quantum": "^2.9.4", "@peculiar/x509": "^2.1.0", "reflect-metadata": "^0.2.2" } }, "sha512-D39/0fiDiYmGV+nraY9s63D1Ho2AX/waMLy22/9gUadTUuvbAnstfb4bpbHYbwZuV5ZLDYN1SmzDKuiHzKAA4Q=="], + "@sindresorhus/is": ["@sindresorhus/is@7.1.1", "", {}, "sha512-rO92VvpgMc3kfiTjGT52LEtJ8Yc5kCWhZjLQ3LwlA4pSgPpQO7bVpYXParOD8Jwf+cVQECJo3yP/4I8aZtUQTQ=="], "@smithy/types": ["@smithy/types@4.19.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-r7jh49VJxGerfAcTQA6gXcKc+98zOp/tqRwzYjgOE+iSQsP6cEU1hq2QzbuipmP68QtYdY9wKEhiCQZIzHgZ4Q=="], @@ -2402,6 +2447,18 @@ "@vscode/l10n": ["@vscode/l10n@0.0.18", "", {}, "sha512-KYSIHVmslkaCDyw013pphY+d7x1qV8IZupYfeIfzNA+nsaWHbn5uPuQRvdRFsa9zFzGeudPuoGoZ1Op4jrJXIQ=="], + "@yielded/auth": ["@yielded/auth@0.1.0-beta.32", "", { "dependencies": { "@yielded/crypto": "0.1.0-beta.32", "@yielded/jose": "0.1.0-beta.32", "@yielded/oauth": "0.1.0-beta.32" }, "peerDependencies": { "effect": "^4.0.0" } }, "sha512-HnXi2oJmtaLbmvNaaymx0mWyyIalmIpCAYa1+8OErq2ABFZX1YeUOo03bC9Rx2yb04kA7wNUQPHlSoPoLbjnuA=="], + + "@yielded/auth-persistence": ["@yielded/auth-persistence@0.1.0-beta.32", "", { "dependencies": { "@yielded/auth": "0.1.0-beta.32", "@yielded/crypto": "0.1.0-beta.32" }, "peerDependencies": { "effect": "^4.0.0" } }, "sha512-WzJUn1K6+2PK+VNq1RiTJJPvKmju6UvQzoP2Vas4g9HIoO/dk19nphkYAnaYXBj681U3mXw/yyqNVDiTNx+XXA=="], + + "@yielded/auth-simplewebauthn": ["@yielded/auth-simplewebauthn@0.1.0-beta.32", "", { "dependencies": { "@yielded/auth": "0.1.0-beta.32", "tldts": "7.4.11" }, "peerDependencies": { "@simplewebauthn/browser": ">=14.0.0 <15", "@simplewebauthn/server": ">=14.0.1 <15", "effect": "^4.0.0" }, "optionalPeers": ["@simplewebauthn/browser", "@simplewebauthn/server"] }, "sha512-DTbsU5PjPrubT46huO/w/JhKl0+NBkTI0tEdkr+uAV5xJzWm5+9pdDNS+kYhNAoTwPwlvlCK6AsDT2vZRIgYgA=="], + + "@yielded/crypto": ["@yielded/crypto@0.1.0-beta.32", "", { "peerDependencies": { "effect": "^4.0.0" } }, "sha512-m3N1s94E0RPqgCFqHtQmBq3GQiXApzb2rYJOkTEtAyqtj9Yv22FWBdEUT7s5Y/lR3YbQK0JReymp/Saj0Kwu5g=="], + + "@yielded/jose": ["@yielded/jose@0.1.0-beta.32", "", { "dependencies": { "@yielded/crypto": "0.1.0-beta.32" }, "peerDependencies": { "effect": "^4.0.0" } }, "sha512-bOtE4GJldDM+Mcb5UirTfNapRPmRJ4RPic/XcPamIdpqI0G/37DcaIMlyVX6Jc1xQFExVW7mQsl9VO61F3e9LA=="], + + "@yielded/oauth": ["@yielded/oauth@0.1.0-beta.32", "", { "dependencies": { "@yielded/jose": "0.1.0-beta.32" }, "peerDependencies": { "effect": "^4.0.0" } }, "sha512-4vCkTwY0Cs3wixMAGU6KywFuVG+m0QuuH8lV166wXCSPbk24DKAK/faZqtiVmwwpRVjJSeonvcGNI4II01mzHw=="], + "abbrev": ["abbrev@3.0.1", "", {}, "sha512-AO2ac6pjRB3SJmGJo+v5/aK6Omggp6fsLrs6wN9bd35ulu4cCwaAU9+7ZhXjeqHVkaHThLuzH0nZr0YpCDhygg=="], "abort-controller": ["abort-controller@3.0.0", "", { "dependencies": { "event-target-shim": "^5.0.0" } }, "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg=="], @@ -2456,6 +2513,8 @@ "asn1": ["asn1@0.2.6", "", { "dependencies": { "safer-buffer": "~2.1.0" } }, "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ=="], + "asn1js": ["asn1js@3.0.10", "", { "dependencies": { "pvtsutils": "^1.3.6", "pvutils": "^1.1.5", "tslib": "^2.8.1" } }, "sha512-S2s3aOytiKdFRdulw2qPE51MzjzVOisppcVv7jVFR+Kw0kxwvFrDcYA0h7Ndqbmj0HkMIXYWaoj7fli8kgx1eg=="], + "assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="], "ast-kit": ["ast-kit@3.0.0-beta.1", "", { "dependencies": { "@babel/parser": "^8.0.0-beta.4", "estree-walker": "^3.0.3", "pathe": "^2.0.3" } }, "sha512-trmleAnZ2PxN/loHWVhhx1qeOHSRXq4TDsBBxq3GqeJitfk3+jTQ+v/C1km/KYq9M7wKqCewMh+/NAvVH7m+bw=="], @@ -3788,6 +3847,10 @@ "punycode2": ["punycode2@1.0.1", "", {}, "sha512-+TXpd9YRW4YUZZPoRHJ3DILtWwootGc2DsgvfHmklQ8It1skINAuqSdqizt5nlTaBmwrYACHkHApCXjc9gHk2Q=="], + "pvtsutils": ["pvtsutils@1.3.6", "", { "dependencies": { "tslib": "^2.8.1" } }, "sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg=="], + + "pvutils": ["pvutils@1.2.0", "", {}, "sha512-BbubeCEyTuQjVMakvJQ/Sxbc93F2pwmbsxONT/ZRrwU7Ua38d8unYTwXpTVLAKJ4BDuH9IGztCjQcd/N/39Dvg=="], + "quansync": ["quansync@1.0.0", "", {}, "sha512-5xZacEEufv3HSTPQuchrvV6soaiACMFnq1H8wkVioctoH3TRha9Sz66lOxRwPK/qZj7HPiSveih9yAyh98gvqA=="], "query-selector-shadow-dom": ["query-selector-shadow-dom@1.0.1", "", {}, "sha512-lT5yCqEBgfoMYpf3F2xQRK7zEr1rhIIZuceDK6+xRkJQ4NMbHTwXqk4NkwDwQMNqXgG9r9fyHnzwNVs6zV5KRw=="], @@ -3852,6 +3915,8 @@ "redis-parser": ["redis-parser@3.0.0", "", { "dependencies": { "redis-errors": "^1.0.0" } }, "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A=="], + "reflect-metadata": ["reflect-metadata@0.2.2", "", {}, "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q=="], + "reflect.getprototypeof": ["reflect.getprototypeof@1.0.10", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.9", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0", "get-intrinsic": "^1.2.7", "get-proto": "^1.0.1", "which-builtin-type": "^1.2.1" } }, "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw=="], "regenerate": ["regenerate@1.4.2", "", {}, "sha512-zrceR/XhGYU/d/opr2EKO7aRHUeiBI8qjtfHqADTwZd6Szfy16la6kqD0MIUs5z5hx6AaKa+PixpPrR289+I0A=="], @@ -4174,9 +4239,9 @@ "tlds": ["tlds@1.261.0", "", { "bin": { "tlds": "bin.js" } }, "sha512-QXqwfEl9ddlGBaRFXIvNKK6OhipSiLXuRuLJX5DErz0o0Q0rYxulWLdFryTkV5PkdZct5iMInwYEGe/eR++1AA=="], - "tldts": ["tldts@7.0.17", "", { "dependencies": { "tldts-core": "^7.0.17" }, "bin": { "tldts": "bin/cli.js" } }, "sha512-Y1KQBgDd/NUc+LfOtKS6mNsC9CCaH+m2P1RoIZy7RAPo3C3/t8X45+zgut31cRZtZ3xKPjfn3TkGTrctC2TQIQ=="], + "tldts": ["tldts@7.4.18", "", { "dependencies": { "tldts-core": "^7.4.18" }, "bin": { "tldts": "bin/cli.js" } }, "sha512-somxA8xKAUzlAxl0TX/jMSYIbRNFIVGnI96pVGJPeMc5BsHmfVXn6mOJT6U++ELnl04eGfzpfcEuZe8WiASkXg=="], - "tldts-core": ["tldts-core@7.0.17", "", {}, "sha512-DieYoGrP78PWKsrXr8MZwtQ7GLCUeLxihtjC1jZsW1DnvSMdKPitJSe8OSYDM2u5H6g3kWJZpePqkp43TfLh0g=="], + "tldts-core": ["tldts-core@7.4.18", "", {}, "sha512-KI2PKvCmvQI/xD6ypPqkU0i7oxNXR/O58RLRaHR4svsgVUQ2JE/XC8BtMbG6PSehD27I8sZJRcC1ASnCy6lp3g=="], "tmp": ["tmp@0.2.5", "", {}, "sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow=="], @@ -4202,6 +4267,8 @@ "tsx": ["tsx@4.20.6", "", { "dependencies": { "esbuild": "~0.25.0", "get-tsconfig": "^4.7.5" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "bin": { "tsx": "dist/cli.mjs" } }, "sha512-ytQKuwgmrrkDTFP4LjR0ToE2nqgy886GpvRSpU0JAnrdBYppuY5rLkRUYPU1yCryb24SsKBTL/hlDQAEFVwtZg=="], + "tsyringe": ["tsyringe@4.10.0", "", { "dependencies": { "tslib": "^1.9.3" } }, "sha512-axr3IdNuVIxnaK5XGEUFTu3YmAQ6lllgrvqfEoR16g/HGnYY/6We4oWENtAnzK6/LpJ2ur9PAb80RBt7/U4ugw=="], + "turbo": ["turbo@2.8.16", "", { "optionalDependencies": { "turbo-darwin-64": "2.8.16", "turbo-darwin-arm64": "2.8.16", "turbo-linux-64": "2.8.16", "turbo-linux-arm64": "2.8.16", "turbo-windows-64": "2.8.16", "turbo-windows-arm64": "2.8.16" }, "bin": { "turbo": "bin/turbo" } }, "sha512-u6e9e3cTTpE2adQ1DYm3A3r8y3LAONEx1jYvJx6eIgSY4bMLxIxs0riWzI0Z/IK903ikiUzRPZ2c1Ph5lVLkhA=="], "turbo-darwin-64": ["turbo-darwin-64@2.8.16", "", { "os": "darwin", "cpu": "x64" }, "sha512-KWa4hUMWrpADC6Q/wIHRkBLw6X6MV9nx6X7hSXbTrrMz0KdaKhmfudUZ3sS76bJFmgArBU25cSc0AUyyrswYxg=="], @@ -4722,6 +4789,8 @@ "@metascraper/helpers/mime": ["mime@3.0.0", "", { "bin": { "mime": "cli.js" } }, "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A=="], + "@metascraper/helpers/tldts": ["tldts@7.0.17", "", { "dependencies": { "tldts-core": "^7.0.17" }, "bin": { "tldts": "bin/cli.js" } }, "sha512-Y1KQBgDd/NUc+LfOtKS6mNsC9CCaH+m2P1RoIZy7RAPo3C3/t8X45+zgut31cRZtZ3xKPjfn3TkGTrctC2TQIQ=="], + "@npmcli/agent/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], "@octokit/plugin-paginate-rest/@octokit/types": ["@octokit/types@16.0.0", "", { "dependencies": { "@octokit/openapi-types": "^27.0.0" } }, "sha512-sKq+9r1Mm4efXW1FCk7hFSeJo4QKreL/tTbR0rz/qx/r1Oa2VV83LTA/H/MuCOX7uCIJmQVRKBcbmWoySjAnSg=="], @@ -4914,6 +4983,8 @@ "@vitest/coverage-istanbul/magicast": ["magicast@0.5.1", "", { "dependencies": { "@babel/parser": "^7.28.5", "@babel/types": "^7.28.5", "source-map-js": "^1.2.1" } }, "sha512-xrHS24IxaLrvuo613F719wvOIv9xPHFWQHuvGUBmPnCA/3MQxKI3b+r7n1jAoDHmsbC5bRhTZYR77invLAxVnw=="], + "@yielded/auth-simplewebauthn/tldts": ["tldts@7.4.11", "", { "dependencies": { "tldts-core": "^7.4.11" }, "bin": { "tldts": "bin/cli.js" } }, "sha512-aBiNayCfTQxuIJBm06M+xR14cYaYlDlSXZbgsnKzKNxDKUVq7KFwTjwBSsb7m9Y5xO8WfPnBc63WaYFMTGlvqw=="], + "alchemy/picomatch": ["picomatch@4.0.7", "", {}, "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA=="], "alchemy/rolldown": ["rolldown@1.2.5", "", { "dependencies": { "@oxc-project/types": "=0.146.0", "@rolldown/pluginutils": "^1.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm-eabi": "1.2.5", "@rolldown/binding-android-arm64": "1.2.5", "@rolldown/binding-darwin-arm64": "1.2.5", "@rolldown/binding-darwin-x64": "1.2.5", "@rolldown/binding-freebsd-x64": "1.2.5", "@rolldown/binding-linux-arm-gnueabihf": "1.2.5", "@rolldown/binding-linux-arm64-gnu": "1.2.5", "@rolldown/binding-linux-arm64-musl": "1.2.5", "@rolldown/binding-linux-ppc64-gnu": "1.2.5", "@rolldown/binding-linux-s390x-gnu": "1.2.5", "@rolldown/binding-linux-x64-gnu": "1.2.5", "@rolldown/binding-linux-x64-musl": "1.2.5", "@rolldown/binding-openharmony-arm64": "1.2.5", "@rolldown/binding-win32-arm64-msvc": "1.2.5", "@rolldown/binding-win32-x64-msvc": "1.2.5" }, "bin": { "rolldown": "./bin/cli.mjs" } }, "sha512-VD2IE5PUG4Oj8zz2VGykiYd5wbnjdIiSsNQb8Qu5B+noEp+A78mu2iVvpp27g8es14Tk9rofNs5Tku9iQCS4fA=="], @@ -5138,6 +5209,8 @@ "tinyglobby/picomatch": ["picomatch@4.0.7", "", {}, "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA=="], + "tough-cookie/tldts": ["tldts@7.0.17", "", { "dependencies": { "tldts-core": "^7.0.17" }, "bin": { "tldts": "bin/cli.js" } }, "sha512-Y1KQBgDd/NUc+LfOtKS6mNsC9CCaH+m2P1RoIZy7RAPo3C3/t8X45+zgut31cRZtZ3xKPjfn3TkGTrctC2TQIQ=="], + "tsdown/rolldown": ["rolldown@1.0.0-rc.3", "", { "dependencies": { "@oxc-project/types": "=0.112.0", "@rolldown/pluginutils": "1.0.0-rc.3" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.0.0-rc.3", "@rolldown/binding-darwin-arm64": "1.0.0-rc.3", "@rolldown/binding-darwin-x64": "1.0.0-rc.3", "@rolldown/binding-freebsd-x64": "1.0.0-rc.3", "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.3", "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.3", "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.3", "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.3", "@rolldown/binding-linux-x64-musl": "1.0.0-rc.3", "@rolldown/binding-openharmony-arm64": "1.0.0-rc.3", "@rolldown/binding-wasm32-wasi": "1.0.0-rc.3", "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.3", "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.3" }, "bin": { "rolldown": "bin/cli.mjs" } }, "sha512-Po/YZECDOqVXjIXrtC5h++a5NLvKAQNrd9ggrIG3sbDfGO5BqTUsrI6l8zdniKRp3r5Tp/2JTrXqx4GIguFCMw=="], "tsdown/tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="], @@ -5146,6 +5219,8 @@ "tsx/get-tsconfig": ["get-tsconfig@4.13.0", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ=="], + "tsyringe/tslib": ["tslib@1.14.1", "", {}, "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg=="], + "unist-util-remove-position/unist-util-visit": ["unist-util-visit@5.0.0", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-MR04uvD+07cwl/yhVuVWAtw+3GOR/knlL55Nd/wAdblk27GCVt3lqpTivy/tkJcZoNPzTwS1Y+KMojlLDhoTzg=="], "unrun/rolldown": ["rolldown@1.0.0-rc.3", "", { "dependencies": { "@oxc-project/types": "=0.112.0", "@rolldown/pluginutils": "1.0.0-rc.3" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.0.0-rc.3", "@rolldown/binding-darwin-arm64": "1.0.0-rc.3", "@rolldown/binding-darwin-x64": "1.0.0-rc.3", "@rolldown/binding-freebsd-x64": "1.0.0-rc.3", "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.3", "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.3", "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.3", "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.3", "@rolldown/binding-linux-x64-musl": "1.0.0-rc.3", "@rolldown/binding-openharmony-arm64": "1.0.0-rc.3", "@rolldown/binding-wasm32-wasi": "1.0.0-rc.3", "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.3", "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.3" }, "bin": { "rolldown": "bin/cli.mjs" } }, "sha512-Po/YZECDOqVXjIXrtC5h++a5NLvKAQNrd9ggrIG3sbDfGO5BqTUsrI6l8zdniKRp3r5Tp/2JTrXqx4GIguFCMw=="], @@ -5590,6 +5665,8 @@ "@metascraper/helpers/jsdom/whatwg-url": ["whatwg-url@15.1.0", "", { "dependencies": { "tr46": "^6.0.0", "webidl-conversions": "^8.0.0" } }, "sha512-2ytDk0kiEj/yu90JOAp44PVPUkO9+jVhyf+SybKlRHSDlvOOZhdPIrr7xTH64l4WixO2cP+wQIcgujkGBPPz6g=="], + "@metascraper/helpers/tldts/tldts-core": ["tldts-core@7.0.17", "", {}, "sha512-DieYoGrP78PWKsrXr8MZwtQ7GLCUeLxihtjC1jZsW1DnvSMdKPitJSe8OSYDM2u5H6g3kWJZpePqkp43TfLh0g=="], + "@octokit/plugin-paginate-rest/@octokit/types/@octokit/openapi-types": ["@octokit/openapi-types@27.0.0", "", {}, "sha512-whrdktVs1h6gtR+09+QsNk2+FO+49j6ga1c55YZudfEG+oKJVvJLQi3zkOm5JjiUXAagWK2tI2kTGKJ2Ys7MGA=="], "@octokit/plugin-rest-endpoint-methods/@octokit/types/@octokit/openapi-types": ["@octokit/openapi-types@27.0.0", "", {}, "sha512-whrdktVs1h6gtR+09+QsNk2+FO+49j6ga1c55YZudfEG+oKJVvJLQi3zkOm5JjiUXAagWK2tI2kTGKJ2Ys7MGA=="], @@ -6064,6 +6141,8 @@ "ssh-remote-port-forward/@types/ssh2/@types/node": ["@types/node@24.10.1", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ=="], + "tough-cookie/tldts/tldts-core": ["tldts-core@7.0.17", "", {}, "sha512-DieYoGrP78PWKsrXr8MZwtQ7GLCUeLxihtjC1jZsW1DnvSMdKPitJSe8OSYDM2u5H6g3kWJZpePqkp43TfLh0g=="], + "tsdown/rolldown/@oxc-project/types": ["@oxc-project/types@0.112.0", "", {}, "sha512-m6RebKHIRsax2iCwVpYW2ErQwa4ywHJrE4sCK3/8JK8ZZAWOKXaRJFl/uP51gaVyyXlaS4+chU1nSCdzYf6QqQ=="], "tsdown/rolldown/@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.0.0-rc.3", "", { "os": "android", "cpu": "arm64" }, "sha512-0T1k9FinuBZ/t7rZ8jN6OpUKPnUjNdYHoj/cESWrQ3ZraAJ4OMm6z7QjSfCxqj8mOp9kTKc1zHK3kGz5vMu+nQ=="], diff --git a/packages/auth/package.json b/packages/auth/package.json index f000b3df1..7ea2d5761 100644 --- a/packages/auth/package.json +++ b/packages/auth/package.json @@ -8,19 +8,33 @@ ".": "./src/index.ts", "./backend": "./src/consumers/backend-auth.ts", "./proxy": "./src/consumers/proxy-auth.ts", - "./errors": "./src/errors.ts" + "./errors": "./src/errors.ts", + "./contract": "./src/yielded/contract.ts", + "./server": "./src/yielded/index.ts" }, "dependencies": { "@clerk/backend": "^3.3.0", + "@effect/sql-pg": "catalog:effect", "@hazel/db": "workspace:*", "@hazel/domain": "workspace:*", "@hazel/effect-bun": "workspace:*", "@hazel/schema": "workspace:*", + "@simplewebauthn/server": "^14.0.1", + "@yielded/auth": "0.1.0-beta.32", + "@yielded/auth-persistence": "0.1.0-beta.32", + "@yielded/auth-simplewebauthn": "0.1.0-beta.32", + "@yielded/crypto": "0.1.0-beta.32", + "@yielded/jose": "0.1.0-beta.32", "effect": "catalog:effect", - "jose": "^6.1.3" + "jose": "^6.1.3", + "tldts": "^7.4.11" }, "devDependencies": { + "@effect/platform-node": "catalog:effect", "@types/bun": "1.3.9", "typescript": "^5.9.3" + }, + "scripts": { + "test:integration": "vitest run src/yielded" } } diff --git a/packages/auth/src/yielded/access-token.ts b/packages/auth/src/yielded/access-token.ts new file mode 100644 index 000000000..e284f5003 --- /dev/null +++ b/packages/auth/src/yielded/access-token.ts @@ -0,0 +1,66 @@ +import * as Jwk from "@yielded/jose/Jwk" +import * as Jwks from "@yielded/jose/Jwks" +import * as Jwt from "@yielded/jose/Jwt" +import { DateTime, Effect, Redacted, Schema } from "effect" + +/** + * Short-lived access token for services on other hosts (electric.hazel.sh, rivet.hazel.sh). + * The browser's session cookie is host-only on api.hazel.sh, so those services can't see + * it. The backend exchanges a valid session for this ES256 JWT; the services verify it + * locally against the published JWKS, without a database or network call. + */ + +export const issuer = "https://api.hazel.sh" +export const audience = "hazel-services" +export const lifetimeSeconds = 300 + +export const AccessClaims = Schema.Struct({ + iss: Schema.String, + aud: Schema.String, + sub: Schema.String, + sid: Schema.String, + iat: Schema.Finite, + exp: Schema.Finite, +}) + +export const makeIssuer = (input: { + readonly kid: string + readonly privateJwk: Redacted.Redacted +}) => + Effect.gen(function* () { + const key = yield* Jwk.importPrivate(input.privateJwk, "ES256") + + return { + mint: (session: { readonly subjectId: string; readonly sessionId: string }) => + Effect.gen(function* () { + const now = Math.floor(DateTime.toEpochMillis(yield* DateTime.now) / 1000) + return yield* Jwt.sign( + AccessClaims, + { + iss: issuer, + aud: audience, + sub: session.subjectId, + sid: session.sessionId, + iat: now, + exp: now + lifetimeSeconds, + }, + key, + { alg: "ES256", kid: input.kid, typ: "at+jwt" }, + ) + }), + } + }) + +/** Verification used by electric-proxy / actors. Requires `Jwks` (local or remote). */ +export const verifyAccessToken = (token: string) => + Jwt.verifyWithKeySet(AccessClaims, Redacted.make(token), { + algorithms: ["ES256"], + issuer, + audience, + typ: "at+jwt", + requiredClaims: ["iss", "sub", "aud", "exp", "iat"], + }) + +export const generateSigningKey = (kid: string) => Jwk.generateKeyPair({ algorithm: "ES256", kid }) + +export { Jwks } diff --git a/packages/auth/src/yielded/auth.ts b/packages/auth/src/yielded/auth.ts new file mode 100644 index 000000000..2f1f42a88 --- /dev/null +++ b/packages/auth/src/yielded/auth.ts @@ -0,0 +1,68 @@ +import { Auth, OAuth, Passkey, Sessions } from "@yielded/auth" +import * as GitHub from "@yielded/auth/GitHub" +import * as Google from "@yielded/auth/Google" + +import { HazelAuthApi, Registration } from "./contract.ts" + +/** One namespace for sign-in, registration and linked-account management, so all + * three share the same login credentials. */ +export const oauthNamespace = "hazel/oauth" + +export const oauthPolicy = { + generation: 1, + lifetimeMillis: 300_000, + exchangeTimeoutMillis: 30_000, +} + +export const sessionConfiguration = Sessions.stateful({ + maxAge: "30 days", + idleTimeout: "14 days", +}) + +export const HazelAuth = Auth.make(HazelAuthApi, { + sessions: sessionConfiguration, + strategies: { + social: OAuth.makeRegistration({ + namespace: oauthNamespace, + profiles: { github: GitHub.GitHubUserProfile, google: Google.GoogleUserProfile }, + registration: Registration, + authenticate: true, + policy: oauthPolicy, + registrationPolicy: { + lifetimeMillis: 300_000, + maximumVerificationAgeMillis: 300_000, + retentionMillis: 600_000, + }, + }), + accounts: OAuth.makeAccounts({ + namespace: oauthNamespace, + policy: { + ...oauthPolicy, + maximumEvidenceAgeMillis: 300_000, + requireImmediateInvalidation: true, + }, + }), + passkey: Passkey.make(), + passkeys: Passkey.makeManagement({ + management: { + maximumCredentials: 10, + maximumEvidenceAgeMillis: 300_000, + requireImmediateInvalidation: true, + }, + }), + }, + defaultStrategy: "social", +}) + +/** Either an OAuth login (possession) or a user-verified passkey satisfies sign-in. */ +export const requirement = Sessions.AuthenticationRequirement.make({ + alternatives: [ + { + factors: ["possession"], + userVerified: false, + phishingResistant: false, + minimumCredentials: 1, + }, + ], + maximumAgeMillis: 300_000, +}) diff --git a/packages/auth/src/yielded/contract.ts b/packages/auth/src/yielded/contract.ts new file mode 100644 index 000000000..7d2d1022e --- /dev/null +++ b/packages/auth/src/yielded/contract.ts @@ -0,0 +1,52 @@ +import { AuthContract, PasskeyContract } from "@yielded/auth" +import { Schema } from "effect" + +// Browser-safe shared contract. No provider configuration, keys or persistence here. + +export const Registration = Schema.Struct({ + firstName: Schema.NonEmptyString.check(Schema.isMaxLength(100)), + lastName: Schema.String.check(Schema.isMaxLength(100)), +}) + +export const Claims = Schema.Struct({ + displayName: Schema.String, +}) + +const passkeys = PasskeyContract.makeManagement("hazel/passkeys").operations + +export const HazelAuthApi = AuthContract.make("hazel", { + claims: Claims, + actions: (sessions) => { + const passkey = PasskeyContract.make("hazel/passkey", sessions).operations + + return { + signIn: AuthContract.oauthSignIn(), + completeSignIn: AuthContract.oauthCompleteSignIn(sessions), + // authenticate: the first confirmed registration issues the session directly. + register: AuthContract.oauthRegister(sessions, Registration, { authenticate: true }), + listLinkedAccounts: AuthContract.oauthListLinkedAccounts({ strategy: "accounts" }), + passkeySignIn: AuthContract.fromOperation(passkey.Begin, { + strategy: "passkey", + method: "signIn", + }), + completePasskeySignIn: AuthContract.fromOperation(passkey.Complete, { + strategy: "passkey", + method: "completeSignIn", + requestFields: { bindingCredential: "request-binding" }, + subject: { + fromSuccess: (result) => + result._tag === "Authenticated" ? result.session.subjectId : undefined, + }, + }), + enrollPasskey: AuthContract.fromOperation(passkeys.Begin, { strategy: "passkeys" }), + completePasskeyEnrollment: AuthContract.fromOperation(passkeys.Complete, { + strategy: "passkeys", + requestFields: { bindingCredential: "request-binding" }, + }), + listPasskeys: AuthContract.fromOperation(passkeys.List, { + strategy: "passkeys", + mode: "query", + }), + } + }, +}) diff --git a/packages/auth/src/yielded/hazel-auth.test.ts b/packages/auth/src/yielded/hazel-auth.test.ts new file mode 100644 index 000000000..fa4a8e968 --- /dev/null +++ b/packages/auth/src/yielded/hazel-auth.test.ts @@ -0,0 +1,374 @@ +/** + * End-to-end over real HTTP routes and a real Postgres whose schema is pushed from + * packages/db (drizzle-kit), so the library's mappings are checked against Hazel's actual + * tables. Only GitHub's network endpoints are simulated. + * + * Auth storage gets a fresh, lazily-connecting client per request, exactly as on the + * Cloudflare Worker. Set AUTH_TEST_DATABASE_URL to a disposable database to run; the + * test drops and recreates its public schema. + */ +import { execFileSync } from "node:child_process" +import { fileURLToPath } from "node:url" +import * as NodeHttpPlatform from "@effect/platform-node/NodeHttpPlatform" +import * as NodeServices from "@effect/platform-node/NodeServices" +import { PgClient } from "@effect/sql-pg" +import { Hooks } from "@yielded/auth" +import { Context, Effect, Exit, Layer, Redacted, References, Scope } from "effect" +import { Base64Url } from "effect/encoding" +import { + Etag, + HttpClient, + HttpClientResponse, + HttpRouter, + HttpServerRequest, + HttpServerResponse, +} from "effect/http" +import { SqlClient } from "effect/sql" +import { afterAll, beforeAll, describe, expect, test } from "vitest" + +import * as AccessToken from "./access-token.ts" +import { HazelAuth } from "./auth.ts" +import { CryptoLive, makeHazelAuthLive } from "./server.ts" +import { AuthSqlConnection, AuthSqlDirect, layerDirect, makeRequestConnection } from "./sql.ts" + +const DATABASE_URL = process.env.AUTH_TEST_DATABASE_URL +const origin = "http://localhost:3003" +const webOrigin = "http://localhost:3000" + +const keyring = (byte: number) => ({ + activeKeyId: "v1", + keys: [{ id: "v1", material: Redacted.make(Base64Url.encode(new Uint8Array(32).fill(byte))) }], +}) + +// ---- Simulated GitHub: token endpoint, /user and /user/emails ---- +let githubUserId = 0 +const FakeGitHub = Layer.succeed( + HttpClient.HttpClient, + HttpClient.make((request, url) => + Effect.sync(() => { + const json = (body: unknown) => + HttpClientResponse.fromWeb( + request, + new Response(JSON.stringify(body), { headers: { "content-type": "application/json" } }), + ) + if (url.href === "https://github.com/login/oauth/access_token") + return json({ + access_token: `gho_${githubUserId}`, + token_type: "bearer", + scope: "read:user,user:email", + }) + if (url.href === "https://api.github.com/user") + return json({ + id: githubUserId, + login: `octo${githubUserId}`, + name: "Octo Cat", + email: null, + avatar_url: `https://avatars.githubusercontent.com/u/${githubUserId}`, + }) + if (url.href.startsWith("https://api.github.com/user/emails")) + return json([{ email: `octo${githubUserId}@example.com`, primary: true, verified: true }]) + return HttpClientResponse.fromWeb(request, new Response("not found", { status: 404 })) + }), + ), +) + +describe.skipIf(DATABASE_URL === undefined)("Hazel auth on Postgres", () => { + const url = Redacted.make(DATABASE_URL ?? "") + const TestSql = PgClient.layer({ url }) + const query = (effect: Effect.Effect) => + Effect.runPromise(effect.pipe(Effect.provide(TestSql))) + + const { http, Services, sessionCookieName } = makeHazelAuthLive({ + origin, + trustedOrigins: [webOrigin], + github: { clientId: "Iv1.test", clientSecret: Redacted.make("test-secret") }, + passkey: { rpId: "localhost", origins: [webOrigin] }, + keys: { binding: keyring(1), transaction: keyring(2) }, + returnTargets: ["/"], + registrationPath: "/auth/register-profile", + }) + + const MeRoute = HttpRouter.add( + "GET", + "/me", + Effect.gen(function* () { + const auth = yield* HazelAuth + const session = yield* auth.getSession() + if (session === null) return HttpServerResponse.empty({ status: 401 }) + return yield* HttpServerResponse.json({ + userId: session.subjectId, + displayName: session.claims.displayName, + }) + }), + ) + + // What the RPC middleware does: verify a credential from a header, outside http.middleware. + const VerifyRoute = Layer.unwrap( + Effect.gen(function* () { + const auth = yield* HazelAuth + return HttpRouter.add( + "POST", + "/internal/verify", + Effect.gen(function* () { + const request = yield* HttpServerRequest.HttpServerRequest + const credential = request.headers["x-session-credential"] ?? "" + return yield* auth.verifySession(Redacted.make(credential)).pipe( + Effect.flatMap((session) => HttpServerResponse.json({ userId: session.subjectId })), + Effect.catchTag("SessionInvalid", () => + Effect.succeed(HttpServerResponse.empty({ status: 401 })), + ), + ) + }), + ) + }), + ) + + const App = Layer.mergeAll(http.routes(), MeRoute.pipe(http.middleware), VerifyRoute).pipe( + Layer.provide(http.layer), + Layer.provideMerge(Services), + Layer.provide(FakeGitHub), + Layer.provide(Hooks.LifecycleHooks.empty), + // Counts the dedicated connections opened outside a request connection. + Layer.provide( + Layer.effect( + AuthSqlDirect, + Effect.map(Effect.service(AuthSqlDirect), (direct) => ({ + open: direct.open.pipe(Effect.tap(() => Effect.sync(() => directOpened++))), + })), + ).pipe(Layer.provide(layerDirect(url))), + ), + Layer.provide([NodeServices.layer, NodeHttpPlatform.layer, Etag.layer]), + ) + + let web: ReturnType + let connectionsOpened = 0 + let directOpened = 0 + + /** Each request gets its own lazily-connecting auth client, closed after the response. */ + const send = async (request: Request): Promise => { + const scope = Effect.runSync(Scope.make()) + const connection = await Effect.runPromise( + makeRequestConnection(url, scope).pipe( + Effect.map((connection) => ({ + client: connection.client.pipe(Effect.tap(() => Effect.sync(() => connectionsOpened++))), + })), + ), + ) + try { + const handler = web.handler as ( + request: Request, + context: Context.Context, + ) => Promise + const response = await handler( + request, + Context.make(AuthSqlConnection, connection).pipe( + Context.add(References.MinimumLogLevel, process.env.DEBUG_AUTH ? "Debug" : "Info"), + ) as Context.Context, + ) + // Buffer the body so the request's connection can close. + const body = await response.arrayBuffer() + return new Response(response.status === 204 || response.status === 303 ? null : body, response) + } finally { + await Effect.runPromise(Scope.close(scope, Exit.void)) + } + } + + beforeAll(async () => { + await query( + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient + yield* sql.unsafe(`DROP SCHEMA public CASCADE`) + yield* sql.unsafe(`CREATE SCHEMA public`) + }), + ) + // The real schema, exactly as deployed. + execFileSync("bunx", ["drizzle-kit", "push", "--force"], { + cwd: fileURLToPath(new URL("../../../db", import.meta.url)), + env: { ...process.env, DATABASE_URL }, + stdio: "pipe", + }) + web = HttpRouter.toWebHandler(App, { disableLogger: true }) + }, 120_000) + + afterAll(async () => { + await web?.dispose() + }) + + const jar = () => { + const cookies = new Map() + return { + cookies, + header: () => [...cookies].map(([k, v]) => `${k}=${v}`).join("; "), + store: (response: Response) => { + for (const raw of response.headers.getSetCookie()) { + const [pair] = raw.split(";") + const index = pair!.indexOf("=") + const value = pair!.slice(index + 1) + if (value === "" || /max-age=0/i.test(raw)) cookies.delete(pair!.slice(0, index)) + else cookies.set(pair!.slice(0, index), value) + } + }, + } + } + type Jar = ReturnType + + const call = async (cookies: Jar, method: "GET" | "POST", path: string, payload?: unknown) => { + const headers: Record = { origin: webOrigin, cookie: cookies.header() } + if (method === "POST") { + headers["content-type"] = "application/json" + headers["x-effect-auth-csrf"] = "1" + } + const response = await send( + new Request(`${origin}${path}`, { + method, + headers, + ...(method === "POST" + ? { body: JSON.stringify(payload === undefined ? {} : { payload }) } + : {}), + redirect: "manual", + }), + ) + cookies.store(response) + return response + } + + const signInWithGitHub = async (cookies: Jar) => { + const begin = await call(cookies, "POST", "/auth/signIn", { provider: "github", returnTarget: "/" }) + expect(begin.status).toBe(200) + const state = new URL((await begin.json()).value.authorizationUrl).searchParams.get("state")! + const callback = await send( + new Request( + `${origin}/auth/github/callback?${new URLSearchParams({ code: "abc", state, iss: "https://github.com/login/oauth" })}`, + { headers: { cookie: cookies.header() }, redirect: "manual" }, + ), + ) + cookies.store(callback) + expect(callback.status).toBe(303) + return new URL(callback.headers.get("location")!, origin) + } + + const register = (cookies: Jar, location: URL) => + call(cookies, "POST", "/auth/register", { + reference: location.searchParams.get("reference"), + flowId: location.searchParams.get("flowId"), + commandId: crypto.randomUUID(), + registration: { firstName: "Octo", lastName: "Cat" }, + }) + + test("new GitHub user registers into Hazel's users table and is signed in", async () => { + githubUserId = 4242 + const browser = jar() + + const location = await signInWithGitHub(browser) + expect(location.pathname).toBe("/auth/register-profile") + const registered = await register(browser, location) + expect(await registered.json()).toMatchObject({ _tag: "Success", value: { _tag: "Authenticated" } }) + expect(browser.cookies.has(sessionCookieName)).toBe(true) + + const me = await (await call(browser, "GET", "/me")).json() + expect(me.displayName).toBe("Octo Cat") + + const [user] = await query( + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient + return yield* sql<{ + externalId: string + email: string + userType: string + isOnboarded: boolean + }>`select "externalId", email, "userType", "isOnboarded" from users where id = ${me.userId}::uuid` + }), + ) + expect(user).toEqual({ + externalId: `hazel:${me.userId}`, + email: "octo4242@example.com", + userType: "user", + isOnboarded: false, + }) + }, 60_000) + + test("returning user signs in; sign-out revokes the session server-side", async () => { + githubUserId = 4242 + const browser = jar() + expect((await signInWithGitHub(browser)).pathname).toBe("/") + expect((await call(browser, "GET", "/me")).status).toBe(200) + + const before = browser.header() + expect((await call(browser, "POST", "/auth/signOut")).status).toBe(200) + const replay = await send( + new Request(`${origin}/me`, { headers: { origin: webOrigin, cookie: before } }), + ) + expect(replay.status).toBe(401) + }, 60_000) + + test("a session credential verifies outside the auth middleware", async () => { + githubUserId = 4242 + const browser = jar() + await signInWithGitHub(browser) + const verify = (credential: string) => + send( + new Request(`${origin}/internal/verify`, { + method: "POST", + headers: { "x-session-credential": credential }, + }), + ) + const ok = await verify(browser.cookies.get(sessionCookieName)!) + expect(ok.status).toBe(200) + expect((await verify("nope")).status).toBe(401) + }, 60_000) + + test("after startup's schema checks, storage only uses the request's connection", async () => { + githubUserId = 4242 + const direct = directOpened + const opened = connectionsOpened + const browser = jar() + await signInWithGitHub(browser) + expect((await call(browser, "GET", "/me")).status).toBe(200) + expect(connectionsOpened).toBeGreaterThan(opened) + expect(directOpened).toBe(direct) + }, 60_000) + + test("requests that never touch auth storage open no database connection", async () => { + const opened = connectionsOpened + const response = await send(new Request(`${origin}/me`, { headers: { origin: webOrigin } })) + expect(response.status).toBe(401) + expect(connectionsOpened).toBe(opened) + }, 60_000) + + test("signed-in user can begin passkey enrollment", async () => { + githubUserId = 4242 + const browser = jar() + await signInWithGitHub(browser) + const response = await call(browser, "POST", "/auth/enrollPasskey", { + flowId: crypto.randomUUID(), + commandId: crypto.randomUUID(), + profileId: "default", + name: "Laptop", + }) + expect(response.status).toBe(200) + expect(JSON.stringify(await response.json())).toContain("localhost") + }, 60_000) + + test("session exchanges for an access token that verifies offline", async () => { + const pair = await Effect.runPromise( + AccessToken.generateSigningKey("k1").pipe(Effect.provide(CryptoLive)), + ) + const issuer = await Effect.runPromise( + AccessToken.makeIssuer({ kid: "k1", privateJwk: pair.privateKey }).pipe( + Effect.provide(CryptoLive), + ), + ) + const token = await Effect.runPromise( + issuer + .mint({ subjectId: "00000000-0000-4000-8000-000000000001", sessionId: "s1" }) + .pipe(Effect.provide(CryptoLive)), + ) + const verified = await Effect.runPromise( + AccessToken.verifyAccessToken(Redacted.value(token)).pipe( + Effect.provide(AccessToken.Jwks.layerLocal({ keys: [pair.publicKey] })), + Effect.provide(CryptoLive), + ), + ) + expect(verified.claims.sub).toBe("00000000-0000-4000-8000-000000000001") + }) +}) diff --git a/packages/auth/src/yielded/index.ts b/packages/auth/src/yielded/index.ts new file mode 100644 index 000000000..db30c15c1 --- /dev/null +++ b/packages/auth/src/yielded/index.ts @@ -0,0 +1,17 @@ +/** + * Server side of Hazel's authentication on `@yielded/auth`: GitHub and Google sign-in, + * passkeys and stateful sessions stored in Postgres. The browser-safe contract is + * `@hazel/auth/contract`. + */ +export * as AccessToken from "./access-token.ts" +export { HazelAuth, requirement } from "./auth.ts" +export { Claims, HazelAuthApi, Registration } from "./contract.ts" +export { + CryptoLive, + type HazelAuthConfig, + type Keyring, + makeHazelAuthHttp, + makeHazelAuthLive, + sessionCookieName, +} from "./server.ts" +export { AuthSqlConnection, AuthSqlDirect, layerDirect, layerPool, makeRequestConnection } from "./sql.ts" diff --git a/packages/auth/src/yielded/policy.ts b/packages/auth/src/yielded/policy.ts new file mode 100644 index 000000000..52c14e2bb --- /dev/null +++ b/packages/auth/src/yielded/policy.ts @@ -0,0 +1,127 @@ +import { Auth, OAuth, Passkey, Sessions } from "@yielded/auth" +import { Effect, Layer, Option, Schema } from "effect" + +import { HazelAuth, requirement, sessionConfiguration } from "./auth.ts" + +/** + * Sensitive account actions (linking a provider, adding/removing a passkey) must be + * authorised by the application against the caller's current session. The library + * supplies the challenge; Hazel decides whether this session's original evidence still + * matches the account's current credentials and security revision. + */ + +const SessionReader = HazelAuth.sessions.statefulLayer( + sessionConfiguration.policy(HazelAuth.sessions.moduleId), +) + +type Revision = { + readonly subjectId: string + readonly securityRevision: string + readonly credentials: ReadonlyArray<{ readonly credentialId: string; readonly revision: string }> +} + +const sameAuthority = (original: Revision, current: Revision) => + original.subjectId === current.subjectId && + original.securityRevision === current.securityRevision && + original.credentials.every((old) => + current.credentials.some( + (now) => now.credentialId === old.credentialId && now.revision === old.revision, + ), + ) + +const currentSessionToken = Effect.serviceOption(Auth.AuthRequest).pipe( + Effect.map((request) => (Option.isSome(request) ? request.value.credentials.session : undefined)), +) + +const PasskeyActionsLive = Layer.effect( + Passkey.PasskeyActionEvidence, + Effect.gen(function* () { + const sessions = yield* HazelAuth.sessions.SessionStrategy + + return Passkey.PasskeyActionEvidence.of({ + verify: Effect.fn("HazelAuth.authorizePasskeyAction")( + function* ({ invocation, challenge }) { + const token = yield* currentSessionToken + if (invocation._tag !== "Authenticated" || token === undefined) + return yield* Passkey.PasskeyActionRequired.make({}) + + const { inspection } = yield* HazelAuth.sessions + .inspectInvocation(invocation, token) + .pipe(Effect.provideService(HazelAuth.sessions.SessionStrategy, sessions)) + const original = inspection.provenance.evidence + + if ( + inspection.session.sessionId !== invocation.sessionId || + inspection.session.subjectId !== invocation.subjectId || + !sameAuthority(original.revision, challenge.revision) + ) + return yield* Passkey.PasskeyActionRequired.make({}) + + return { + evidence: { + ...original, + revision: challenge.revision, + flowId: Sessions.AuthenticationFlowId.make(challenge.flowId), + bindingDigest: challenge.bindingDigest, + }, + requirement, + } + }, + Effect.mapError((error) => + Schema.is(Passkey.PasskeyActionRequired)(error) + ? error + : Passkey.PasskeyUnavailable.make({}), + ), + ), + }) + }), +) + +const OAuthActionsLive = Layer.effect( + OAuth.OAuthActionEvidence, + Effect.gen(function* () { + const sessions = yield* HazelAuth.sessions.SessionStrategy + + return OAuth.OAuthActionEvidence.of({ + verify: Effect.fn("HazelAuth.authorizeOAuthAction")( + function* ({ invocation, challenge }) { + const token = yield* currentSessionToken + if (invocation._tag !== "Authenticated" || token === undefined) + return yield* OAuth.OAuthActionRequired.make({}) + + const { inspection } = yield* HazelAuth.sessions + .inspectInvocation(invocation, token) + .pipe(Effect.provideService(HazelAuth.sessions.SessionStrategy, sessions)) + const original = inspection.provenance.evidence + + if (!sameAuthority(original.revision, challenge.revision)) + return yield* OAuth.OAuthActionRequired.make({}) + + return { + source: { + _tag: "Session" as const, + sessionId: inspection.session.sessionId, + authenticatedAt: inspection.session.assurance.authenticatedAt, + }, + evidence: { + ...original, + revision: challenge.revision, + flowId: Sessions.AuthenticationFlowId.make(challenge.flowId), + bindingDigest: challenge.bindingDigest, + }, + requirement, + } + }, + Effect.mapError((error) => + Schema.is(OAuth.OAuthActionRequired)(error) || Schema.is(Sessions.SessionInvalid)(error) + ? OAuth.OAuthActionRequired.make({}) + : OAuth.OAuthUnavailable.make({}), + ), + ), + }) + }), +) + +export const ActionPoliciesLive = Layer.mergeAll(PasskeyActionsLive, OAuthActionsLive).pipe( + Layer.provide(SessionReader), +) diff --git a/packages/auth/src/yielded/server.ts b/packages/auth/src/yielded/server.ts new file mode 100644 index 000000000..fc57c4c78 --- /dev/null +++ b/packages/auth/src/yielded/server.ts @@ -0,0 +1,188 @@ +import { Auth, Http, OAuth, Operations, Passkey } from "@yielded/auth" +import * as GitHub from "@yielded/auth/GitHub" +import * as Google from "@yielded/auth/Google" +import * as SimpleWebAuthn from "@yielded/auth-simplewebauthn/Server" +import * as KdfAdmission from "@yielded/crypto/KdfAdmission" +import * as PortableCrypto from "@yielded/crypto/Portable" +import * as WebCrypto from "@yielded/crypto/WebCrypto" +import { Effect, Layer, Option, type Redacted, Schema } from "effect" +import { SqlClient } from "effect/sql" + +import { HazelAuth } from "./auth.ts" +import { HazelAuthApi } from "./contract.ts" +import { ActionPoliciesLive } from "./policy.ts" +import { layerRouted } from "./sql.ts" +import { OAuthStorageLive, Persistence, storage } from "./storage.ts" + +/** + * WebCrypto plus the library's pure-JS XChaCha20-Poly1305 (OAuth state encryption), the + * same on Bun and Workers. There are no passwords, so the KDF is never used. + */ +export const CryptoLive = Layer.merge( + WebCrypto.layerCryptoWeb, + Layer.suspend(() => PortableCrypto.layer(globalThis.crypto.subtle)).pipe( + Layer.provideMerge(KdfAdmission.layer()), + ), +) + +export interface Keyring { + readonly activeKeyId: string + readonly keys: ReadonlyArray<{ readonly id: string; readonly material: Redacted.Redacted }> +} + +export interface HazelAuthConfig { + /** The backend origin that serves /auth/* (api.hazel.sh; localhost:3003 in dev). */ + readonly origin: string + /** The web app origin(s) calling the API with credentials (app.hazel.sh). */ + readonly trustedOrigins?: ReadonlyArray + readonly github: { readonly clientId: string; readonly clientSecret: Redacted.Redacted } + readonly google?: { readonly clientId: string; readonly clientSecret: Redacted.Redacted } + readonly passkey: { readonly rpId: string; readonly origins: ReadonlyArray } + readonly keys: { + readonly binding: Keyring + readonly transaction: Keyring + } + /** Paths the OAuth callback may return to after sign-in. */ + readonly returnTargets: ReadonlyArray + /** Where the callback sends a first-time user to pick their name. */ + readonly registrationPath: string +} + +/** The browser session cookie: `__Host-` prefixed whenever the API is served over HTTPS. */ +export const sessionCookieName = (origin: string) => + new URL(origin).protocol === "https:" ? "__Host-effect-auth-session" : "effect-auth-session" + +export const makeHazelAuthHttp = (config: HazelAuthConfig) => { + const secure = new URL(config.origin).protocol === "https:" + + const github = GitHub.provider({ + clientId: config.github.clientId, + clientSecret: config.github.clientSecret, + // Requests user:email and reads the verified primary address from /user/emails, + // so users with a private GitHub email still register with a real address. + verifiedPrimaryEmail: true, + }) + const google = (google: NonNullable) => + Google.provider({ clientId: google.clientId, clientSecret: google.clientSecret }) + const providers: Record> = { github } + if (config.google !== undefined) providers.google = google(config.google) + + const http = Http.make(HazelAuth, { + origin: config.origin, + ...(config.trustedOrigins === undefined ? {} : { trustedOrigins: config.trustedOrigins }), + cookie: { secure }, + oauth: { + providers, + // Registration UI belongs to Hazel. Only public correlation enters the URL; + // the registration credential and request binder stay in HttpOnly cookies. + respond: (value, { flowId }) => + Effect.gen(function* () { + const result = yield* Schema.decodeUnknownEffect( + HazelAuthApi.actions.completeSignIn.route.operation.rpc.successSchema, + )(value).pipe(Effect.orDie) + + const target = + "_tag" in result && result._tag === "RegistrationRequired" + ? `${config.registrationPath}?${new URLSearchParams({ flowId, reference: result.reference })}` + : result.returnTarget + + return new Response(null, { status: 303, headers: { location: target } }) + }), + }, + }) + + return { http } +} + +/** Claims are read from Hazel's users table at session issuance. */ +const claimsFor = (subjectId: string) => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient + const rows = yield* sql<{ firstName: string; lastName: string }>` + select "firstName", "lastName" from users where id = ${subjectId}::uuid and "deletedAt" is null` + const row = rows[0] + if (row === undefined) return Option.none() + return Option.some({ displayName: `${row.firstName} ${row.lastName}`.trim() }) + }) + +const SocialClaimsLive = Layer.effect( + HazelAuth.strategies.social.SessionClaims, + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient + return { + resolve: ({ subjectId }) => + claimsFor(subjectId).pipe( + Effect.provideService(SqlClient.SqlClient, sql), + Effect.flatMap( + Option.match({ + onNone: () => Effect.fail(OAuth.OAuthUnavailable.make({})), + onSome: Effect.succeed, + }), + ), + Effect.catchTag("SqlError", () => Effect.fail(OAuth.OAuthUnavailable.make({}))), + ), + } + }), +) + +const PasskeyClaimsLive = Layer.effect( + HazelAuth.strategies.passkey.SessionClaims, + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient + return { + resolve: ({ subjectId }) => + claimsFor(subjectId).pipe( + Effect.provideService(SqlClient.SqlClient, sql), + Effect.flatMap( + Option.match({ + onNone: () => Effect.fail(Passkey.PasskeyUnavailable.make({})), + onSome: Effect.succeed, + }), + ), + Effect.catchTag("SqlError", () => Effect.fail(Passkey.PasskeyUnavailable.make({}))), + ), + } + }), +) + +export const makeHazelAuthLive = (config: HazelAuthConfig) => { + const { http } = makeHazelAuthHttp(config) + + // One "default" profile; validated by the library at layer build. + const passkeyConfig = Passkey.PasskeyConfig.layer({ + id: config.passkey.rpId, + name: "Hazel", + origins: config.passkey.origins, + developmentLocalhost: config.passkey.rpId === "localhost", + }) + + // Tables come from packages/db (drizzle-kit); nothing is created at runtime. + const Storage = Layer.mergeAll(Persistence.layer, OAuthStorageLive).pipe( + Layer.provideMerge(Persistence.Config.layer(storage)), + Layer.provideMerge(passkeyConfig), + ) + + const Services = Layer.mergeAll( + SocialClaimsLive, + PasskeyClaimsLive, + Auth.RequestBindingConfig.layer({ + generation: 1, + lifetimeMillis: 600_000, + keyring: config.keys.binding, + }), + OAuth.OAuthTransactionProtector.layer(config.keys.transaction), + OAuth.OAuthLinkTransactionProtector.layer(config.keys.transaction), + OAuth.OAuthReturnTargets.exactRoutes(config.returnTargets), + SimpleWebAuthn.layer, + ActionPoliciesLive, + ).pipe( + Layer.provideMerge(Storage), + // Postgres stamps session times with its own clock; tolerate it running slightly + // ahead of this process. Shared by Auth and persistence, so provided beneath both. + Layer.provideMerge(Operations.AuthenticationClock.layer({ futureToleranceMillis: 1_000 })), + Layer.provideMerge(layerRouted), + Layer.provideMerge(CryptoLive), + ) + + return { http, Services, sessionCookieName: sessionCookieName(config.origin) } +} diff --git a/packages/auth/src/yielded/sql.ts b/packages/auth/src/yielded/sql.ts new file mode 100644 index 000000000..c8cda8333 --- /dev/null +++ b/packages/auth/src/yielded/sql.ts @@ -0,0 +1,89 @@ +import { PgClient } from "@effect/sql-pg" +import { Context, Effect, Layer, Option, Redacted, Scope } from "effect" +import { Reactivity } from "effect/reactivity" +import { SqlClient } from "effect/sql" +import type { SqlError } from "effect/sql/SqlError" + +/** + * The Postgres client auth storage runs on, chosen per request. + * + * The auth services are built once, but on Cloudflare Workers a socket belongs to the + * request that opened it, so they cannot hold a pool. Auth storage therefore uses a + * `SqlClient` that resolves this service on every statement: the Worker provides a + * lazily-connecting client per request, Bun provides one pool for the process. + */ +export class AuthSqlConnection extends Context.Service< + AuthSqlConnection, + { readonly client: Effect.Effect } +>()("@hazel/auth/AuthSqlConnection") {} + +/** + * Opens a dedicated connection for a statement that runs without an + * {@link AuthSqlConnection} in scope, closed when the statement's lease ends. + * + * The storage layers check the physical schema (columns, keys, join types) once, under + * the context they were built in rather than the request's. Where connections are per + * request (Workers), those few statements use this instead. + */ +export class AuthSqlDirect extends Context.Service< + AuthSqlDirect, + { readonly open: Effect.Effect } +>()("@hazel/auth/AuthSqlDirect") {} + +/** `SqlClient` for auth storage, routed to the current {@link AuthSqlConnection}. */ +export const layerRouted: Layer.Layer = Layer.effect( + SqlClient.SqlClient, + Effect.gen(function* () { + const direct = yield* Effect.serviceOption(AuthSqlDirect) + + const acquirer = Effect.serviceOption(AuthSqlConnection).pipe( + Effect.flatMap( + Option.match({ + onSome: (connection) => connection.client, + onNone: () => + Option.match(direct, { + onSome: (direct) => direct.open, + onNone: () => + Effect.die(new Error("AuthSqlConnection is not provided for this request")), + }), + }), + ), + // Each statement (and each transaction) leases a connection from that client. + Effect.flatMap((client) => client.reserve), + ) + + return yield* SqlClient.make({ + acquirer, + compiler: PgClient.makeCompiler(), + spanAttributes: [["db.system.name", "postgresql"]], + prepareTransactionControls: true, + }) + }), +).pipe(Layer.provide(Reactivity.layer)) + +/** One pooled client for a long-running process (Bun). */ +export const layerPool = (url: Redacted.Redacted) => + Layer.effect( + AuthSqlConnection, + PgClient.make({ url, maxConnections: 5 }).pipe( + Effect.map((client) => ({ client: Effect.succeed(client) })), + ), + ).pipe(Layer.provide(Reactivity.layer)) + +const connect = (url: Redacted.Redacted) => + PgClient.makeClient({ url }).pipe( + Effect.provide(Reactivity.layer), + Effect.map((client): SqlClient.SqlClient => client), + ) + +/** Direct connections for hosts that connect per request (Workers). */ +export const layerDirect = (url: Redacted.Redacted) => Layer.succeed(AuthSqlDirect, { open: connect(url) }) + +/** + * A client for one request that connects on its first statement, closed with `scope`. + * Requests that never touch auth storage open no connection. + */ +export const makeRequestConnection = (url: Redacted.Redacted, scope: Scope.Scope) => + Effect.cached(connect(url).pipe(Scope.provide(scope))).pipe( + Effect.map((client) => AuthSqlConnection.of({ client })), + ) diff --git a/packages/auth/src/yielded/storage.ts b/packages/auth/src/yielded/storage.ts new file mode 100644 index 000000000..97ec2d0bd --- /dev/null +++ b/packages/auth/src/yielded/storage.ts @@ -0,0 +1,251 @@ +import { OAuth, Schema as AuthSchema, Sessions } from "@yielded/auth" +import { AuthPersistence, PersistenceMappingError } from "@yielded/auth-persistence" +import type { SubjectIdCodec } from "@yielded/auth-persistence/Adapter" +import * as Mapping from "@yielded/auth-persistence/OAuthPersistence" +import { Context, Crypto, Effect, Layer, Schema } from "effect" + +import { HazelAuth, requirement } from "./auth.ts" +import { Registration } from "./contract.ts" +import * as T from "./tables.ts" + +const { sql, eq } = Mapping + +/** Composed SQL storage for sessions, pending authentication and passkeys, on Hazel's + * `users` table. OAuth registration has no composed storage, so it is mapped below. */ +export const Persistence = AuthPersistence.make(HazelAuth) + +export const storage = Persistence.map({ + subjects: { + table: T.users, + id: "id", + status: "active", + activeValue: true, + securityRevision: "securityRevision", + idCodec: AuthSchema.SubjectId, + requirements: () => Effect.succeed(requirement), + }, + tables: { + identifiers: T.identifiers, + credentials: T.credentials, + sessions: T.sessions, + pending: T.pending, + passkeyCredentials: T.passkeyCredentials, + passkeyFlows: T.passkeyFlows, + }, +}) + +// ---- OAuth mappings ---- + +const subjectId: SubjectIdCodec = { + toNative: (id) => Effect.succeed(id), + toSubject: (id) => + Schema.decodeEffect(AuthSchema.SubjectId)(id).pipe( + Effect.mapError((cause) => PersistenceMappingError.make({ operation: "hazel.subject", cause })), + ), + equals: (left, right) => left === right, +} + +const subject = { + table: T.oauthUsers, + id: "id", + status: "status", + securityRevision: "securityRevision", + isActiveStatus: (value: unknown) => value === true, + activeCondition: eq(T.oauthUsers.columns.status, true), + decodeAuthenticationRequirement: () => requirement, + nextSecurityRevision: (current) => + Sessions.SecurityRevision.make(current === "initial" ? "1" : String(BigInt(current) + 1n)), +} satisfies Mapping.OAuthSubjectTable + +const authority = { + table: T.oauthAuthority, + subjectId: "subjectId", + credentialId: "credentialId", + revision: "revision", + status: "status", + isActiveStatus: (value: unknown) => value === true, + activeCondition: eq(T.oauthAuthority.columns.status, true), + encodeInsert: (value) => ({ ...value, status: true }), +} satisfies Mapping.OAuthAuthorityTable + +const credential = { + table: T.oauthLogins, + moduleId: "moduleId", + credentialId: "credentialId", + subjectId: "subjectId", + identityKey: "identityKey", + credentialRevision: "credentialRevision", + status: "status", + isActiveStatus: (value: unknown) => value === "active", + activeCondition: eq(T.oauthLogins.columns.status, "active"), + removal: "delete", + encodeInsert: (value) => ({ ...value, status: "active" }), +} satisfies Mapping.OAuthCredentialTable + +const ownership = { + table: T.oauthIdentities, + identityKey: "identityKey", + provider: "provider", + issuer: "issuer", + externalSubject: "externalSubject", + subjectId: "subjectId", + decodeSubjectId: (row) => Schema.decodeUnknownSync(Schema.String)(row.subjectId), + encodeInsert: ({ identityKey, identity, subjectId }) => ({ + identityKey, + provider: identity.provider, + issuer: identity.issuer, + externalSubject: identity.subject, + subjectId, + }), +} satisfies Mapping.OAuthOwnershipTable + +const flow = { + table: T.oauthFlows, + moduleId: "moduleId", + flowId: "flowId", + purpose: "purpose", + generation: "generation", + provider: "provider", + callbackId: "callbackId", + issuer: "issuer", + responseIssuerMode: "responseIssuerMode", + subjectId: "subjectId", + stateDigest: "stateDigest", + binderVerifier: "binderVerifier", + binderExpiresAt: "binderExpiresAt", + snapshot: "snapshot", + issuedAt: "issuedAt", + expiresAt: "expiresAt", + encodeInsert: (input) => input, +} satisfies Mapping.OAuthFlowTable + +const intent = { + table: T.oauthRegistrations, + moduleId: "moduleId", + reference: "reference", + flowId: "flowId", + identityKey: "identityKey", + snapshot: "snapshot", + expiresAt: "expiresAt", + retentionUntil: "retentionUntil", + encodeInsert: (value) => ({ + moduleId: value.context.moduleId, + reference: value.reference, + flowId: value.context.flowId, + }), +} satisfies Mapping.OAuthRegistrationIntentTable + +const registrationJson = Schema.fromJsonString(Registration) + +/** Provider-verified email; GitHub omits it when the user's email is private. */ +const emailFromIntent = (intent: OAuth.OAuthRegistrationIntent) => + intent.profile?.email ?? `${intent.identity.subject}@${intent.identity.provider}.invalid` + +export const OAuthStorageLive = Layer.effectContext( + Effect.gen(function* () { + const crypto = yield* Crypto.Crypto + + const uuid = crypto.randomUUIDv4.pipe( + Effect.mapError((cause) => PersistenceMappingError.make({ operation: "hazel.allocate", cause })), + ) + + const common = { subject, authority, subjectId, clock: Mapping.clock } + + const signIn = yield* Mapping.makeOAuthSignInServices({ + ...common, + ownership, + credential, + flow, + constraints: Mapping.requiredOAuthSignInConstraints, + }) + + const intents = yield* Mapping.makeOAuthRegistrationIntentServices({ + ownership, + intent, + clock: Mapping.clock, + constraints: { + intentReference: Mapping.requiredOAuthRegistrationConstraints.intentReference, + intentFlow: Mapping.requiredOAuthRegistrationConstraints.intentFlow, + ownership: Mapping.requiredOAuthRegistrationConstraints.ownership, + }, + eligible: () => sql`true`, + }) + + const registration = yield* Mapping.makeOAuthRegistrationServices({ + registration: Registration, + ownership, + intent, + subject, + credential, + authority, + subjectId, + clock: Mapping.clock, + constraints: Mapping.requiredOAuthRegistrationConstraints, + inspect: ({ registration }) => + Effect.succeed({ + fingerprint: `hazel-v1:${Schema.encodeSync(registrationJson)(registration)}`, + eligible: true, + }), + eligibility: { admission: () => sql`true`, postcondition: () => sql`true` }, + allocateSubjectId: uuid, + allocateCredentialId: uuid, + allocateRevision: Effect.succeed(Sessions.SecurityRevision.make("initial")), + encodeSubjectInsert: ({ intent, registration }, ids) => ({ + id: ids.subjectId, + // externalId is the Clerk user id for users who signed up through Clerk; it stays + // NOT NULL and unique, so users created here get a value in their own namespace. + externalId: `hazel:${ids.subjectId}`, + status: true, + securityRevision: ids.securityRevision, + email: emailFromIntent(intent), + firstName: registration.firstName, + lastName: registration.lastName, + avatarUrl: intent.profile?.avatarUrl ?? null, + }), + }) + + const accounts = yield* Mapping.makeOAuthAccountsServices({ + ...common, + ownership, + credential, + flow, + constraints: Mapping.requiredOAuthSignInConstraints, + metadataAccess: ({ invocation, subjectId }) => + invocation._tag === "Authenticated" && invocation.subjectId === subjectId + ? sql`true` + : sql`false`, + eligibility: [ + Mapping.oauthEligibilityTable({ + table: T.oauthLogins, + subjectId: "subjectId", + credentialId: "credentialId", + revision: "credentialRevision", + scope: "hazel-oauth-logins", + condition: () => eq(T.oauthLogins.columns.status, "active"), + decode: (row) => ({ + credentialId: Schema.decodeUnknownSync(Schema.String)(row.credentialId), + revision: Schema.decodeUnknownSync(Sessions.SecurityRevision)(row.credentialRevision), + usablePrimary: row.status === "active", + factors: ["possession"], + userVerified: false, + phishingResistant: false, + }), + }), + ], + cleanup: [], + sessionInvalidation: "same-authority-immediate", + otherReferences: () => sql`false`, + allocateCredentialId: uuid, + allocateRevision: uuid.pipe(Effect.map((value) => Sessions.SecurityRevision.make(value))), + }) + + return Context.make(OAuth.OAuthSignInPersistence, signIn.oauthSignInPersistence).pipe( + Context.add(OAuth.OAuthRegistrationIntents, intents.oauthRegistrationIntents), + Context.add(OAuth.OAuthAccountsPersistence, accounts.oauthAccountsPersistence), + Context.add( + HazelAuth.strategies.social.registration.RegistrationAuthority, + registration.registrationAuthority, + ), + ) + }), +) diff --git a/packages/auth/src/yielded/tables.ts b/packages/auth/src/yielded/tables.ts new file mode 100644 index 000000000..bee20c976 --- /dev/null +++ b/packages/auth/src/yielded/tables.ts @@ -0,0 +1,213 @@ +import { AuthPersistence } from "@yielded/auth-persistence" +import * as Mapping from "@yielded/auth-persistence/OAuthPersistence" + +/** + * The library's view of Hazel's auth tables. The physical tables are the Drizzle schema in + * `packages/db/src/schema/auth.ts` (plus `users`); keep names and unique keys in step. + */ + +// ---- AuthPersistence (sessions, pending authentication, passkeys) ---- + +export const users = AuthPersistence.table({ + name: "users", + columns: { + id: { name: "id", type: "text" }, + active: { name: "authActive", type: "boolean" }, + securityRevision: { name: "securityRevision", type: "text" }, + }, + unique: [["id"]], +}) + +export const identifiers = AuthPersistence.table({ + name: "auth_identifiers", + columns: { + moduleId: { name: "moduleId", type: "text", nullable: true }, + credentialId: { name: "credentialId", type: "text", nullable: true }, + namespace: { name: "namespace", type: "text" }, + value: { name: "value", type: "text" }, + subjectId: { name: "subjectId", type: "text" }, + revision: { name: "revision", type: "text" }, + verifiedAt: { name: "verifiedAt", type: "integer", nullable: true }, + active: { name: "active", type: "boolean" }, + }, + unique: [["namespace", "value"]], +}) + +export const credentials = AuthPersistence.table({ + name: "auth_credentials", + columns: { + credentialId: { name: "credentialId", type: "text" }, + subjectId: { name: "subjectId", type: "text" }, + revision: { name: "revision", type: "text" }, + active: { name: "active", type: "boolean" }, + }, + unique: [["credentialId"]], +}) + +export const sessions = AuthPersistence.table({ + name: "auth_sessions", + columns: { + sessionId: { name: "sessionId", type: "text" }, + subjectId: { name: "subjectId", type: "text" }, + digest: { name: "digest", type: "text" }, + securityRevision: { name: "securityRevision", type: "text" }, + issuedAt: { name: "issuedAt", type: "integer" }, + expiresAt: { name: "expiresAt", type: "integer" }, + absoluteExpiresAt: { name: "absoluteExpiresAt", type: "integer" }, + record: { name: "record", type: "text" }, + }, + unique: [["sessionId"], ["digest"]], +}) + +export const pending = AuthPersistence.table({ + name: "auth_pending", + columns: { + moduleId: { name: "moduleId", type: "text" }, + kind: { name: "kind", type: "text" }, + digest: { name: "digest", type: "text" }, + version: { name: "version", type: "text" }, + flowId: { name: "flowId", type: "text" }, + subjectId: { name: "subjectId", type: "text" }, + bindingDigest: { name: "bindingDigest", type: "text" }, + snapshot: { name: "snapshot", type: "text" }, + expiresAt: { name: "expiresAt", type: "integer" }, + attemptLimit: { name: "attemptLimit", type: "integer" }, + failedAttempts: { name: "failedAttempts", type: "integer" }, + consumed: { name: "consumed", type: "boolean" }, + }, + unique: [["digest"]], +}) + +export const passkeyCredentials = AuthPersistence.table({ + name: "auth_passkey_credentials", + columns: { + credentialId: { name: "credentialId", type: "text" }, + subjectId: { name: "subjectId", type: "text" }, + rpId: { name: "rpId", type: "text" }, + protocolCredentialId: { name: "protocolCredentialId", type: "text" }, + credentialKey: { name: "credentialKey", type: "text" }, + userHandle: { name: "userHandle", type: "text" }, + publicKey: { name: "publicKey", type: "text" }, + algorithm: { name: "algorithm", type: "integer" }, + profile: { name: "profile", type: "text" }, + credentialRevision: { name: "credentialRevision", type: "text" }, + active: { name: "active", type: "boolean" }, + primarySignIn: { name: "primarySignIn", type: "boolean" }, + enrollmentUserVerified: { name: "enrollmentUserVerified", type: "boolean" }, + backupEligible: { name: "backupEligible", type: "boolean" }, + backupState: { name: "backupState", type: "boolean" }, + counter: { name: "counter", type: "integer" }, + name: { name: "name", type: "text" }, + createdAt: { name: "createdAt", type: "integer" }, + lastUsedAt: { name: "lastUsedAt", type: "integer", nullable: true }, + }, + unique: [["credentialId"], ["credentialKey"]], +}) + +export const passkeyFlows = AuthPersistence.table({ + name: "auth_passkey_flows", + columns: { + moduleId: { name: "moduleId", type: "text" }, + flowId: { name: "flowId", type: "text" }, + purpose: { name: "purpose", type: "text" }, + snapshot: { name: "snapshot", type: "text" }, + requestBindingVerifier: { name: "requestBindingVerifier", type: "text" }, + requestBindingExpiresAt: { name: "requestBindingExpiresAt", type: "integer" }, + issuedAt: { name: "issuedAt", type: "integer" }, + expiresAt: { name: "expiresAt", type: "integer" }, + }, + unique: [["moduleId", "flowId"]], +}) + +// ---- OAuthPersistence (provider identities, login credentials, flows) ---- + +export const oauthUsers = Mapping.table({ + name: "users", + columns: { + id: { name: "id", type: "text" }, + status: { name: "authActive", type: "boolean" }, + securityRevision: { name: "securityRevision", type: "text" }, + // Application columns written when OAuth registration provisions a user. + externalId: { name: "externalId", type: "text" }, + email: { name: "email", type: "text" }, + firstName: { name: "firstName", type: "text" }, + lastName: { name: "lastName", type: "text" }, + avatarUrl: { name: "avatarUrl", type: "text", nullable: true }, + }, + unique: [["id"]], +}) + +export const oauthAuthority = Mapping.table({ + name: "auth_credentials", + columns: { + subjectId: { name: "subjectId", type: "text" }, + credentialId: { name: "credentialId", type: "text" }, + revision: { name: "revision", type: "text" }, + status: { name: "active", type: "boolean" }, + }, + unique: [["subjectId", "credentialId"]], +}) + +export const oauthIdentities = Mapping.table({ + name: "auth_oauth_identities", + columns: { + identityKey: { name: "identityKey", type: "text" }, + provider: { name: "provider", type: "text" }, + issuer: { name: "issuer", type: "text" }, + externalSubject: { name: "externalSubject", type: "text" }, + subjectId: { name: "subjectId", type: "text" }, + }, + unique: [["identityKey"]], +}) + +export const oauthLogins = Mapping.table({ + name: "auth_oauth_logins", + columns: { + moduleId: { name: "moduleId", type: "text" }, + credentialId: { name: "credentialId", type: "text" }, + subjectId: { name: "subjectId", type: "text" }, + identityKey: { name: "identityKey", type: "text" }, + credentialRevision: { name: "credentialRevision", type: "text" }, + status: { name: "status", type: "text" }, + }, + unique: [["credentialId"], ["identityKey"]], +}) + +export const oauthFlows = Mapping.table({ + name: "auth_oauth_flows", + columns: { + moduleId: { name: "moduleId", type: "text" }, + flowId: { name: "flowId", type: "text" }, + purpose: { name: "purpose", type: "text" }, + generation: { name: "generation", type: "integer", nullable: true }, + provider: { name: "provider", type: "text", nullable: true }, + callbackId: { name: "callbackId", type: "text", nullable: true }, + issuer: { name: "issuer", type: "text", nullable: true }, + responseIssuerMode: { name: "responseIssuerMode", type: "text", nullable: true }, + subjectId: { name: "subjectId", type: "text", nullable: true }, + stateDigest: { name: "stateDigest", type: "text", nullable: true }, + binderVerifier: { name: "binderVerifier", type: "text", nullable: true }, + binderExpiresAt: { name: "binderExpiresAt", type: "integer", nullable: true }, + snapshot: { name: "snapshot", type: "text", nullable: true }, + issuedAt: { name: "issuedAt", type: "integer", nullable: true }, + expiresAt: { name: "expiresAt", type: "integer", nullable: true }, + }, + unique: [["moduleId", "flowId"], ["stateDigest"]], +}) + +export const oauthRegistrations = Mapping.table({ + name: "auth_oauth_registrations", + columns: { + moduleId: { name: "moduleId", type: "text" }, + reference: { name: "reference", type: "text" }, + flowId: { name: "flowId", type: "text" }, + identityKey: { name: "identityKey", type: "text", nullable: true }, + snapshot: { name: "snapshot", type: "text", nullable: true }, + expiresAt: { name: "expiresAt", type: "integer", nullable: true }, + retentionUntil: { name: "retentionUntil", type: "integer", nullable: true }, + }, + unique: [ + ["moduleId", "reference"], + ["moduleId", "flowId"], + ], +}) diff --git a/packages/db/src/schema/auth.ts b/packages/db/src/schema/auth.ts new file mode 100644 index 000000000..8a51f7e8a --- /dev/null +++ b/packages/db/src/schema/auth.ts @@ -0,0 +1,185 @@ +import type { UserId } from "@hazel/schema" +import { bigint, boolean, index, pgTable, text, unique, uuid } from "drizzle-orm/pg-core" + +/** + * Storage for Hazel's own authentication (`@hazel/auth/server`, built on `@yielded/auth`). + * `users` is the auth subject (its `authActive` and `securityRevision` columns live in + * users.ts); these tables hold sessions, pending sign-ins, passkeys and OAuth identities. + * + * Column names, types and unique constraints are read by the library's SQL mappings in + * `packages/auth/src/yielded/tables.ts`, so change both together. Times are epoch + * milliseconds. `subjectId` columns are uuid so they join `users.id` directly. + */ + +const millis = (name?: string) => + name === undefined ? bigint({ mode: "number" }) : bigint(name, { mode: "number" }) +const subjectId = () => uuid().$type() + +/** Credentials that make up a user's current security revision (one row per login). */ +export const authCredentialsTable = pgTable( + "auth_credentials", + { + credentialId: text().notNull().unique(), + subjectId: subjectId().notNull(), + revision: text().notNull(), + active: boolean().notNull(), + }, + (table) => [unique("auth_credentials_subject_credential").on(table.subjectId, table.credentialId)], +) + +/** Verified identifiers (unused while sign-in is OAuth and passkeys only). */ +export const authIdentifiersTable = pgTable( + "auth_identifiers", + { + moduleId: text(), + credentialId: text(), + namespace: text().notNull(), + value: text().notNull(), + subjectId: subjectId().notNull(), + revision: text().notNull(), + verifiedAt: millis(), + active: boolean().notNull(), + }, + (table) => [unique("auth_identifiers_namespace_value").on(table.namespace, table.value)], +) + +export const authSessionsTable = pgTable( + "auth_sessions", + { + sessionId: text().notNull().unique(), + subjectId: subjectId().notNull(), + digest: text().notNull().unique(), + securityRevision: text().notNull(), + issuedAt: millis().notNull(), + expiresAt: millis().notNull(), + absoluteExpiresAt: millis().notNull(), + record: text().notNull(), + }, + (table) => [index("auth_sessions_subject_idx").on(table.subjectId)], +) + +/** Sign-ins waiting on a further step. */ +export const authPendingTable = pgTable("auth_pending", { + moduleId: text().notNull(), + kind: text().notNull(), + digest: text().notNull().unique(), + version: text().notNull(), + flowId: text().notNull(), + subjectId: subjectId().notNull(), + bindingDigest: text().notNull(), + snapshot: text().notNull(), + expiresAt: millis().notNull(), + attemptLimit: millis().notNull(), + failedAttempts: millis().notNull(), + consumed: boolean().notNull(), +}) + +export const authPasskeyCredentialsTable = pgTable( + "auth_passkey_credentials", + { + credentialId: text().notNull().unique(), + subjectId: subjectId().notNull(), + rpId: text().notNull(), + protocolCredentialId: text().notNull(), + credentialKey: text().notNull().unique(), + userHandle: text().notNull(), + publicKey: text().notNull(), + algorithm: millis().notNull(), + profile: text().notNull(), + credentialRevision: text().notNull(), + active: boolean().notNull(), + primarySignIn: boolean().notNull(), + enrollmentUserVerified: boolean().notNull(), + backupEligible: boolean().notNull(), + backupState: boolean().notNull(), + counter: millis().notNull(), + name: text().notNull(), + createdAt: millis().notNull(), + lastUsedAt: millis(), + }, + (table) => [index("auth_passkey_credentials_subject_idx").on(table.subjectId)], +) + +/** Passkey ceremonies in progress. */ +export const authPasskeyFlowsTable = pgTable( + "auth_passkey_flows", + { + moduleId: text().notNull(), + flowId: text().notNull(), + purpose: text().notNull(), + snapshot: text().notNull(), + requestBindingVerifier: text().notNull(), + requestBindingExpiresAt: millis().notNull(), + issuedAt: millis().notNull(), + expiresAt: millis().notNull(), + }, + (table) => [unique("auth_passkey_flows_module_flow").on(table.moduleId, table.flowId)], +) + +/** Which Hazel user owns each provider account (GitHub user id, Google `sub`). */ +export const authOAuthIdentitiesTable = pgTable( + "auth_oauth_identities", + { + identityKey: text().primaryKey(), + provider: text().notNull(), + issuer: text().notNull(), + externalSubject: text().notNull(), + subjectId: subjectId().notNull(), + }, + (table) => [index("auth_oauth_identities_subject_idx").on(table.subjectId)], +) + +/** Provider accounts usable for sign-in. */ +export const authOAuthLoginsTable = pgTable( + "auth_oauth_logins", + { + moduleId: text().notNull(), + credentialId: text().primaryKey(), + subjectId: subjectId().notNull(), + identityKey: text().notNull().unique(), + credentialRevision: text().notNull(), + status: text().notNull(), + }, + (table) => [index("auth_oauth_logins_owner_idx").on(table.identityKey, table.subjectId)], +) + +/** OAuth redirects in progress. */ +export const authOAuthFlowsTable = pgTable( + "auth_oauth_flows", + { + moduleId: text().notNull(), + flowId: text().notNull(), + purpose: text().notNull(), + generation: millis(), + provider: text(), + callbackId: text(), + issuer: text(), + responseIssuerMode: text(), + subjectId: subjectId(), + stateDigest: text().unique(), + binderVerifier: text(), + binderExpiresAt: millis(), + snapshot: text(), + issuedAt: millis(), + expiresAt: millis(), + }, + (table) => [unique("auth_oauth_flows_module_flow").on(table.moduleId, table.flowId)], +) + +/** First-time provider sign-ins waiting for the registration form. */ +export const authOAuthRegistrationsTable = pgTable( + "auth_oauth_registrations", + { + moduleId: text().notNull(), + reference: text().notNull(), + flowId: text().notNull(), + identityKey: text(), + snapshot: text(), + expiresAt: millis(), + retentionUntil: millis(), + }, + (table) => [ + unique("auth_oauth_registrations_module_reference").on(table.moduleId, table.reference), + unique("auth_oauth_registrations_module_flow").on(table.moduleId, table.flowId), + ], +) diff --git a/packages/db/src/schema/index.ts b/packages/db/src/schema/index.ts index c6489c749..cf88b96f0 100644 --- a/packages/db/src/schema/index.ts +++ b/packages/db/src/schema/index.ts @@ -2,6 +2,7 @@ export * from "@hazel/schema" export * from "./attachments" +export * from "./auth" export * from "./bot-commands" export * from "./bot-installations" export * from "./bots" diff --git a/packages/db/src/schema/users.ts b/packages/db/src/schema/users.ts index aac30729a..1c535e07f 100644 --- a/packages/db/src/schema/users.ts +++ b/packages/db/src/schema/users.ts @@ -19,6 +19,10 @@ export const usersTable = pgTable( createdAt: timestamp({ mode: "date", withTimezone: true }).notNull().defaultNow(), updatedAt: timestamp({ mode: "date", withTimezone: true }).notNull().defaultNow(), deletedAt: timestamp({ mode: "date", withTimezone: true }), + // Hazel auth subject state (see auth.ts). `authActive` false blocks sign-in and ends + // sessions; `securityRevision` changes when the user's sign-in methods change. + authActive: boolean().notNull().default(true), + securityRevision: text().notNull().default("initial"), }, (table) => [ index("users_external_id_idx").on(table.externalId), From 5e35d258ce286b1173b35d5239ab0d0522dd4840 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Sat, 10 Oct 2026 23:57:57 +0200 Subject: [PATCH 2/4] feat(backend): serve Hazel sign-in alongside Clerk Mounts /auth/* (GitHub, Google, passkeys, sessions), /auth/token and /.well-known/jwks.json when AUTH_GITHUB_CLIENT_ID is set. The RPC and HttpApi auth middleware accept the Hazel session cookie (trusted Origin required) or the session credential as a bearer token, before falling back to Clerk JWTs and bot tokens. Auth storage gets a pooled Postgres client on Bun and a lazily-connecting one per request on the Worker. Co-Authored-By: Claude Opus 5.5 --- apps/backend/.env.example | 13 + apps/backend/package.json | 1 + apps/backend/src/app.ts | 31 ++- apps/backend/src/index.ts | 10 +- apps/backend/src/rpc/middleware/auth.test.ts | 61 ++++ apps/backend/src/rpc/middleware/auth.ts | 21 +- apps/backend/src/services/auth.ts | 35 ++- apps/backend/src/services/hazel-auth.test.ts | 278 +++++++++++++++++++ apps/backend/src/services/hazel-auth.ts | 167 +++++++++++ apps/backend/src/services/hazel-session.ts | 116 ++++++++ apps/backend/src/worker/env.ts | 13 + apps/backend/src/worker/platform.ts | 34 ++- bun.lock | 1 + packages/auth/package.json | 7 +- packages/auth/scripts/generate-keys.ts | 22 ++ packages/auth/src/yielded/access-token.ts | 38 ++- packages/auth/src/yielded/hazel-auth.test.ts | 27 +- packages/auth/src/yielded/server.ts | 14 +- packages/auth/src/yielded/sql.ts | 10 +- 19 files changed, 856 insertions(+), 43 deletions(-) create mode 100644 apps/backend/src/services/hazel-auth.test.ts create mode 100644 apps/backend/src/services/hazel-auth.ts create mode 100644 apps/backend/src/services/hazel-session.ts create mode 100644 packages/auth/scripts/generate-keys.ts diff --git a/apps/backend/.env.example b/apps/backend/.env.example index 0b1237d0a..26c5ba5c6 100644 --- a/apps/backend/.env.example +++ b/apps/backend/.env.example @@ -15,6 +15,19 @@ CLERK_SECRET_KEY=sk_test_your_clerk_secret_here CLERK_PUBLISHABLE_KEY=pk_test_your_clerk_publishable_here CLERK_WEBHOOK_SECRET=whsec_your_clerk_webhook_secret_here +# Hazel sign-in (replacing Clerk). Off until AUTH_GITHUB_CLIENT_ID is set. +# GitHub OAuth App callback URL: {API_BASE_URL}/auth/github/callback +# Google OAuth client redirect URI: {API_BASE_URL}/auth/google/callback +# Generate the keys with: bun run --cwd packages/auth generate-keys +# AUTH_GITHUB_CLIENT_ID= +# AUTH_GITHUB_CLIENT_SECRET= +# AUTH_GOOGLE_CLIENT_ID= +# AUTH_GOOGLE_CLIENT_SECRET= +# AUTH_BINDING_KEY= +# AUTH_TRANSACTION_KEY= +# AUTH_ACCESS_TOKEN_KID= +# AUTH_ACCESS_TOKEN_PRIVATE_JWK= + # S3-Compatible Storage (e.g., Cloudflare R2) # Bun auto-reads these S3_* env vars (or AWS_* as fallback) # Get these from your S3-compatible storage provider dashboard diff --git a/apps/backend/package.json b/apps/backend/package.json index 36f2332f8..487ab3a57 100644 --- a/apps/backend/package.json +++ b/apps/backend/package.json @@ -38,6 +38,7 @@ "@hazel/infra": "workspace:*", "@hazel/integrations": "workspace:*", "@hazel/schema": "workspace:*", + "@yielded/auth": "0.1.0-beta.32", "alchemy": "catalog:alchemy", "aws4fetch": "^1.0.20", "dfx": "^1.1.0", diff --git a/apps/backend/src/app.ts b/apps/backend/src/app.ts index 545ca14bb..73e585a5e 100644 --- a/apps/backend/src/app.ts +++ b/apps/backend/src/app.ts @@ -76,6 +76,8 @@ import { MockDataGenerator } from "./services/mock-data-generator" import { OAuthBearerAuth } from "./services/oauth-bearer-auth" import { ObjectStorage } from "./services/object-storage" import { OAuthProviderRegistry } from "./services/oauth" +import { HazelAuthInstance, HazelAuthRoutes, HazelAuthServicesLive } from "./services/hazel-auth" +import { HazelSession } from "./services/hazel-session" import { SessionManager } from "./services/session-manager" import { WebhookBotService } from "./services/webhook-bot-service" import { BotGatewayService } from "./services/bot-gateway-service" @@ -103,7 +105,13 @@ const RpcRoute = RpcServer.layerHttp({ protocol: "http", }).pipe(Layer.provide(RpcSerialization.layerNdjson), Layer.provide(RpcServerLive)) -export const AllRoutes = Layer.mergeAll(HttpApiRoutes, HealthRouter, DocsRoute, RpcRoute).pipe( +export const AllRoutes = Layer.mergeAll( + HttpApiRoutes, + HealthRouter, + DocsRoute, + RpcRoute, + HazelAuthRoutes, +).pipe( Layer.provide( HttpRouter.cors({ allowedOrigins: [ @@ -119,6 +127,16 @@ export const AllRoutes = Layer.mergeAll(HttpApiRoutes, HealthRouter, DocsRoute, ), ) +/** + * Hazel's own sign-in: the configured instance, its auth services, and the session lookup + * the RPC and HttpApi middleware use. Inert until `AUTH_GITHUB_CLIENT_ID` is set. Needs + * `AuthSqlConnection` (and `AuthSqlDirect` on Workers) from the entry point. + */ +export const HazelAuthLive = HazelSession.layer.pipe( + Layer.provideMerge(HazelAuthServicesLive), + Layer.provideMerge(HazelAuthInstance.layer), +) + export const RepoLive = Layer.mergeAll( MessageRepo.layer, ChannelRepo.layer, @@ -204,5 +222,12 @@ export const AppServicesLive = Layer.mergeAll( OAuthBearerAuth.layer, ).pipe(Layer.provideMerge(FetchHttpClient.layer)) -/** `CurrentUser` resolution for authenticated routes. Requires `Database` from the platform. */ -export const AppAuthorizationLive = AuthorizationLive.pipe(Layer.provideMerge(SessionManager.layer)) +/** + * `CurrentUser` resolution for authenticated routes, and Hazel's sign-in services. Only the + * HTTP entry points build this; the Durable Objects never authenticate. Requires `Database` + * and the auth SQL connection from the platform. + */ +export const AppAuthorizationLive = AuthorizationLive.pipe( + Layer.provideMerge(SessionManager.layer), + Layer.provideMerge(HazelAuthLive), +) diff --git a/apps/backend/src/index.ts b/apps/backend/src/index.ts index 6809cee28..8f6b456a3 100644 --- a/apps/backend/src/index.ts +++ b/apps/backend/src/index.ts @@ -3,13 +3,15 @@ * `worker.ts`. See infra/cloudflare-migration-plan.md. */ import { BunHttpServer, BunRuntime, BunSocket } from "@effect/platform-bun" +import { layerPool as authSqlPool } from "@hazel/auth/server" import { Redis, RedisResultPersistenceLive } from "@hazel/effect-bun" import { createTracingLayer } from "@hazel/effect-bun/Telemetry" -import { Config, ConfigProvider, Layer } from "effect" +import { Config, ConfigProvider, Effect, Layer } from "effect" import { HttpMiddleware, HttpRouter } from "effect/http" import { AllRoutes, AppAuthorizationLive, AppServicesLive, HazelApi } from "./app" import { BotGatewayTransport } from "./services/bot-gateway-transport" import { DiscordGatewayBackgroundLive } from "./services/chat-sync/discord-gateway-service" +import { EnvVars } from "./lib/env-vars" import { DatabaseLive } from "./services/database" import { MessageOutboxDispatcher } from "./services/message-outbox-dispatcher" import { RateLimiterRedisLive } from "./services/rate-limiter-redis" @@ -24,12 +26,18 @@ const TracerLive = createTracingLayer("api") // ResultPersistence layer for session caching (uses Redis backing) const PersistenceLive = RedisResultPersistenceLive.pipe(Layer.provide(Redis.Default)) +/** Auth storage's own Postgres pool (Effect SQL; the rest of the backend uses Drizzle). */ +const AuthSqlLive = Layer.unwrap( + Effect.map(Effect.service(EnvVars), (env) => authSqlPool(env.DATABASE_URL, { ssl: !env.IS_DEV })), +).pipe(Layer.provide(EnvVars.layer)) + /** * Bun's platform services: a pooled database, Redis-backed caches and rate limits, and bot gateway * events appended to the Durable Streams server the Bun bot gateway reads. */ const PlatformLive = Layer.mergeAll( DatabaseLive, + AuthSqlLive, PersistenceLive, Redis.Default, RateLimiterRedisLive, diff --git a/apps/backend/src/rpc/middleware/auth.test.ts b/apps/backend/src/rpc/middleware/auth.test.ts index ba48e7d30..219e50ac7 100644 --- a/apps/backend/src/rpc/middleware/auth.test.ts +++ b/apps/backend/src/rpc/middleware/auth.test.ts @@ -6,6 +6,7 @@ import { CurrentUser, type CurrentUser as CurrentUserNamespace } from "@hazel/do import type { UserId } from "@hazel/schema" import { Effect, Layer, Option, Ref, Result, Context } from "effect" import { AuthMiddleware, AuthMiddlewareLive } from "./auth.ts" +import { HazelSession } from "../../services/hazel-session.ts" import { SessionManager } from "../../services/session-manager.ts" import { serviceShape } from "../../test/effect-helpers" @@ -73,6 +74,19 @@ const makeSessionManagerLayer = (currentUser: CurrentUserNamespace.Schema) => }), ) +type HazelSessionShape = Context.Service.Shape + +const makeHazelSessionLayer = (overrides: Partial = {}) => + Layer.succeed( + HazelSession, + serviceShape({ + enabled: false, + verify: () => Effect.succeedNone, + fromCookie: () => Effect.succeedNone, + ...overrides, + }), + ) + const makeBotRepoLayer = (findByTokenHash: BotRepoShape["findByTokenHash"]) => Layer.succeed( BotRepo, @@ -119,6 +133,8 @@ const USER_RECORD: UserRecord = { settings: null, isOnboarded: true, timezone: "UTC", + authActive: true, + securityRevision: "initial", createdAt: new Date("2026-01-01T00:00:00.000Z"), updatedAt: new Date("2026-01-01T00:00:00.000Z"), deletedAt: null, @@ -128,6 +144,7 @@ const runAuth = ( headers: Headers.Headers, overrides: { sessionManager?: ReturnType + hazelSession?: ReturnType botRepo?: ReturnType userRepo?: ReturnType } = {}, @@ -137,6 +154,7 @@ const runAuth = ( invokeMiddleware(headers).pipe( Effect.provide(AuthMiddlewareLive), Effect.provide(overrides.sessionManager ?? makeSessionManagerLayer(makeCurrentUser())), + Effect.provide(overrides.hazelSession ?? makeHazelSessionLayer()), Effect.provide( overrides.botRepo ?? makeBotRepoLayer(() => Effect.succeed(Option.none())), ), @@ -215,4 +233,47 @@ describe("AuthMiddlewareLive", () => { expect(failureTag).toBe("InvalidBearerTokenError") } }) + + it("authenticates a Hazel session cookie before any bearer token", async () => { + const currentUser = makeCurrentUser({ email: "cookie@example.com" }) + const result = await runAuth(Headers.fromInput({ authorization: "Bearer a.b.c" }), { + hazelSession: makeHazelSessionLayer({ + enabled: true, + fromCookie: () => Effect.succeed(Option.some(currentUser)), + }), + }) + + expect(Result.isSuccess(result) && Option.getOrNull(result.success)?.email).toBe("cookie@example.com") + }) + + it("authenticates a Hazel session credential sent as a bearer token", async () => { + const currentUser = makeCurrentUser({ email: "native@example.com" }) + const result = await runAuth(Headers.fromInput({ authorization: "Bearer session-credential" }), { + hazelSession: makeHazelSessionLayer({ + enabled: true, + verify: (credential) => + Effect.succeed( + credential === "session-credential" ? Option.some(currentUser) : Option.none(), + ), + }), + }) + + expect(Result.isSuccess(result) && Option.getOrNull(result.success)?.email).toBe("native@example.com") + }) + + it("never checks bot tokens as Hazel sessions", async () => { + let checked = false + const result = await runAuth(Headers.fromInput({ authorization: "Bearer hzl_bot_abc" }), { + hazelSession: makeHazelSessionLayer({ + enabled: true, + verify: () => { + checked = true + return Effect.succeedNone + }, + }), + }) + + expect(checked).toBe(false) + expect(Result.isFailure(result)).toBe(true) + }) }) diff --git a/apps/backend/src/rpc/middleware/auth.ts b/apps/backend/src/rpc/middleware/auth.ts index f776aefb1..0a9c006f0 100644 --- a/apps/backend/src/rpc/middleware/auth.ts +++ b/apps/backend/src/rpc/middleware/auth.ts @@ -4,6 +4,7 @@ import { CurrentUser, InvalidBearerTokenError, SessionNotProvidedError } from "@ import { Effect, Layer, Option } from "effect" import { AuthMiddleware } from "@hazel/domain/rpc" import { type ApiScope, CurrentBotScopes } from "@hazel/domain/scopes" +import { HazelSession } from "../../services/hazel-session" import { SessionManager } from "../../services/session-manager" export { AuthMiddleware } from "@hazel/domain/rpc" @@ -31,12 +32,19 @@ export const AuthMiddlewareLive = Layer.effect( AuthMiddleware, Effect.gen(function* () { const sessionManager = yield* SessionManager + const hazelSession = yield* HazelSession const botRepo = yield* BotRepo const userRepo = yield* UserRepo return AuthMiddleware.of((effect, { headers }) => Effect.gen(function* () { - // Check for Bearer token first (bot SDK or desktop app authentication) + // Hazel session cookie (browser). RPC calls are POSTs, so the Origin is checked. + const cookieUser = yield* hazelSession.fromCookie(headers, "POST") + if (Option.isSome(cookieUser)) { + return yield* Effect.provideService(effect, CurrentUser.Context, cookieUser.value) + } + + // Bearer token: Clerk JWT, Hazel session credential (native clients) or bot token const authHeader = Headers.get(headers, "authorization") if (Option.isSome(authHeader) && authHeader.value.startsWith("Bearer ")) { @@ -48,6 +56,17 @@ export const AuthMiddlewareLive = Layer.effect( return yield* Effect.provideService(effect, CurrentUser.Context, currentUser) } + if (hazelSession.enabled && !token.startsWith("hzl_bot_")) { + const sessionUser = yield* hazelSession.verify(token) + if (Option.isSome(sessionUser)) { + return yield* Effect.provideService( + effect, + CurrentUser.Context, + sessionUser.value, + ) + } + } + // Otherwise, treat as bot token (hash-based lookup) const tokenHash = yield* Effect.promise(() => hashToken(token)) diff --git a/apps/backend/src/services/auth.ts b/apps/backend/src/services/auth.ts index 14f4c172b..2907b553e 100644 --- a/apps/backend/src/services/auth.ts +++ b/apps/backend/src/services/auth.ts @@ -1,21 +1,48 @@ import { CurrentUser } from "@hazel/domain" -import { Effect, Layer, Redacted } from "effect" +import { Effect, Layer, Option, Redacted } from "effect" +import { HttpServerRequest } from "effect/http" +import { HazelSession } from "./hazel-session" import { SessionManager } from "./session-manager" +/** Three base64url segments: a Clerk session JWT rather than a Hazel session credential. */ +const isJwt = (token: string) => { + const parts = token.split(".") + return parts.length === 3 && parts.every((part) => /^[A-Za-z0-9_-]+$/.test(part)) +} + export const AuthorizationLive = Layer.effect( CurrentUser.Authorization, Effect.gen(function* () { yield* Effect.logDebug("Initializing Authorization middleware...") const sessionManager = yield* SessionManager + const hazelSession = yield* HazelSession return CurrentUser.Authorization.of({ bearer: (httpEffect, { credential: bearerToken }) => Effect.gen(function* () { - yield* Effect.logDebug("checking bearer token") + // Hazel session cookie (browser) + const request = yield* HttpServerRequest.HttpServerRequest + const cookieUser = yield* hazelSession.fromCookie(request.headers, request.method) + if (Option.isSome(cookieUser)) { + return yield* Effect.provideService(httpEffect, CurrentUser.Context, cookieUser.value) + } + + // Hazel session credential (native clients) + const token = Redacted.value(bearerToken) + if (hazelSession.enabled && token !== "" && !isJwt(token)) { + const sessionUser = yield* hazelSession.verify(token) + if (Option.isSome(sessionUser)) { + return yield* Effect.provideService( + httpEffect, + CurrentUser.Context, + sessionUser.value, + ) + } + } - // Use SessionManager to handle bearer token authentication - const user = yield* sessionManager.authenticateWithBearer(Redacted.value(bearerToken)) + // Clerk session JWT + const user = yield* sessionManager.authenticateWithBearer(token) return yield* Effect.provideService(httpEffect, CurrentUser.Context, user) }), }) diff --git a/apps/backend/src/services/hazel-auth.test.ts b/apps/backend/src/services/hazel-auth.test.ts new file mode 100644 index 000000000..5857d739d --- /dev/null +++ b/apps/backend/src/services/hazel-auth.test.ts @@ -0,0 +1,278 @@ +/** + * The backend's Hazel sign-in wiring end to end: env config, the `/auth/*` and token + * routes, and both auth middlewares (RPC and HttpApi) resolving the session cookie to the + * Hazel user. Runs against a disposable Postgres container with the schema pushed from + * packages/db. Only GitHub is simulated. + */ +import * as NodeHttpPlatform from "@effect/platform-node/NodeHttpPlatform" +import * as NodeServices from "@effect/platform-node/NodeServices" +import { AccessToken, CryptoLive, layerPool } from "@hazel/auth/server" +import { BotRepo, UserRepo } from "@hazel/backend-core" +import { CurrentUser } from "@hazel/domain" +import { AuthMiddleware } from "@hazel/domain/rpc" +import { ConfigProvider, Effect, Layer, Redacted } from "effect" +import { Base64Url } from "effect/encoding" +import { + Etag, + HttpClient, + HttpClientResponse, + HttpRouter, + HttpServerRequest, + HttpServerResponse, +} from "effect/http" +import type { SuccessValue } from "effect/rpc/RpcMiddleware" +import { afterAll, beforeAll, describe, expect, it } from "vitest" +import { AuthMiddlewareLive } from "../rpc/middleware/auth" +import { createChatSyncDbHarness, type ChatSyncDbHarness } from "../test/chat-sync-db-harness" +import { serviceShape } from "../test/effect-helpers" +import { AuthorizationLive } from "./auth" +import { HazelAuthInstance, HazelAuthRoutes, HazelAuthServicesLive } from "./hazel-auth" +import { HazelSession } from "./hazel-session" +import { SessionManager } from "./session-manager" + +const api = "http://localhost:3003" +const app = "http://localhost:3000" +const key = Base64Url.encode(new Uint8Array(32).fill(7)) + +const FakeGitHub = Layer.succeed( + HttpClient.HttpClient, + HttpClient.make((request, url) => + Effect.sync(() => { + const json = (body: unknown) => + HttpClientResponse.fromWeb( + request, + new Response(JSON.stringify(body), { headers: { "content-type": "application/json" } }), + ) + if (url.href === "https://github.com/login/oauth/access_token") + return json({ access_token: "gho_1", token_type: "bearer", scope: "read:user,user:email" }) + if (url.href === "https://api.github.com/user") + return json({ + id: 777, + login: "octo", + name: "Octo Cat", + email: null, + avatar_url: "https://avatars.githubusercontent.com/u/777", + }) + if (url.href.startsWith("https://api.github.com/user/emails")) + return json([{ email: "octo@example.com", primary: true, verified: true }]) + return HttpClientResponse.fromWeb(request, new Response("not found", { status: 404 })) + }), + ), +) + +/** Clerk is not involved: any Clerk path reaching it is a test failure. */ +const NoClerk = Layer.succeed( + SessionManager, + serviceShape({ + authenticateWithBearer: () => Effect.die(new Error("Clerk should not be consulted")), + }), +) + +const successValue = { _: Symbol("success") } as SuccessValue + +const tagOf = (error: unknown) => + typeof error === "object" && error !== null && "_tag" in error ? String(error._tag) : "unknown" + +/** Stand-ins for an RPC call and an HttpApi endpoint, each behind the real middleware. */ +const ProbeRoutes = Layer.unwrap( + Effect.gen(function* () { + const rpcAuth = yield* AuthMiddleware + const apiAuth = yield* CurrentUser.Authorization + const whoAmI = Effect.map(Effect.service(CurrentUser.Context), (user) => user.email) + + return Layer.mergeAll( + HttpRouter.add( + "POST", + "/probe/rpc", + Effect.gen(function* () { + const request = yield* HttpServerRequest.HttpServerRequest + let email = "" + yield* rpcAuth( + Effect.map(whoAmI, (value) => { + email = value + return successValue + }), + { + client: {} as never, + requestId: 1n as never, + rpc: {} as never, + payload: undefined, + headers: request.headers, + }, + ) + return yield* HttpServerResponse.json({ email }) + }).pipe( + Effect.catch((error) => + HttpServerResponse.json({ error: tagOf(error) }, { status: 401 }), + ), + ), + ), + HttpRouter.add( + "GET", + "/probe/api", + apiAuth + .bearer( + Effect.flatMap(whoAmI, (email) => + HttpServerResponse.json({ email }).pipe(Effect.orDie), + ), + { credential: Redacted.make(""), endpoint: {} as never, group: {} as never }, + ) + .pipe( + Effect.catch((error) => + HttpServerResponse.json({ error: tagOf(error) }, { status: 401 }), + ), + ), + ), + ) + }), +) + +describe("Hazel sign-in in the backend", () => { + let harness: ChatSyncDbHarness + let web: ReturnType + + beforeAll(async () => { + harness = await createChatSyncDbHarness() + const url = Redacted.make(harness.container.getConnectionUri()) + + const pair = await Effect.runPromise( + AccessToken.generateSigningKey("test").pipe(Effect.provide(CryptoLive)), + ) + const env = ConfigProvider.fromUnknown({ + API_BASE_URL: api, + FRONTEND_URL: app, + AUTH_GITHUB_CLIENT_ID: "Iv1.test", + AUTH_GITHUB_CLIENT_SECRET: "secret", + AUTH_BINDING_KEY: key, + AUTH_TRANSACTION_KEY: key, + AUTH_ACCESS_TOKEN_KID: "test", + AUTH_ACCESS_TOKEN_PRIVATE_JWK: JSON.stringify(Redacted.value(pair.privateKey)), + }) + + // As in app.ts: AppAuthorizationLive's pieces, under the entry point's platform. + const Auth = Layer.mergeAll(AuthMiddlewareLive, AuthorizationLive).pipe( + Layer.provideMerge(HazelSession.layer), + Layer.provideMerge(HazelAuthServicesLive), + Layer.provideMerge(HazelAuthInstance.layer), + Layer.provide(NoClerk), + ) + + web = HttpRouter.toWebHandler( + Layer.mergeAll(HazelAuthRoutes, ProbeRoutes).pipe( + Layer.provide(Auth), + Layer.provide([UserRepo.layer, BotRepo.layer]), + Layer.provide(harness.dbLayer), + Layer.provide(layerPool(url)), + Layer.provide(FakeGitHub), + Layer.provide([NodeServices.layer, NodeHttpPlatform.layer, Etag.layer]), + Layer.provide(ConfigProvider.layer(env)), + ) as Layer.Layer, + { disableLogger: !process.env.DEBUG_AUTH }, + ) + }, 180_000) + + afterAll(async () => { + await web?.dispose() + await harness?.stop() + }, 60_000) + + const cookies = new Map() + const cookieHeader = () => [...cookies].map(([name, value]) => `${name}=${value}`).join("; ") + const send = async (path: string, init: RequestInit & { origin?: string | null } = {}) => { + const headers = new Headers(init.headers) + if (init.origin !== null) headers.set("origin", init.origin ?? app) + headers.set("cookie", cookieHeader()) + const response = await (web.handler as (request: Request) => Promise)( + new Request(`${api}${path}`, { ...init, headers, redirect: "manual" }), + ) + for (const raw of response.headers.getSetCookie()) { + const [pair] = raw.split(";") + const index = pair!.indexOf("=") + const [name, value] = [pair!.slice(0, index), pair!.slice(index + 1)] + if (value === "" || /max-age=0/i.test(raw)) cookies.delete(name) + else cookies.set(name, value) + } + return response + } + const post = (path: string, payload: unknown = {}) => + send(path, { + method: "POST", + headers: { "content-type": "application/json", "x-effect-auth-csrf": "1" }, + body: JSON.stringify({ payload }), + }) + + it("signs up with GitHub and is recognised by the RPC and HttpApi middleware", async () => { + const begin = await post("/auth/signIn", { provider: "github", returnTarget: "/" }) + expect(begin.status).toBe(200) + const state = new URL((await begin.json()).value.authorizationUrl).searchParams.get("state")! + + const callback = await send( + `/auth/github/callback?${new URLSearchParams({ code: "c", state, iss: "https://github.com/login/oauth" })}`, + { origin: null }, + ) + expect(callback.status).toBe(303) + const registration = new URL(callback.headers.get("location")!) + expect(`${registration.origin}${registration.pathname}`).toBe(`${app}/auth/register`) + + const registered = await post("/auth/register", { + reference: registration.searchParams.get("reference"), + flowId: registration.searchParams.get("flowId"), + commandId: crypto.randomUUID(), + registration: { firstName: "Octo", lastName: "Cat" }, + }) + expect((await registered.json()).value._tag).toBe("Authenticated") + expect(cookies.has("effect-auth-session")).toBe(true) + + const rpc = await send("/probe/rpc", { method: "POST" }) + expect(await rpc.json()).toEqual({ email: "octo@example.com" }) + + const httpApi = await send("/probe/api") + expect(await httpApi.json()).toEqual({ email: "octo@example.com" }) + }, 60_000) + + it("rejects a cookie session sent from an untrusted origin", async () => { + const response = await send("/probe/rpc", { method: "POST", origin: "https://evil.example" }) + expect(response.status).toBe(401) + expect(await response.json()).toEqual({ error: "InvalidBearerTokenError" }) + }) + + it("accepts the session credential as a bearer token (native clients)", async () => { + const credential = cookies.get("effect-auth-session")! + const saved = new Map(cookies) + cookies.clear() + try { + const response = await send("/probe/rpc", { + method: "POST", + origin: null, + headers: { authorization: `Bearer ${credential}` }, + }) + expect(await response.json()).toEqual({ email: "octo@example.com" }) + } finally { + for (const [name, value] of saved) cookies.set(name, value) + } + }) + + it("exchanges the session for an access token that verifies against the JWKS", async () => { + const minted = await post("/auth/token") + expect(minted.status).toBe(200) + const { token } = await minted.json() + const jwks = await (await send("/.well-known/jwks.json")).json() + + const verified = await Effect.runPromise( + AccessToken.verifyAccessToken(api, token).pipe( + Effect.provide(AccessToken.Jwks.layerLocal(jwks)), + Effect.provide(CryptoLive), + ), + ) + const me = await (await send("/probe/api")).json() + expect(me.email).toBe("octo@example.com") + expect(verified.claims.iss).toBe(api) + }) + + it("signing out ends the session for the middleware too", async () => { + expect((await post("/auth/signOut")).status).toBe(200) + const response = await send("/probe/rpc", { method: "POST" }) + expect(response.status).toBe(401) + expect(cookies.has("effect-auth-session")).toBe(false) + }) +}) diff --git a/apps/backend/src/services/hazel-auth.ts b/apps/backend/src/services/hazel-auth.ts new file mode 100644 index 000000000..b9d4ab1df --- /dev/null +++ b/apps/backend/src/services/hazel-auth.ts @@ -0,0 +1,167 @@ +import { Hooks } from "@yielded/auth" +import { AccessToken, HazelAuth, type HazelAuthConfig, makeHazelAuthLive } from "@hazel/auth/server" +import { Config, Context, Effect, Layer, Option, Redacted, Schema } from "effect" +import { HttpRouter, HttpServerResponse } from "effect/http" + +/** + * Hazel's own sign-in (`@hazel/auth/server`), replacing Clerk. Enabled when + * `AUTH_GITHUB_CLIENT_ID` is set; until then Clerk remains the only sign-in and these + * routes are not mounted. + * + * - `/auth/*`: sign-in, registration, passkeys and sessions (cookie on the API host). + * - `POST /auth/token`: exchanges the session for a short-lived JWT for the hosts that + * cannot see the API's cookie (Electric proxy, actors), when a signing key is set. + * - `GET /.well-known/jwks.json`: the public key those hosts verify it with. + */ + +const keyring = (name: string) => + Config.Redacted(name).pipe( + Config.map((material) => ({ activeKeyId: "v1", keys: [{ id: "v1", material }] })), + ) + +const origins = (name: string) => + Config.String(name).pipe( + Config.map((value) => + value + .split(",") + .map((origin) => origin.trim()) + .filter((origin) => origin !== ""), + ), + Config.withDefault([] as Array), + ) + +const AuthConfig = Config.all({ + githubClientId: Config.option(Config.String("AUTH_GITHUB_CLIENT_ID")), + apiBaseUrl: Config.String("API_BASE_URL"), + frontendUrl: Config.String("FRONTEND_URL"), + extraOrigins: origins("AUTH_TRUSTED_ORIGINS"), + passkeyRpId: Config.option(Config.String("AUTH_PASSKEY_RP_ID")), + googleClientId: Config.option(Config.String("AUTH_GOOGLE_CLIENT_ID")), + accessTokenKid: Config.String("AUTH_ACCESS_TOKEN_KID").pipe(Config.withDefault("v1")), + accessTokenJwk: Config.option(Config.Redacted("AUTH_ACCESS_TOKEN_PRIVATE_JWK")), +}) + +const PrivateJwk = Schema.fromJsonString(Schema.Unknown) + +const make = Effect.gen(function* () { + const env = yield* AuthConfig + if (Option.isNone(env.githubClientId)) { + yield* Effect.logInfo("[auth] AUTH_GITHUB_CLIENT_ID is not set; Hazel sign-in is disabled") + return Option.none() + } + + const origin = new URL(env.apiBaseUrl).origin + const appOrigin = new URL(env.frontendUrl).origin + const trustedOrigins = [...new Set([appOrigin, ...env.extraOrigins])] + + const google = Option.isSome(env.googleClientId) + ? { + clientId: env.googleClientId.value, + clientSecret: yield* Config.Redacted("AUTH_GOOGLE_CLIENT_SECRET"), + } + : undefined + + const config: HazelAuthConfig = { + origin, + appOrigin, + trustedOrigins, + github: { + clientId: env.githubClientId.value, + clientSecret: yield* Config.Redacted("AUTH_GITHUB_CLIENT_SECRET"), + }, + ...(google === undefined ? {} : { google }), + passkey: { + rpId: Option.getOrElse(env.passkeyRpId, () => new URL(appOrigin).hostname), + origins: trustedOrigins, + }, + keys: { + binding: yield* keyring("AUTH_BINDING_KEY"), + transaction: yield* keyring("AUTH_TRANSACTION_KEY"), + }, + returnTargets: ["/"], + registrationPath: "/auth/register", + } + + const accessTokens = Option.map( + env.accessTokenJwk, + (jwk): AccessToken.SigningKey => ({ + kid: env.accessTokenKid, + privateJwk: Redacted.make(Schema.decodeSync(PrivateJwk)(Redacted.value(jwk))), + }), + ) + + return Option.some({ ...makeHazelAuthLive(config), config, accessTokens }) +}) + +/** + * The configured auth instance, shared by the routes (`HazelAuthRoutes`) and the services + * every request handler can use (`HazelAuthServicesLive`) so both come from one `Http.make`. + */ +export class HazelAuthInstance extends Context.Service()( + "@hazel/backend/HazelAuthInstance", + { + make, + }, +) { + static readonly layer = Layer.effect(this, this.make) +} + +/** `HazelAuth` and its storage, for the RPC and HttpApi auth middleware. Empty when disabled. */ +export const HazelAuthServicesLive = Layer.unwrap( + Effect.map(Effect.service(HazelAuthInstance), (instance) => + Option.match(instance, { + onNone: () => Layer.empty, + onSome: ({ http, Services }) => + http.layer.pipe(Layer.provideMerge(Services), Layer.provide(Hooks.LifecycleHooks.empty)), + }), + ), +) + +/** Session → access token exchange, and the key set that verifies those tokens. */ +const accessTokenRoutes = (issuerOrigin: string, key: AccessToken.SigningKey) => + Layer.unwrap( + Effect.gen(function* () { + const issuer = yield* AccessToken.makeIssuer(issuerOrigin, key) + const publicJwk = yield* AccessToken.publicJwk(key) + return Layer.mergeAll( + HttpRouter.add( + "POST", + "/auth/token", + Effect.gen(function* () { + const auth = yield* HazelAuth + const session = yield* auth.getSession() + if (session === null) return HttpServerResponse.empty({ status: 401 }) + const token = yield* issuer.mint(session) + return yield* HttpServerResponse.json({ + token: Redacted.value(token), + expiresIn: AccessToken.lifetimeSeconds, + }) + }), + ), + HttpRouter.add( + "GET", + "/.well-known/jwks.json", + HttpServerResponse.json({ keys: [publicJwk] }).pipe( + Effect.map(HttpServerResponse.setHeader("cache-control", "public, max-age=300")), + ), + ), + ) + }), + ) + +/** `/auth/*` routes. Needs `HazelAuthServicesLive` below it. Empty when disabled. */ +export const HazelAuthRoutes = Layer.unwrap( + Effect.map(Effect.service(HazelAuthInstance), (instance) => + Option.match(instance, { + onNone: () => Layer.empty, + onSome: ({ http, config, accessTokens }) => + Layer.mergeAll( + http.routes(), + Option.match(accessTokens, { + onNone: () => Layer.empty, + onSome: (key) => accessTokenRoutes(config.origin, key).pipe(http.middleware), + }), + ), + }), + ), +) diff --git a/apps/backend/src/services/hazel-session.ts b/apps/backend/src/services/hazel-session.ts new file mode 100644 index 000000000..deb3b5677 --- /dev/null +++ b/apps/backend/src/services/hazel-session.ts @@ -0,0 +1,116 @@ +import { HazelAuth } from "@hazel/auth/server" +import { UserRepo } from "@hazel/backend-core" +import { CurrentUser, InvalidBearerTokenError, SessionLoadError } from "@hazel/domain" +import type { UserId } from "@hazel/schema" +import { Context, Effect, Layer, Option, Redacted } from "effect" +import { Cookies, Headers } from "effect/http" +import { HazelAuthInstance } from "./hazel-auth" + +/** + * Resolves Hazel sessions (`@hazel/auth/server`) to the current user for the RPC and + * HttpApi auth middleware. Browsers send the session as an HttpOnly cookie on the API + * host; native clients send the credential as a bearer token. + */ +export class HazelSession extends Context.Service()("@hazel/backend/HazelSession", { + make: Effect.gen(function* () { + const instance = yield* HazelAuthInstance + const auth = yield* Effect.serviceOption(HazelAuth) + const userRepo = yield* UserRepo + + const enabled = Option.isSome(instance) && Option.isSome(auth) + const cookieName = Option.isSome(instance) ? instance.value.sessionCookieName : undefined + const allowedOrigins = new Set( + Option.isSome(instance) + ? [instance.value.config.origin, ...(instance.value.config.trustedOrigins ?? [])] + : [], + ) + + const invalid = (detail: string) => + new InvalidBearerTokenError({ message: "Invalid session", detail }) + + const loadUser = (subjectId: string) => + userRepo.findById(subjectId as UserId).pipe( + Effect.catchTags({ + DatabaseError: (error) => + Effect.fail( + new SessionLoadError({ message: "Failed to load user", detail: String(error) }), + ), + }), + Effect.flatMap( + Option.match({ + onNone: () => Effect.fail(invalid("User not found")), + onSome: (user) => + user.deletedAt !== null + ? Effect.fail(invalid("User is deleted")) + : Effect.succeed( + new CurrentUser.Schema({ + id: user.id, + role: "member", + organizationId: undefined, + avatarUrl: user.avatarUrl ?? undefined, + firstName: user.firstName, + lastName: user.lastName, + email: user.email, + isOnboarded: user.isOnboarded, + timezone: user.timezone, + settings: user.settings, + }), + ), + }), + ), + ) + + /** Verifies a session credential; `None` when it is not a valid Hazel session. */ + const verify = ( + credential: string, + ): Effect.Effect, InvalidBearerTokenError | SessionLoadError> => + Effect.gen(function* () { + if (Option.isNone(auth)) return Option.none() + const session = yield* auth.value.verifySession(Redacted.make(credential)).pipe( + Effect.map(Option.some), + Effect.catchTag("SessionInvalid", () => Effect.succeedNone), + // Every other outcome is the session store failing, not a bad credential. + Effect.mapError( + (error) => + new SessionLoadError({ + message: "Session verification failed", + detail: error._tag, + }), + ), + ) + if (Option.isNone(session)) return Option.none() + return Option.some(yield* loadUser(session.value.subjectId)) + }) + + /** + * The session cookie, if the request carries one. Cookie-authenticated requests must + * come from a trusted origin: mutations are POSTs, which browsers always send with an + * Origin, so a missing or foreign Origin means a cross-site request. + */ + const fromCookie = ( + headers: Headers.Headers, + method: string, + ): Effect.Effect, InvalidBearerTokenError | SessionLoadError> => + Effect.gen(function* () { + if (!enabled || cookieName === undefined) return Option.none() + const cookie = Option.flatMap(Headers.get(headers, "cookie"), (header) => + Option.fromNullishOr(Cookies.parseHeader(header)[cookieName]), + ) + if (Option.isNone(cookie)) return Option.none() + + const origin = Headers.get(headers, "origin") + const safe = method === "GET" || method === "HEAD" + if (!safe && (Option.isNone(origin) || !allowedOrigins.has(origin.value))) { + return yield* Effect.fail(invalid("Untrusted origin for a cookie session")) + } + + const user = yield* verify(cookie.value) + if (Option.isNone(user)) return yield* Effect.fail(invalid("Session expired or revoked")) + return user + }) + + return { enabled, verify, fromCookie } as const + }), +}) { + static readonly layer = Layer.effect(this, this.make).pipe(Layer.provide(UserRepo.layer)) +} diff --git a/apps/backend/src/worker/env.ts b/apps/backend/src/worker/env.ts index 6141e9c9d..4817b76ad 100644 --- a/apps/backend/src/worker/env.ts +++ b/apps/backend/src/worker/env.ts @@ -28,6 +28,19 @@ export const apiEnv = ({ stage, urls }: HazelStackContext) => requirePlainEntry("CLERK_PUBLISHABLE_KEY"), optionalSecret("CLERK_WEBHOOK_SECRET"), + // Auth (Hazel, replacing Clerk): off until AUTH_GITHUB_CLIENT_ID is set. Keys come from + // `bun run --cwd packages/auth generate-keys`. + optionalPlain("AUTH_GITHUB_CLIENT_ID"), + optionalSecret("AUTH_GITHUB_CLIENT_SECRET"), + optionalPlain("AUTH_GOOGLE_CLIENT_ID"), + optionalSecret("AUTH_GOOGLE_CLIENT_SECRET"), + optionalSecret("AUTH_BINDING_KEY"), + optionalSecret("AUTH_TRANSACTION_KEY"), + optionalPlain("AUTH_ACCESS_TOKEN_KID"), + optionalSecret("AUTH_ACCESS_TOKEN_PRIVATE_JWK"), + optionalPlain("AUTH_PASSKEY_RP_ID"), + optionalPlain("AUTH_TRUSTED_ORIGINS"), + // Uploads: presigned against the bucket's S3 API (R2) requirePlainEntry("S3_BUCKET"), requirePlainEntry("S3_ENDPOINT"), diff --git a/apps/backend/src/worker/platform.ts b/apps/backend/src/worker/platform.ts index a44347239..be8df5375 100644 --- a/apps/backend/src/worker/platform.ts +++ b/apps/backend/src/worker/platform.ts @@ -3,6 +3,7 @@ * caches, `ConfigProvider.fromEnv`), rebuilt over Cloudflare bindings. */ import type { KVNamespace } from "@cloudflare/workers-types" +import { AuthSqlConnection, layerDirect as authSqlDirect, makeRequestConnection } from "@hazel/auth/server" import { Database } from "@hazel/db" import { layerKvResultPersistence } from "@hazel/effect-cloudflare" import { readHazelDbBinding } from "@hazel/infra/cloudflare" @@ -39,6 +40,8 @@ export const requestPlatformLive = (env: Record, botGateways: B BotGatewayTransport.layerDurableObject(botGateways), // Suspended: init also runs at plan time, where the KV binding does not exist yet. Layer.suspend(() => layerKvResultPersistence(cacheNamespace(env))), + // Auth storage's startup schema checks run outside any request's connection. + Layer.suspend(() => authSqlDirect(Redacted.make(hazelDbConnectionString(env)))), workerEnvLayer(env), ) @@ -58,12 +61,25 @@ export const objectPlatformLive = (env: Record, botGateways: Bo workerEnvLayer(env), ) -/** Opens the request's Postgres client; it connects lazily and is closed with the request scope. */ -export const provideRequestDatabase = (env: Record) => - Effect.provideServiceEffect( - Database.DatabaseConnection, - Effect.acquireRelease( - Effect.sync(() => Database.makeRequestConnection(hazelDbConnectionString(env))), - (connection) => Effect.promise(() => connection.end()).pipe(Effect.ignore), - ), - ) +/** + * Opens the request's Postgres clients (Drizzle, and Effect SQL for auth storage); both + * connect lazily and are closed with the request scope. + */ +export const provideRequestDatabase = + (env: Record) => + (effect: Effect.Effect) => + effect.pipe( + Effect.provideServiceEffect( + AuthSqlConnection, + Effect.flatMap(Effect.scope, (scope) => + makeRequestConnection(Redacted.make(hazelDbConnectionString(env)), scope), + ), + ), + Effect.provideServiceEffect( + Database.DatabaseConnection, + Effect.acquireRelease( + Effect.sync(() => Database.makeRequestConnection(hazelDbConnectionString(env))), + (connection) => Effect.promise(() => connection.end()).pipe(Effect.ignore), + ), + ), + ) diff --git a/bun.lock b/bun.lock index b8dfd696e..9211ff6ed 100644 --- a/bun.lock +++ b/bun.lock @@ -55,6 +55,7 @@ "@hazel/infra": "workspace:*", "@hazel/integrations": "workspace:*", "@hazel/schema": "workspace:*", + "@yielded/auth": "0.1.0-beta.32", "alchemy": "catalog:alchemy", "aws4fetch": "^1.0.20", "dfx": "^1.1.0", diff --git a/packages/auth/package.json b/packages/auth/package.json index 7ea2d5761..a832a1b93 100644 --- a/packages/auth/package.json +++ b/packages/auth/package.json @@ -12,6 +12,10 @@ "./contract": "./src/yielded/contract.ts", "./server": "./src/yielded/index.ts" }, + "scripts": { + "test:integration": "vitest run src/yielded", + "generate-keys": "bun scripts/generate-keys.ts" + }, "dependencies": { "@clerk/backend": "^3.3.0", "@effect/sql-pg": "catalog:effect", @@ -33,8 +37,5 @@ "@effect/platform-node": "catalog:effect", "@types/bun": "1.3.9", "typescript": "^5.9.3" - }, - "scripts": { - "test:integration": "vitest run src/yielded" } } diff --git a/packages/auth/scripts/generate-keys.ts b/packages/auth/scripts/generate-keys.ts new file mode 100644 index 000000000..3e5c41c27 --- /dev/null +++ b/packages/auth/scripts/generate-keys.ts @@ -0,0 +1,22 @@ +/** + * Prints fresh secrets for Hazel sign-in, as env lines: + * - AUTH_BINDING_KEY / AUTH_TRANSACTION_KEY: 32 random bytes each (request binding and + * OAuth state encryption). + * - AUTH_ACCESS_TOKEN_PRIVATE_JWK: an ES256 key for the access tokens the Electric proxy + * and actors verify. Changing it invalidates outstanding access tokens (5 minutes). + * + * Usage: bun run --cwd packages/auth generate-keys + */ +import { Effect, Redacted } from "effect" +import { Base64Url } from "effect/encoding" +import { AccessToken, CryptoLive } from "../src/yielded/index.ts" + +const randomKey = () => Base64Url.encode(crypto.getRandomValues(new Uint8Array(32))) +const kid = new Date().toISOString().slice(0, 10) + +const pair = await Effect.runPromise(AccessToken.generateSigningKey(kid).pipe(Effect.provide(CryptoLive))) + +console.log(`AUTH_BINDING_KEY=${randomKey()}`) +console.log(`AUTH_TRANSACTION_KEY=${randomKey()}`) +console.log(`AUTH_ACCESS_TOKEN_KID=${kid}`) +console.log(`AUTH_ACCESS_TOKEN_PRIVATE_JWK='${JSON.stringify(Redacted.value(pair.privateKey))}'`) diff --git a/packages/auth/src/yielded/access-token.ts b/packages/auth/src/yielded/access-token.ts index e284f5003..a8d84bfdc 100644 --- a/packages/auth/src/yielded/access-token.ts +++ b/packages/auth/src/yielded/access-token.ts @@ -1,6 +1,8 @@ +import { InvalidKey } from "@yielded/jose/Errors" import * as Jwk from "@yielded/jose/Jwk" import * as Jwks from "@yielded/jose/Jwks" import * as Jwt from "@yielded/jose/Jwt" +import { Signature } from "@yielded/crypto/Signature" import { DateTime, Effect, Redacted, Schema } from "effect" /** @@ -8,9 +10,10 @@ import { DateTime, Effect, Redacted, Schema } from "effect" * The browser's session cookie is host-only on api.hazel.sh, so those services can't see * it. The backend exchanges a valid session for this ES256 JWT; the services verify it * locally against the published JWKS, without a database or network call. + * + * The issuer is the API origin, so each stage's tokens only verify on that stage. */ -export const issuer = "https://api.hazel.sh" export const audience = "hazel-services" export const lifetimeSeconds = 300 @@ -23,12 +26,17 @@ export const AccessClaims = Schema.Struct({ exp: Schema.Finite, }) -export const makeIssuer = (input: { +export interface SigningKey { readonly kid: string + /** ES256 private JWK. */ readonly privateJwk: Redacted.Redacted -}) => +} + +export const makeIssuer = (issuer: string, key: SigningKey) => Effect.gen(function* () { - const key = yield* Jwk.importPrivate(input.privateJwk, "ES256") + const privateKey = yield* Jwk.importPrivate(key.privateJwk, "ES256") + // Held here so minting needs nothing from the request. + const signature = yield* Signature return { mint: (session: { readonly subjectId: string; readonly sessionId: string }) => @@ -44,15 +52,28 @@ export const makeIssuer = (input: { iat: now, exp: now + lifetimeSeconds, }, - key, - { alg: "ES256", kid: input.kid, typ: "at+jwt" }, + privateKey, + { alg: "ES256", kid: key.kid, typ: "at+jwt" }, ) - }), + }).pipe(Effect.provideService(Signature, signature)), } }) +/** The public half of a signing key, as published in the JWKS. */ +export const publicJwk = (key: SigningKey) => + Effect.gen(function* () { + const jwk = Redacted.value(key.privateJwk) + if (typeof jwk !== "object" || jwk === null) return yield* InvalidKey.make({}) + const { d: _secret, ...publicPart } = jwk as Record + const imported = yield* Jwk.importPublic( + { ...publicPart, kid: key.kid, alg: "ES256", use: "sig" }, + "ES256", + ) + return yield* Jwk.exportPublic(imported) + }) + /** Verification used by electric-proxy / actors. Requires `Jwks` (local or remote). */ -export const verifyAccessToken = (token: string) => +export const verifyAccessToken = (issuer: string, token: string) => Jwt.verifyWithKeySet(AccessClaims, Redacted.make(token), { algorithms: ["ES256"], issuer, @@ -61,6 +82,7 @@ export const verifyAccessToken = (token: string) => requiredClaims: ["iss", "sub", "aud", "exp", "iat"], }) +/** A new ES256 key pair; the private JWK goes in `AUTH_ACCESS_TOKEN_PRIVATE_JWK`. */ export const generateSigningKey = (kid: string) => Jwk.generateKeyPair({ algorithm: "ES256", kid }) export { Jwks } diff --git a/packages/auth/src/yielded/hazel-auth.test.ts b/packages/auth/src/yielded/hazel-auth.test.ts index fa4a8e968..0ced0356c 100644 --- a/packages/auth/src/yielded/hazel-auth.test.ts +++ b/packages/auth/src/yielded/hazel-auth.test.ts @@ -80,6 +80,7 @@ describe.skipIf(DATABASE_URL === undefined)("Hazel auth on Postgres", () => { const { http, Services, sessionCookieName } = makeHazelAuthLive({ origin, + appOrigin: webOrigin, trustedOrigins: [webOrigin], github: { clientId: "Iv1.test", clientSecret: Redacted.make("test-secret") }, passkey: { rpId: "localhost", origins: [webOrigin] }, @@ -244,7 +245,9 @@ describe.skipIf(DATABASE_URL === undefined)("Hazel auth on Postgres", () => { ) cookies.store(callback) expect(callback.status).toBe(303) - return new URL(callback.headers.get("location")!, origin) + const location = new URL(callback.headers.get("location")!) + expect(location.origin).toBe(webOrigin) + return location } const register = (cookies: Jar, location: URL) => @@ -353,22 +356,34 @@ describe.skipIf(DATABASE_URL === undefined)("Hazel auth on Postgres", () => { const pair = await Effect.runPromise( AccessToken.generateSigningKey("k1").pipe(Effect.provide(CryptoLive)), ) + const key = { kid: "k1", privateJwk: pair.privateKey } const issuer = await Effect.runPromise( - AccessToken.makeIssuer({ kid: "k1", privateJwk: pair.privateKey }).pipe( - Effect.provide(CryptoLive), - ), + AccessToken.makeIssuer(origin, key).pipe(Effect.provide(CryptoLive)), ) + // The JWKS publishes the key derived from the private JWK alone. + const published = await Effect.runPromise(AccessToken.publicJwk(key).pipe(Effect.provide(CryptoLive))) + expect(published).not.toHaveProperty("d") const token = await Effect.runPromise( issuer .mint({ subjectId: "00000000-0000-4000-8000-000000000001", sessionId: "s1" }) .pipe(Effect.provide(CryptoLive)), ) const verified = await Effect.runPromise( - AccessToken.verifyAccessToken(Redacted.value(token)).pipe( - Effect.provide(AccessToken.Jwks.layerLocal({ keys: [pair.publicKey] })), + AccessToken.verifyAccessToken(origin, Redacted.value(token)).pipe( + Effect.provide(AccessToken.Jwks.layerLocal({ keys: [published] })), Effect.provide(CryptoLive), ), ) expect(verified.claims.sub).toBe("00000000-0000-4000-8000-000000000001") + + // Another stage's issuer does not verify. + const foreign = await Effect.runPromise( + AccessToken.verifyAccessToken("https://api.hazel.sh", Redacted.value(token)).pipe( + Effect.provide(AccessToken.Jwks.layerLocal({ keys: [published] })), + Effect.provide(CryptoLive), + Effect.result, + ), + ) + expect(foreign._tag).toBe("Failure") }) }) diff --git a/packages/auth/src/yielded/server.ts b/packages/auth/src/yielded/server.ts index fc57c4c78..40953089f 100644 --- a/packages/auth/src/yielded/server.ts +++ b/packages/auth/src/yielded/server.ts @@ -33,7 +33,9 @@ export interface Keyring { export interface HazelAuthConfig { /** The backend origin that serves /auth/* (api.hazel.sh; localhost:3003 in dev). */ readonly origin: string - /** The web app origin(s) calling the API with credentials (app.hazel.sh). */ + /** The web app (app.hazel.sh); sign-in redirects land here. */ + readonly appOrigin: string + /** Origins allowed to call the auth routes with credentials, besides `origin`. */ readonly trustedOrigins?: ReadonlyArray readonly github: { readonly clientId: string; readonly clientSecret: Redacted.Redacted } readonly google?: { readonly clientId: string; readonly clientSecret: Redacted.Redacted } @@ -42,9 +44,9 @@ export interface HazelAuthConfig { readonly binding: Keyring readonly transaction: Keyring } - /** Paths the OAuth callback may return to after sign-in. */ + /** Paths on the web app the OAuth callback may return to after sign-in. */ readonly returnTargets: ReadonlyArray - /** Where the callback sends a first-time user to pick their name. */ + /** Path on the web app where the callback sends a first-time user to pick their name. */ readonly registrationPath: string } @@ -81,12 +83,16 @@ export const makeHazelAuthHttp = (config: HazelAuthConfig) => { HazelAuthApi.actions.completeSignIn.route.operation.rpc.successSchema, )(value).pipe(Effect.orDie) + // Targets are paths on the web app, which is a different origin from the API. const target = "_tag" in result && result._tag === "RegistrationRequired" ? `${config.registrationPath}?${new URLSearchParams({ flowId, reference: result.reference })}` : result.returnTarget - return new Response(null, { status: 303, headers: { location: target } }) + return new Response(null, { + status: 303, + headers: { location: new URL(target, config.appOrigin).href }, + }) }), }, }) diff --git a/packages/auth/src/yielded/sql.ts b/packages/auth/src/yielded/sql.ts index c8cda8333..0738ef006 100644 --- a/packages/auth/src/yielded/sql.ts +++ b/packages/auth/src/yielded/sql.ts @@ -62,12 +62,14 @@ export const layerRouted: Layer.Layer = Layer.effect( ).pipe(Layer.provide(Reactivity.layer)) /** One pooled client for a long-running process (Bun). */ -export const layerPool = (url: Redacted.Redacted) => +export const layerPool = (url: Redacted.Redacted, options: { readonly ssl?: boolean } = {}) => Layer.effect( AuthSqlConnection, - PgClient.make({ url, maxConnections: 5 }).pipe( - Effect.map((client) => ({ client: Effect.succeed(client) })), - ), + PgClient.make({ + url, + maxConnections: 5, + ...(options.ssl === undefined ? {} : { ssl: options.ssl }), + }).pipe(Effect.map((client) => ({ client: Effect.succeed(client) }))), ).pipe(Layer.provide(Reactivity.layer)) const connect = (url: Redacted.Redacted) => From 796c6bba17df8d27069589b9054ca0298ff69494 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Sun, 11 Oct 2026 01:11:16 +0200 Subject: [PATCH 3/4] feat(auth): carry Clerk users over to Hazel sign-in - import-clerk-identities links each user's GitHub and Google accounts from Clerk to their existing Hazel user (dry run by default, idempotent), so their first sign-in lands in their own account. - A first-time GitHub or Google sign-in whose provider-verified email belongs to exactly one existing user links to that user and restarts sign-in via the web app's /auth/continue, instead of registering a duplicate account. Co-Authored-By: Claude Opus 5.5 --- .../import-clerk-identities.ts | 153 ++++++++++++++++++ apps/backend/src/services/hazel-auth.test.ts | 53 +++++- apps/backend/src/services/hazel-auth.ts | 1 + packages/auth/src/yielded/hazel-auth.test.ts | 89 ++++++++++ packages/auth/src/yielded/identities.ts | 115 +++++++++++++ packages/auth/src/yielded/index.ts | 10 ++ packages/auth/src/yielded/server.ts | 61 ++++++- packages/auth/src/yielded/sql.ts | 4 + packages/auth/src/yielded/storage.ts | 47 +++++- 9 files changed, 522 insertions(+), 11 deletions(-) create mode 100644 apps/backend/src/scripts/migrate-to-hazel-auth/import-clerk-identities.ts create mode 100644 packages/auth/src/yielded/identities.ts diff --git a/apps/backend/src/scripts/migrate-to-hazel-auth/import-clerk-identities.ts b/apps/backend/src/scripts/migrate-to-hazel-auth/import-clerk-identities.ts new file mode 100644 index 000000000..b5b54ddc2 --- /dev/null +++ b/apps/backend/src/scripts/migrate-to-hazel-auth/import-clerk-identities.ts @@ -0,0 +1,153 @@ +/** + * Carries users over from Clerk to Hazel sign-in: links each user's GitHub and Google + * accounts (as recorded by Clerk) to their existing Hazel user, so their first sign-in on + * the new system lands in their own account. + * + * Dry run by default: reads Clerk and the database, writes nothing, and prints how users + * split up. `--apply` writes the links. Safe to re-run (linking is idempotent), so it can + * run nightly until the cutover to pick up new Clerk sign-ups. + * + * Usage: + * CLERK_SECRET_KEY=… DATABASE_URL=… bun run src/scripts/migrate-to-hazel-auth/import-clerk-identities.ts + * CLERK_SECRET_KEY=… DATABASE_URL=… bun run src/scripts/migrate-to-hazel-auth/import-clerk-identities.ts --apply + * + * Output: a summary, and ./exports/hazel-auth-import-report.json with the users in each + * group (it contains emails: keep it local). + * + * Groups: + * - linked: has GitHub or Google in Clerk. Imported; signs straight in. + * - emailOnly: no GitHub or Google. Signs in with GitHub or Google whose verified email + * matches theirs (linked automatically at sign-in), unless the email is shared. + * - emailShared: email-only and another active user has the same email, so sign-in can't + * match it automatically. Needs a manual link. + * - conflicts: the provider account is already linked to a different Hazel user. + * - missingInHazel / missingInClerk: present on one side only. + */ +import { mkdirSync, writeFileSync } from "node:fs" +import { createClerkClient, type User as ClerkUser } from "@clerk/backend" +import { PgClient } from "@effect/sql-pg" +import { CryptoLive, isProvider, linkIdentity, type Provider } from "@hazel/auth/server" +import { Config, Effect, Redacted } from "effect" +import { SqlClient } from "effect/sql" + +const apply = process.argv.includes("--apply") +const REPORT_PATH = "./exports/hazel-auth-import-report.json" +const PAGE_SIZE = 500 + +type HazelUser = { id: string; externalId: string; email: string; deleted: boolean; userType: string } +type Entry = { hazelUserId?: string; clerkUserId?: string; email?: string; detail?: string } + +const listClerkUsers = (secretKey: string) => + Effect.tryPromise(async () => { + const clerk = createClerkClient({ secretKey }) + const users: Array = [] + for (let offset = 0; ; offset += PAGE_SIZE) { + const page = await clerk.users.getUserList({ limit: PAGE_SIZE, offset, orderBy: "+created_at" }) + users.push(...page.data) + if (page.data.length < PAGE_SIZE) return users + } + }) + +/** Verified GitHub and Google accounts; Clerk names providers `oauth_github` / `oauth_google`. */ +const providerAccounts = (user: ClerkUser) => + user.externalAccounts.flatMap((account) => { + const provider = account.provider.replace(/^oauth_/, "") + if (!isProvider(provider) || account.providerUserId === "") return [] + if (account.verification !== null && account.verification.status !== "verified") return [] + return [{ provider: provider as Provider, subject: account.providerUserId }] + }) + +const primaryEmail = (user: ClerkUser) => + user.emailAddresses.find((address) => address.id === user.primaryEmailAddressId)?.emailAddress + +const program = Effect.gen(function* () { + const secretKey = yield* Config.Redacted("CLERK_SECRET_KEY") + const sql = yield* SqlClient.SqlClient + + const hazelUsers = yield* sql` + select id::text as id, "externalId", email, "deletedAt" is not null as deleted, "userType" + from users` + const byClerkId = new Map(hazelUsers.map((user) => [user.externalId, user])) + const activeByEmail = new Map() + for (const user of hazelUsers) { + if (user.deleted || user.userType !== "user") continue + const email = user.email.toLowerCase() + activeByEmail.set(email, (activeByEmail.get(email) ?? 0) + 1) + } + + const clerkUsers = yield* listClerkUsers(Redacted.value(secretKey)) + yield* Effect.log(`Clerk users: ${clerkUsers.length}. Hazel users: ${hazelUsers.length}.`) + + const report = { + linked: [] as Array, + emailOnly: [] as Array, + emailShared: [] as Array, + conflicts: [] as Array, + missingInHazel: [] as Array, + missingInClerk: [] as Array, + } + let identitiesLinked = 0 + let identitiesAlreadyLinked = 0 + + for (const clerkUser of clerkUsers) { + const email = primaryEmail(clerkUser) + const hazel = byClerkId.get(clerkUser.id) + if (hazel === undefined) { + report.missingInHazel.push({ clerkUserId: clerkUser.id, ...(email ? { email } : {}) }) + continue + } + if (hazel.deleted || hazel.userType !== "user") continue + const entry: Entry = { hazelUserId: hazel.id, clerkUserId: clerkUser.id, email: hazel.email } + + const accounts = providerAccounts(clerkUser) + if (accounts.length === 0) { + const shared = (activeByEmail.get(hazel.email.toLowerCase()) ?? 0) > 1 + ;(shared ? report.emailShared : report.emailOnly).push(entry) + continue + } + + report.linked.push({ ...entry, detail: accounts.map((account) => account.provider).join("+") }) + if (!apply) continue + for (const account of accounts) { + const result = yield* linkIdentity({ ...account, userId: hazel.id }) + if (result._tag === "Linked") identitiesLinked++ + else if (result._tag === "AlreadyLinked") identitiesAlreadyLinked++ + else + report.conflicts.push({ + ...entry, + detail: `${account.provider} account already belongs to Hazel user ${result.ownerId}`, + }) + } + } + + const clerkIds = new Set(clerkUsers.map((user) => user.id)) + for (const user of hazelUsers) { + if (user.deleted || user.userType !== "user" || clerkIds.has(user.externalId)) continue + // Users created by Hazel sign-in itself are not in Clerk by design. + if (user.externalId.startsWith("hazel:")) continue + report.missingInClerk.push({ hazelUserId: user.id, email: user.email, detail: user.externalId }) + } + + mkdirSync("./exports", { recursive: true }) + writeFileSync(REPORT_PATH, JSON.stringify(report, null, "\t")) + + yield* Effect.log(apply ? "Applied." : "Dry run: nothing written. Re-run with --apply to link.") + yield* Effect.log(` linked (GitHub/Google): ${report.linked.length}`) + yield* Effect.log(` email only: ${report.emailOnly.length}`) + yield* Effect.log(` email shared (manual): ${report.emailShared.length}`) + yield* Effect.log(` missing in Hazel: ${report.missingInHazel.length}`) + yield* Effect.log(` missing in Clerk: ${report.missingInClerk.length}`) + if (apply) { + yield* Effect.log(` identities linked now: ${identitiesLinked}`) + yield* Effect.log(` already linked: ${identitiesAlreadyLinked}`) + yield* Effect.log(` conflicts (manual): ${report.conflicts.length}`) + } + yield* Effect.log(`Report: ${REPORT_PATH}`) +}) + +const SqlLive = PgClient.layerConfig({ url: Config.Redacted("DATABASE_URL") }) + +Effect.runPromise(program.pipe(Effect.provide(SqlLive), Effect.provide(CryptoLive))).catch((error) => { + console.error(error) + process.exit(1) +}) diff --git a/apps/backend/src/services/hazel-auth.test.ts b/apps/backend/src/services/hazel-auth.test.ts index 5857d739d..06b4eb468 100644 --- a/apps/backend/src/services/hazel-auth.test.ts +++ b/apps/backend/src/services/hazel-auth.test.ts @@ -8,6 +8,7 @@ import * as NodeHttpPlatform from "@effect/platform-node/NodeHttpPlatform" import * as NodeServices from "@effect/platform-node/NodeServices" import { AccessToken, CryptoLive, layerPool } from "@hazel/auth/server" import { BotRepo, UserRepo } from "@hazel/backend-core" +import { Database, schema } from "@hazel/db" import { CurrentUser } from "@hazel/domain" import { AuthMiddleware } from "@hazel/domain/rpc" import { ConfigProvider, Effect, Layer, Redacted } from "effect" @@ -34,6 +35,9 @@ const api = "http://localhost:3003" const app = "http://localhost:3000" const key = Base64Url.encode(new Uint8Array(32).fill(7)) +/** The GitHub account the fake signs in as. */ +let github = { id: 777, email: "octo@example.com" } + const FakeGitHub = Layer.succeed( HttpClient.HttpClient, HttpClient.make((request, url) => @@ -47,14 +51,14 @@ const FakeGitHub = Layer.succeed( return json({ access_token: "gho_1", token_type: "bearer", scope: "read:user,user:email" }) if (url.href === "https://api.github.com/user") return json({ - id: 777, - login: "octo", + id: github.id, + login: `octo${github.id}`, name: "Octo Cat", email: null, - avatar_url: "https://avatars.githubusercontent.com/u/777", + avatar_url: `https://avatars.githubusercontent.com/u/${github.id}`, }) if (url.href.startsWith("https://api.github.com/user/emails")) - return json([{ email: "octo@example.com", primary: true, verified: true }]) + return json([{ email: github.email, primary: true, verified: true }]) return HttpClientResponse.fromWeb(request, new Response("not found", { status: 404 })) }), ), @@ -275,4 +279,45 @@ describe("Hazel sign-in in the backend", () => { expect(response.status).toBe(401) expect(cookies.has("effect-auth-session")).toBe(false) }) + + it("a Clerk user's first GitHub sign-in lands in their existing account by verified email", async () => { + // Signed up through Clerk with email: a users row and no linked accounts. + const [existing] = await harness.run( + Effect.gen(function* () { + const db = yield* Database.Database + return yield* db.execute((client) => + client + .insert(schema.usersTable) + .values({ + externalId: "user_clerk_email_only", + email: "Clerk.User@Example.com", + firstName: "Clerk", + lastName: "User", + isOnboarded: true, + }) + .returning({ id: schema.usersTable.id }), + ) + }), + ) + github = { id: 888, email: "clerk.user@example.com" } + + const signIn = async () => { + const begin = await post("/auth/signIn", { provider: "github", returnTarget: "/" }) + const state = new URL((await begin.json()).value.authorizationUrl).searchParams.get("state")! + const callback = await send( + `/auth/github/callback?${new URLSearchParams({ code: "c", state, iss: "https://github.com/login/oauth" })}`, + { origin: null }, + ) + return new URL(callback.headers.get("location")!) + } + + const first = await signIn() + expect(`${first.origin}${first.pathname}`).toBe(`${app}/auth/continue`) + expect(first.searchParams.get("provider")).toBe("github") + + expect((await signIn()).href).toBe(`${app}/`) + const rpc = await send("/probe/rpc", { method: "POST" }) + expect(await rpc.json()).toEqual({ email: "Clerk.User@Example.com" }) + expect(existing).toBeDefined() + }, 60_000) }) diff --git a/apps/backend/src/services/hazel-auth.ts b/apps/backend/src/services/hazel-auth.ts index b9d4ab1df..bfdaced04 100644 --- a/apps/backend/src/services/hazel-auth.ts +++ b/apps/backend/src/services/hazel-auth.ts @@ -80,6 +80,7 @@ const make = Effect.gen(function* () { }, returnTargets: ["/"], registrationPath: "/auth/register", + continuePath: "/auth/continue", } const accessTokens = Option.map( diff --git a/packages/auth/src/yielded/hazel-auth.test.ts b/packages/auth/src/yielded/hazel-auth.test.ts index 0ced0356c..8f5c514e9 100644 --- a/packages/auth/src/yielded/hazel-auth.test.ts +++ b/packages/auth/src/yielded/hazel-auth.test.ts @@ -29,6 +29,7 @@ import { afterAll, beforeAll, describe, expect, test } from "vitest" import * as AccessToken from "./access-token.ts" import { HazelAuth } from "./auth.ts" import { CryptoLive, makeHazelAuthLive } from "./server.ts" +import { identityKey, linkIdentity } from "./identities.ts" import { AuthSqlConnection, AuthSqlDirect, layerDirect, makeRequestConnection } from "./sql.ts" const DATABASE_URL = process.env.AUTH_TEST_DATABASE_URL @@ -87,6 +88,7 @@ describe.skipIf(DATABASE_URL === undefined)("Hazel auth on Postgres", () => { keys: { binding: keyring(1), transaction: keyring(2) }, returnTargets: ["/"], registrationPath: "/auth/register-profile", + continuePath: "/auth/continue", }) const MeRoute = HttpRouter.add( @@ -386,4 +388,91 @@ describe.skipIf(DATABASE_URL === undefined)("Hazel auth on Postgres", () => { ) expect(foreign._tag).toBe("Failure") }) + + // ---- Carrying users over from Clerk ---- + + /** A user who signed up through Clerk: a users row with a Clerk id and nothing else. */ + const insertClerkUser = (email: string) => + query( + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient + const [row] = yield* sql<{ id: string }>` + insert into users ("externalId", email, "firstName", "lastName", "isOnboarded") + values (${`user_${crypto.randomUUID()}`}, ${email}, 'Clerk', 'User', true) + returning id::text as id` + return row!.id + }), + ) + + const usersWithEmail = (email: string) => + query( + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient + return (yield* sql`select id from users where lower(email) = lower(${email})`).length + }), + ) + + test("the identity key matches the one the library writes", async () => { + const [row] = await query( + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient + return yield* sql<{ identityKey: string }>` + select "identityKey" from auth_oauth_identities where "externalSubject" = '4242'` + }), + ) + const computed = await Effect.runPromise( + identityKey({ provider: "github", subject: "4242" }).pipe(Effect.provide(CryptoLive)), + ) + expect(computed).toBe(row!.identityKey) + }) + + test("an imported GitHub account signs straight into the existing account", async () => { + const userId = await insertClerkUser("imported@example.com") + const link = (subject: string) => + query(linkIdentity({ provider: "github", subject, userId }).pipe(Effect.provide(CryptoLive))) + expect(await link("9001")).toEqual({ _tag: "Linked" }) + expect(await link("9001")).toEqual({ _tag: "AlreadyLinked" }) + + githubUserId = 9001 + const browser = jar() + expect((await signInWithGitHub(browser)).pathname).toBe("/") + const me = await (await call(browser, "GET", "/me")).json() + expect(me.userId).toBe(userId) + expect(me.displayName).toBe("Clerk User") + }, 60_000) + + test("an account linked to someone else is reported, not reassigned", async () => { + const other = await insertClerkUser("other@example.com") + const result = await query( + linkIdentity({ provider: "github", subject: "9001", userId: other }).pipe( + Effect.provide(CryptoLive), + ), + ) + expect(result._tag).toBe("Conflict") + }) + + test("a verified email match continues into the existing account, with no duplicate", async () => { + // The fake GitHub reports octo@example.com as the verified primary email. + const userId = await insertClerkUser("Octo5555@Example.com") + githubUserId = 5555 + const browser = jar() + + const first = await signInWithGitHub(browser) + expect(first.pathname).toBe("/auth/continue") + expect(first.searchParams.get("provider")).toBe("github") + + // The web app's continue page starts sign-in again; it now resolves to the user. + expect((await signInWithGitHub(browser)).pathname).toBe("/") + const me = await (await call(browser, "GET", "/me")).json() + expect(me.userId).toBe(userId) + expect(await usersWithEmail("octo5555@example.com")).toBe(1) + }, 60_000) + + test("an ambiguous email never links", async () => { + await insertClerkUser("octo6666@example.com") + await insertClerkUser("octo6666@example.com") + githubUserId = 6666 + const browser = jar() + expect((await signInWithGitHub(browser)).pathname).toBe("/auth/register-profile") + }, 60_000) }) diff --git a/packages/auth/src/yielded/identities.ts b/packages/auth/src/yielded/identities.ts new file mode 100644 index 000000000..b0e4c18b9 --- /dev/null +++ b/packages/auth/src/yielded/identities.ts @@ -0,0 +1,115 @@ +import { Crypto, Effect } from "effect" +import { Base64Url } from "effect/encoding" +import { SqlClient } from "effect/sql" + +import { oauthNamespace } from "./auth.ts" + +/** + * Links a GitHub or Google account to an existing Hazel user, so that signing in with it + * resolves to that user instead of starting a registration. Used to carry users over + * from Clerk (their linked accounts are imported ahead of time) and to match a + * provider-verified email to an existing account at sign-in. + * + * Writes the same three rows as a registration through the library: the identity's + * owner, its login credential and the matching authority row. + */ + +/** The issuers the library records for each provider. */ +export const providerIssuers = { + github: "https://github.com/login/oauth", + google: "https://accounts.google.com", +} as const + +export type Provider = keyof typeof providerIssuers + +export interface ExternalIdentity { + readonly provider: Provider + /** GitHub's numeric user id, or Google's `sub`. */ + readonly subject: string +} + +const encoder = new TextEncoder() + +/** + * The library's identity key (`oauthIdentityKey` in @yielded/auth-persistence, not + * exported): SHA-256 over length-prefixed UTF-8 fields. Tested against keys the library + * writes itself; yielded-dev/auth#200 asks for an exported import path. + */ +export const identityKey = (identity: ExternalIdentity) => + Effect.gen(function* () { + const fields = [ + "effect-auth/oauth-identity-key/v1", + identity.provider, + providerIssuers[identity.provider], + identity.subject, + ].map((field) => encoder.encode(field)) + + const packed = new Uint8Array(fields.reduce((size, value) => size + 4 + value.length, 0)) + const view = new DataView(packed.buffer) + let offset = 0 + for (const value of fields) { + view.setUint32(offset, value.length, false) + packed.set(value, offset + 4) + offset += 4 + value.length + } + + const crypto = yield* Crypto.Crypto + return "v1:" + Base64Url.encode(yield* crypto.digest("SHA-256", packed)) + }) + +export type LinkResult = + | { readonly _tag: "Linked" } + | { readonly _tag: "AlreadyLinked" } + /** The account already belongs to another Hazel user; nothing was written. */ + | { readonly _tag: "Conflict"; readonly ownerId: string } + +/** Idempotent: re-linking the same account to the same user is a no-op. */ +export const linkIdentity = (input: ExternalIdentity & { readonly userId: string }) => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient + const crypto = yield* Crypto.Crypto + const key = yield* identityKey(input) + const credentialId = yield* crypto.randomUUIDv4 + + return yield* sql.withTransaction( + Effect.gen(function* () { + const inserted = yield* sql<{ identityKey: string }>` + insert into auth_oauth_identities ("identityKey", provider, issuer, "externalSubject", "subjectId") + values (${key}, ${input.provider}, ${providerIssuers[input.provider]}, ${input.subject}, ${input.userId}::uuid) + on conflict ("identityKey") do nothing + returning "identityKey"` + + if (inserted.length === 0) { + const [owner] = yield* sql<{ subjectId: string }>` + select "subjectId"::text as "subjectId" from auth_oauth_identities where "identityKey" = ${key}` + return owner?.subjectId === input.userId + ? ({ _tag: "AlreadyLinked" } as const) + : ({ _tag: "Conflict", ownerId: owner?.subjectId ?? "" } as const) + } + + yield* sql` + insert into auth_credentials ("credentialId", "subjectId", revision, active) + values (${credentialId}, ${input.userId}::uuid, 'initial', true)` + yield* sql` + insert into auth_oauth_logins ("moduleId", "credentialId", "subjectId", "identityKey", "credentialRevision", status) + values (${oauthNamespace}, ${credentialId}, ${input.userId}::uuid, ${key}, 'initial', 'active')` + return { _tag: "Linked" } as const + }), + ) + }) + +/** + * The one active person (not a bot) with this email, compared case-insensitively. + * `undefined` when there is none or more than one: an ambiguous match never links. + */ +export const findUserByEmail = (email: string) => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient + const rows = yield* sql<{ id: string }>` + select id::text as id from users + where lower(email) = lower(${email}) and "deletedAt" is null and "userType" = 'user' + limit 2` + return rows.length === 1 ? rows[0]!.id : undefined + }) + +export const isProvider = (value: string): value is Provider => value === "github" || value === "google" diff --git a/packages/auth/src/yielded/index.ts b/packages/auth/src/yielded/index.ts index db30c15c1..8d0b00719 100644 --- a/packages/auth/src/yielded/index.ts +++ b/packages/auth/src/yielded/index.ts @@ -5,6 +5,16 @@ */ export * as AccessToken from "./access-token.ts" export { HazelAuth, requirement } from "./auth.ts" +export { + type ExternalIdentity, + findUserByEmail, + identityKey, + isProvider, + type LinkResult, + linkIdentity, + type Provider, + providerIssuers, +} from "./identities.ts" export { Claims, HazelAuthApi, Registration } from "./contract.ts" export { CryptoLive, diff --git a/packages/auth/src/yielded/server.ts b/packages/auth/src/yielded/server.ts index 40953089f..970765bb1 100644 --- a/packages/auth/src/yielded/server.ts +++ b/packages/auth/src/yielded/server.ts @@ -48,13 +48,54 @@ export interface HazelAuthConfig { readonly returnTargets: ReadonlyArray /** Path on the web app where the callback sends a first-time user to pick their name. */ readonly registrationPath: string + /** + * Path on the web app that starts sign-in again with `?provider=`. The callback sends + * users there when their provider account was just linked to an existing Hazel account + * by verified email, so the next sign-in lands in that account. + */ + readonly continuePath: string } +/** The provider of a registration intent whose identity now has an owner, if any. */ +const linkedProvider = (sql: SqlClient.SqlClient, reference: string) => + Effect.gen(function* () { + const rows = yield* sql<{ provider: string }>` + select i.provider from auth_oauth_registrations r + join auth_oauth_identities i on i."identityKey" = r."identityKey" + where r.reference = ${reference} + limit 1` + return rows[0]?.provider + }).pipe( + Effect.catchTag("SqlError", (error) => + Effect.as(Effect.logWarning("[auth] could not check for a linked account", error), undefined), + ), + ) + /** The browser session cookie: `__Host-` prefixed whenever the API is served over HTTPS. */ export const sessionCookieName = (origin: string) => new URL(origin).protocol === "https:" ? "__Host-effect-auth-session" : "effect-auth-session" -export const makeHazelAuthHttp = (config: HazelAuthConfig) => { +/** + * The auth services' own SQL client, held for the OAuth callback's redirect hook. The + * library runs that hook under its request handling context, which need not include + * application services; this keeps it independent of how the routes are composed. + */ +const makeSqlHandle = () => { + let client: SqlClient.SqlClient | undefined + return { + layer: Layer.effectDiscard( + Effect.map(Effect.service(SqlClient.SqlClient), (sql) => { + client = sql + }), + ), + get: () => client, + } +} + +export const makeHazelAuthHttp = ( + config: HazelAuthConfig, + sqlHandle: { readonly get: () => SqlClient.SqlClient | undefined } = { get: () => undefined }, +) => { const secure = new URL(config.origin).protocol === "https:" const github = GitHub.provider({ @@ -84,10 +125,16 @@ export const makeHazelAuthHttp = (config: HazelAuthConfig) => { )(value).pipe(Effect.orDie) // Targets are paths on the web app, which is a different origin from the API. - const target = - "_tag" in result && result._tag === "RegistrationRequired" - ? `${config.registrationPath}?${new URLSearchParams({ flowId, reference: result.reference })}` - : result.returnTarget + let target = result.returnTarget as string + if ("_tag" in result && result._tag === "RegistrationRequired") { + const sql = sqlHandle.get() + const provider = + sql === undefined ? undefined : yield* linkedProvider(sql, result.reference) + target = + provider === undefined + ? `${config.registrationPath}?${new URLSearchParams({ flowId, reference: result.reference })}` + : `${config.continuePath}?${new URLSearchParams({ provider })}` + } return new Response(null, { status: 303, @@ -152,7 +199,8 @@ const PasskeyClaimsLive = Layer.effect( ) export const makeHazelAuthLive = (config: HazelAuthConfig) => { - const { http } = makeHazelAuthHttp(config) + const sqlHandle = makeSqlHandle() + const { http } = makeHazelAuthHttp(config, sqlHandle) // One "default" profile; validated by the library at layer build. const passkeyConfig = Passkey.PasskeyConfig.layer({ @@ -181,6 +229,7 @@ export const makeHazelAuthLive = (config: HazelAuthConfig) => { OAuth.OAuthReturnTargets.exactRoutes(config.returnTargets), SimpleWebAuthn.layer, ActionPoliciesLive, + sqlHandle.layer, ).pipe( Layer.provideMerge(Storage), // Postgres stamps session times with its own clock; tolerate it running slightly diff --git a/packages/auth/src/yielded/sql.ts b/packages/auth/src/yielded/sql.ts index 0738ef006..215486428 100644 --- a/packages/auth/src/yielded/sql.ts +++ b/packages/auth/src/yielded/sql.ts @@ -34,9 +34,13 @@ export class AuthSqlDirect extends Context.Service< export const layerRouted: Layer.Layer = Layer.effect( SqlClient.SqlClient, Effect.gen(function* () { + // A process-wide connection (Bun's pool) provided at construction, if any. + const ambient = yield* Effect.serviceOption(AuthSqlConnection) const direct = yield* Effect.serviceOption(AuthSqlDirect) + // The request's connection, else the process-wide one, else a dedicated connection. const acquirer = Effect.serviceOption(AuthSqlConnection).pipe( + Effect.map((current) => Option.orElse(current, () => ambient)), Effect.flatMap( Option.match({ onSome: (connection) => connection.client, diff --git a/packages/auth/src/yielded/storage.ts b/packages/auth/src/yielded/storage.ts index 97ec2d0bd..5f719297e 100644 --- a/packages/auth/src/yielded/storage.ts +++ b/packages/auth/src/yielded/storage.ts @@ -3,8 +3,10 @@ import { AuthPersistence, PersistenceMappingError } from "@yielded/auth-persiste import type { SubjectIdCodec } from "@yielded/auth-persistence/Adapter" import * as Mapping from "@yielded/auth-persistence/OAuthPersistence" import { Context, Crypto, Effect, Layer, Schema } from "effect" +import { SqlClient } from "effect/sql" import { HazelAuth, requirement } from "./auth.ts" +import { findUserByEmail, isProvider, linkIdentity, providerIssuers } from "./identities.ts" import { Registration } from "./contract.ts" import * as T from "./tables.ts" @@ -141,9 +143,39 @@ const registrationJson = Schema.fromJsonString(Registration) const emailFromIntent = (intent: OAuth.OAuthRegistrationIntent) => intent.profile?.email ?? `${intent.identity.subject}@${intent.identity.provider}.invalid` +/** + * A first-time GitHub or Google sign-in whose provider-verified email belongs to exactly + * one existing user is linked to that user (users who signed up through Clerk with + * email). The registration intent is still issued; the callback then sees the link and + * restarts sign-in, which resolves to the existing account. Matching failures only log: + * the user falls back to registering. + */ +const linkByVerifiedEmail = (intent: OAuth.OAuthRegistrationIntent) => + Effect.gen(function* () { + const { issuer, subject } = intent.identity + const provider: string = intent.identity.provider + const email = intent.profile?.email + if (!isProvider(provider) || issuer !== providerIssuers[provider]) return + if (intent.profile?.emailVerified !== true || email === undefined) return + + const userId = yield* findUserByEmail(email) + if (userId === undefined) return + const linked = yield* linkIdentity({ provider, subject, userId }) + yield* Effect.logInfo("[auth] linked a provider account by verified email", { + provider, + userId, + result: linked._tag, + }) + }).pipe( + Effect.catchCause((cause) => + Effect.logWarning("[auth] verified-email linking failed; continuing to registration", cause), + ), + ) + export const OAuthStorageLive = Layer.effectContext( Effect.gen(function* () { const crypto = yield* Crypto.Crypto + const sqlClient = yield* SqlClient.SqlClient const uuid = crypto.randomUUIDv4.pipe( Effect.mapError((cause) => PersistenceMappingError.make({ operation: "hazel.allocate", cause })), @@ -240,7 +272,20 @@ export const OAuthStorageLive = Layer.effectContext( }) return Context.make(OAuth.OAuthSignInPersistence, signIn.oauthSignInPersistence).pipe( - Context.add(OAuth.OAuthRegistrationIntents, intents.oauthRegistrationIntents), + Context.add(OAuth.OAuthRegistrationIntents, { + ...intents.oauthRegistrationIntents, + issue: (input, prepare) => + intents.oauthRegistrationIntents + .issue(input, prepare) + .pipe( + Effect.tap(() => + linkByVerifiedEmail(input.intent).pipe( + Effect.provideService(SqlClient.SqlClient, sqlClient), + Effect.provideService(Crypto.Crypto, crypto), + ), + ), + ), + }), Context.add(OAuth.OAuthAccountsPersistence, accounts.oauthAccountsPersistence), Context.add( HazelAuth.strategies.social.registration.RegistrationAuthority, From 7a75aa02b8870c098c282c6934c2533c936ba473 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Sun, 11 Oct 2026 01:25:15 +0200 Subject: [PATCH 4/4] chore(auth): prd migration SQL and rollout runbook for Hazel sign-in The auth tables were applied to prd by hand from Drizzle's DDL, since drizzle-kit push would act on unrelated prd drift. The runbook records what is done in prd (schema, 116 imported Clerk accounts, secrets) and what is left before the cutover. Co-Authored-By: Claude Opus 5.5 --- infra/hazel-auth-rollout.md | 47 ++++++ packages/db/sql/2026-10-11-hazel-auth.sql | 195 ++++++++++++++++++++++ 2 files changed, 242 insertions(+) create mode 100644 infra/hazel-auth-rollout.md create mode 100644 packages/db/sql/2026-10-11-hazel-auth.sql diff --git a/infra/hazel-auth-rollout.md b/infra/hazel-auth-rollout.md new file mode 100644 index 000000000..f403cbbb0 --- /dev/null +++ b/infra/hazel-auth-rollout.md @@ -0,0 +1,47 @@ +# Hazel sign-in rollout (replacing Clerk) + +Hazel's own sign-in (`@hazel/auth/server`, on `@yielded/auth`): GitHub, Google and passkeys, +with sessions in Postgres. The backend serves it alongside Clerk and only turns it on when +`AUTH_GITHUB_CLIENT_ID` is set. + +## Done in prd (2026-10-11) + +- **Schema:** `packages/db/sql/2026-10-11-hazel-auth.sql` applied (the `auth_*` tables plus + `users."authActive"` / `"securityRevision"`). It was applied by hand, not `drizzle-kit push`: + prd has tables the Drizzle schema doesn't declare (e.g. `invitations`), and push would treat + them as renames or drops. Check any future push against a schema-only copy of prd first. +- **Clerk accounts imported:** `apps/backend/src/scripts/migrate-to-hazel-auth/import-clerk-identities.ts --apply` + linked 116 users' GitHub and Google accounts (83 Google, 33 GitHub), with no conflicts. + It's safe to re-run; re-run it right before the cutover to pick up new Clerk sign-ups. +- **Secrets in Infisical `prod`:** `AUTH_BINDING_KEY`, `AUTH_TRANSACTION_KEY`, + `AUTH_ACCESS_TOKEN_KID`, `AUTH_ACCESS_TOKEN_PRIVATE_JWK`. + +## Who moves how (prd, 2026-10-11) + +| Clerk users | Count | On first sign-in | +| -------------------- | ------------------------------------------------------------------- | -------------------------------------------------------- | +| GitHub/Google linked | 116 matched (128 in Clerk; 12 have no Hazel user) | Straight into their account | +| Email only | 532 (517 never signed in via Clerk; 7 active in 90 days; 362 Gmail) | Matched by provider-verified email, via `/auth/continue` | + +Active users in the last 90 days: 60, of whom 53 have GitHub/Google linked. + +## Remaining + +1. **OAuth apps.** Create these, then put the credentials in Infisical `prod`: + - GitHub OAuth App: callback `https://api.hazel.sh/auth/github/callback`. Store as + `AUTH_GITHUB_CLIENT_ID` / `AUTH_GITHUB_CLIENT_SECRET`. + - Google OAuth client (web): redirect URI `https://api.hazel.sh/auth/google/callback`. Store + as `AUTH_GOOGLE_CLIENT_ID` / `AUTH_GOOGLE_CLIENT_SECRET`. + - Setting `AUTH_GITHUB_CLIENT_ID` turns the routes on at the next deploy. Do this only + once the web app is ready. +2. **Web app (`apps/web-foldkit`).** Build these pages: sign-in (GitHub, Google, passkey), + `/auth/register` (name form), `/auth/continue?provider=` (restarts sign-in), and passkey + settings. Then switch the RPC client to cookie credentials, and the Electric and actors + clients to `POST /auth/token`. +3. **Electric proxy and actors.** Verify the access token against + `https://api.hazel.sh/.well-known/jwks.json` instead of Clerk. +4. **Preview stage.** Do one real GitHub and Google sign-in through Hyperdrive before prd. +5. **Organizations, invites and profile editing.** Move these off Clerk. +6. **Cutover.** Re-run the import, deploy with `AUTH_GITHUB_CLIENT_ID` set, then switch the web + app. Keep Clerk read-only for 2–4 weeks, then remove it (middleware fallback, `ClerkSync`, + webhooks, `CLERK_*`). diff --git a/packages/db/sql/2026-10-11-hazel-auth.sql b/packages/db/sql/2026-10-11-hazel-auth.sql new file mode 100644 index 000000000..f1c96142c --- /dev/null +++ b/packages/db/sql/2026-10-11-hazel-auth.sql @@ -0,0 +1,195 @@ +-- Hazel sign-in (@hazel/auth/server): the auth_* tables and two users columns. +-- +-- Additive only, in one transaction; safe to apply while the app runs. Generated from the +-- Drizzle schema (packages/db/src/schema/auth.ts, users.ts) via pg_dump of a pushed +-- database, because `drizzle-kit push` against prd would also act on unrelated drift. +-- Both column defaults are constants, so adding them does not rewrite users. +-- +-- Apply: psql "$DIRECT_DATABASE_URL" -v ON_ERROR_STOP=1 -f packages/db/sql/2026-10-11-hazel-auth.sql + +BEGIN; + +ALTER TABLE public.users ADD COLUMN IF NOT EXISTS "authActive" boolean NOT NULL DEFAULT true; +ALTER TABLE public.users ADD COLUMN IF NOT EXISTS "securityRevision" text NOT NULL DEFAULT 'initial'; + + +CREATE TABLE public.auth_credentials ( + "credentialId" text NOT NULL, + "subjectId" uuid NOT NULL, + revision text NOT NULL, + active boolean NOT NULL +); + +CREATE TABLE public.auth_identifiers ( + "moduleId" text, + "credentialId" text, + namespace text NOT NULL, + value text NOT NULL, + "subjectId" uuid NOT NULL, + revision text NOT NULL, + "verifiedAt" bigint, + active boolean NOT NULL +); + +CREATE TABLE public.auth_oauth_flows ( + "moduleId" text NOT NULL, + "flowId" text NOT NULL, + purpose text NOT NULL, + generation bigint, + provider text, + "callbackId" text, + issuer text, + "responseIssuerMode" text, + "subjectId" uuid, + "stateDigest" text, + "binderVerifier" text, + "binderExpiresAt" bigint, + snapshot text, + "issuedAt" bigint, + "expiresAt" bigint +); + +CREATE TABLE public.auth_oauth_identities ( + "identityKey" text NOT NULL, + provider text NOT NULL, + issuer text NOT NULL, + "externalSubject" text NOT NULL, + "subjectId" uuid NOT NULL +); + +CREATE TABLE public.auth_oauth_logins ( + "moduleId" text NOT NULL, + "credentialId" text NOT NULL, + "subjectId" uuid NOT NULL, + "identityKey" text NOT NULL, + "credentialRevision" text NOT NULL, + status text NOT NULL +); + +CREATE TABLE public.auth_oauth_registrations ( + "moduleId" text NOT NULL, + reference text NOT NULL, + "flowId" text NOT NULL, + "identityKey" text, + snapshot text, + "expiresAt" bigint, + "retentionUntil" bigint +); + +CREATE TABLE public.auth_passkey_credentials ( + "credentialId" text NOT NULL, + "subjectId" uuid NOT NULL, + "rpId" text NOT NULL, + "protocolCredentialId" text NOT NULL, + "credentialKey" text NOT NULL, + "userHandle" text NOT NULL, + "publicKey" text NOT NULL, + algorithm bigint NOT NULL, + profile text NOT NULL, + "credentialRevision" text NOT NULL, + active boolean NOT NULL, + "primarySignIn" boolean NOT NULL, + "enrollmentUserVerified" boolean NOT NULL, + "backupEligible" boolean NOT NULL, + "backupState" boolean NOT NULL, + counter bigint NOT NULL, + name text NOT NULL, + "createdAt" bigint NOT NULL, + "lastUsedAt" bigint +); + +CREATE TABLE public.auth_passkey_flows ( + "moduleId" text NOT NULL, + "flowId" text NOT NULL, + purpose text NOT NULL, + snapshot text NOT NULL, + "requestBindingVerifier" text NOT NULL, + "requestBindingExpiresAt" bigint NOT NULL, + "issuedAt" bigint NOT NULL, + "expiresAt" bigint NOT NULL +); + +CREATE TABLE public.auth_pending ( + "moduleId" text NOT NULL, + kind text NOT NULL, + digest text NOT NULL, + version text NOT NULL, + "flowId" text NOT NULL, + "subjectId" uuid NOT NULL, + "bindingDigest" text NOT NULL, + snapshot text NOT NULL, + "expiresAt" bigint NOT NULL, + "attemptLimit" bigint NOT NULL, + "failedAttempts" bigint NOT NULL, + consumed boolean NOT NULL +); + +CREATE TABLE public.auth_sessions ( + "sessionId" text NOT NULL, + "subjectId" uuid NOT NULL, + digest text NOT NULL, + "securityRevision" text NOT NULL, + "issuedAt" bigint NOT NULL, + "expiresAt" bigint NOT NULL, + "absoluteExpiresAt" bigint NOT NULL, + record text NOT NULL +); + +ALTER TABLE ONLY public.auth_credentials + ADD CONSTRAINT "auth_credentials_credentialId_unique" UNIQUE ("credentialId"); + +ALTER TABLE ONLY public.auth_credentials + ADD CONSTRAINT auth_credentials_subject_credential UNIQUE ("subjectId", "credentialId"); + +ALTER TABLE ONLY public.auth_identifiers + ADD CONSTRAINT auth_identifiers_namespace_value UNIQUE (namespace, value); + +ALTER TABLE ONLY public.auth_oauth_flows + ADD CONSTRAINT auth_oauth_flows_module_flow UNIQUE ("moduleId", "flowId"); + +ALTER TABLE ONLY public.auth_oauth_flows + ADD CONSTRAINT "auth_oauth_flows_stateDigest_unique" UNIQUE ("stateDigest"); + +ALTER TABLE ONLY public.auth_oauth_identities + ADD CONSTRAINT auth_oauth_identities_pkey PRIMARY KEY ("identityKey"); + +ALTER TABLE ONLY public.auth_oauth_logins + ADD CONSTRAINT "auth_oauth_logins_identityKey_unique" UNIQUE ("identityKey"); + +ALTER TABLE ONLY public.auth_oauth_logins + ADD CONSTRAINT auth_oauth_logins_pkey PRIMARY KEY ("credentialId"); + +ALTER TABLE ONLY public.auth_oauth_registrations + ADD CONSTRAINT auth_oauth_registrations_module_flow UNIQUE ("moduleId", "flowId"); + +ALTER TABLE ONLY public.auth_oauth_registrations + ADD CONSTRAINT auth_oauth_registrations_module_reference UNIQUE ("moduleId", reference); + +ALTER TABLE ONLY public.auth_passkey_credentials + ADD CONSTRAINT "auth_passkey_credentials_credentialId_unique" UNIQUE ("credentialId"); + +ALTER TABLE ONLY public.auth_passkey_credentials + ADD CONSTRAINT "auth_passkey_credentials_credentialKey_unique" UNIQUE ("credentialKey"); + +ALTER TABLE ONLY public.auth_passkey_flows + ADD CONSTRAINT auth_passkey_flows_module_flow UNIQUE ("moduleId", "flowId"); + +ALTER TABLE ONLY public.auth_pending + ADD CONSTRAINT auth_pending_digest_unique UNIQUE (digest); + +ALTER TABLE ONLY public.auth_sessions + ADD CONSTRAINT auth_sessions_digest_unique UNIQUE (digest); + +ALTER TABLE ONLY public.auth_sessions + ADD CONSTRAINT "auth_sessions_sessionId_unique" UNIQUE ("sessionId"); + +CREATE INDEX auth_oauth_identities_subject_idx ON public.auth_oauth_identities USING btree ("subjectId"); + +CREATE INDEX auth_oauth_logins_owner_idx ON public.auth_oauth_logins USING btree ("identityKey", "subjectId"); + +CREATE INDEX auth_passkey_credentials_subject_idx ON public.auth_passkey_credentials USING btree ("subjectId"); + +CREATE INDEX auth_sessions_subject_idx ON public.auth_sessions USING btree ("subjectId"); + + +COMMIT;