diff --git a/README.md b/README.md index 8f6a7b2..82afee0 100644 --- a/README.md +++ b/README.md @@ -18,14 +18,14 @@ Lively Forms is a conversational form builder inspired by Typeform. It lets crea - Conditional branching with builder-side validation and branch-aware submissions - Anonymous response collection with detailed review and CSV/XLSX export - Personal and organization workspaces with shared form management -- Creator-focused experience with Google sign-in, localization, and theme settings +- Creator-focused experience with ATProto/PDS sign-in, localization, and theme settings ## Stack - Next.js App Router - TypeScript - Tailwind CSS -- Auth.js (`next-auth`) with Google OAuth +- Auth.js (`next-auth`) sessions with ATProto OAuth - Prisma + PostgreSQL - dnd-kit - Framer Motion @@ -43,9 +43,9 @@ bun run prisma:migrate bun run dev ``` -Open `http://localhost:3000`. +Open `http://127.0.0.1:3000`. -You must also configure Google OAuth for local development. See [docs/deployment.md](docs/deployment.md). +You must also configure ATProto OAuth/client metadata for local development. See [docs/deployment.md](docs/deployment.md). ## Useful commands diff --git a/app/api/atproto/client-metadata/route.ts b/app/api/atproto/client-metadata/route.ts new file mode 100644 index 0000000..d376d11 --- /dev/null +++ b/app/api/atproto/client-metadata/route.ts @@ -0,0 +1,9 @@ +import { NextResponse } from "next/server"; + +import { getAtprotoClientMetadata } from "@/lib/atproto-auth"; + +export const runtime = "nodejs"; + +export function GET() { + return NextResponse.json(getAtprotoClientMetadata()); +} diff --git a/app/api/auth/atproto/callback/route.ts b/app/api/auth/atproto/callback/route.ts new file mode 100644 index 0000000..7f2b914 --- /dev/null +++ b/app/api/auth/atproto/callback/route.ts @@ -0,0 +1,37 @@ +import { NextRequest, NextResponse } from "next/server"; + +import { + completeAtprotoSignIn, + getAtprotoRedirectUrl, + getNextAuthSessionCookieName, +} from "@/lib/atproto-auth"; + +export const runtime = "nodejs"; + +function redirectWithError(error: string) { + const url = getAtprotoRedirectUrl("/"); + url.searchParams.set("authError", error); + return NextResponse.redirect(url); +} + +export async function GET(request: NextRequest) { + try { + const result = await completeAtprotoSignIn(request.nextUrl.searchParams); + const response = NextResponse.redirect( + getAtprotoRedirectUrl(result.callbackUrl), + ); + + response.cookies.set(getNextAuthSessionCookieName(), result.sessionToken, { + httpOnly: true, + sameSite: "lax", + secure: getNextAuthSessionCookieName().startsWith("__Secure-"), + path: "/", + expires: result.expires, + }); + + return response; + } catch (error) { + console.error("ATProto sign-in callback failed", error); + return redirectWithError("callback_failed"); + } +} diff --git a/app/api/auth/atproto/start/route.ts b/app/api/auth/atproto/start/route.ts new file mode 100644 index 0000000..37c73c1 --- /dev/null +++ b/app/api/auth/atproto/start/route.ts @@ -0,0 +1,39 @@ +import { NextRequest, NextResponse } from "next/server"; + +import { + getAtprotoAuthorizationUrl, + getAtprotoRedirectUrl, + getSafeAtprotoCallbackUrl, +} from "@/lib/atproto-auth"; + +export const runtime = "nodejs"; + +function redirectWithError(error: string) { + const url = getAtprotoRedirectUrl("/"); + url.searchParams.set("authError", error); + return NextResponse.redirect(url, 303); +} + +export async function POST(request: NextRequest) { + const formData = await request.formData(); + const identifier = String(formData.get("identifier") ?? ""); + const callbackUrl = getSafeAtprotoCallbackUrl( + typeof formData.get("callbackUrl") === "string" + ? String(formData.get("callbackUrl")) + : null, + ); + + try { + const authorizationUrl = await getAtprotoAuthorizationUrl(identifier, { + callbackUrl, + }); + + // The request that starts OAuth is a form POST. A 303 makes the browser + // follow the authorization URL with GET instead of replaying the POST to + // the PDS authorization page. + return NextResponse.redirect(authorizationUrl, 303); + } catch (error) { + console.error("ATProto sign-in start failed", error); + return redirectWithError("start_failed"); + } +} diff --git a/app/error.tsx b/app/error.tsx index 08809e8..9969adf 100644 --- a/app/error.tsx +++ b/app/error.tsx @@ -27,7 +27,7 @@ const errorMessages: Record< eyebrow: "Something broke", title: "The forms hit an unexpected error.", description: - "Try the action again. If it keeps happening, check your environment variables, Google OAuth setup, and local Postgres container.", + "Try the action again. If it keeps happening, check your environment variables, ATProto OAuth setup, and local Postgres container.", tryAgain: "Try again", footer: { poweredBy: "Built with", @@ -41,7 +41,7 @@ const errorMessages: Record< eyebrow: "Что-то сломалось", title: "В формах произошла непредвиденная ошибка.", description: - "Попробуйте выполнить действие ещё раз. Если это повторяется, проверьте переменные окружения, настройку Google OAuth и локальный контейнер Postgres.", + "Попробуйте выполнить действие ещё раз. Если это повторяется, проверьте переменные окружения, настройку ATProto OAuth и локальный контейнер Postgres.", tryAgain: "Попробовать снова", footer: { poweredBy: "Сделано с помощью", diff --git a/app/page.tsx b/app/page.tsx index 0cbcb8f..ad6ce62 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,50 +1,62 @@ -import Image from "next/image"; import Link from "next/link"; -import { ArrowRight } from "lucide-react"; +import { ArrowRight, Sprout } from "lucide-react"; -import { GoogleSignInButton } from "@/components/auth/google-sign-in-button"; +import { AuthErrorAlert } from "@/components/auth/auth-error-alert"; +import { AtprotoSignInForm } from "@/components/auth/atproto-sign-in-form"; import { Button } from "@/components/ui/button"; import { Card } from "@/components/ui/card"; import { getServerAuthSession } from "@/lib/auth"; import { getRequestI18n } from "@/lib/i18n-server"; -export default async function HomePage() { +export default async function HomePage({ + searchParams, +}: { + searchParams?: Promise<{ authError?: string }>; +}) { const session = await getServerAuthSession(); const { t } = await getRequestI18n(); + const params = await searchParams; + const authError = params?.authError; const appName = t("app.name"); return ( -
-
- -
-
- {appName} -

- {appName} -

-
-

- {t("home.description")} +

+
+ +
+ + + + {appName} + +
+ +
+

+ {session?.user ? t("home.welcomeBack") : t("auth.title")} +

+

+ {session?.user + ? t("home.signedInDescription") + : t("auth.description")}

-
+
{session?.user ? ( - ) : ( - +
+ {authError ? ( + + ) : null} + +
)}
diff --git a/bun.lock b/bun.lock index 03912b9..ce6824b 100644 --- a/bun.lock +++ b/bun.lock @@ -5,6 +5,8 @@ "": { "name": "the-forms", "dependencies": { + "@atproto/api": "^0.20.41", + "@atproto/oauth-client-node": "^0.5.3", "@auth/prisma-adapter": "2.11.1", "@dnd-kit/core": "6.3.1", "@dnd-kit/sortable": "10.0.0", @@ -59,6 +61,52 @@ "@asamuzakjp/nwsapi": ["@asamuzakjp/nwsapi@2.3.9", "", {}, "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q=="], + "@atproto-labs/did-resolver": ["@atproto-labs/did-resolver@0.3.7", "", { "dependencies": { "@atproto-labs/fetch": "^0.3.5", "@atproto-labs/pipe": "^0.2.4", "@atproto-labs/simple-store": "^0.5.1", "@atproto-labs/simple-store-memory": "^0.2.6", "@atproto/did": "^0.5.4", "zod": "^3.23.8" } }, "sha512-F3M3U5cU1QSAXX3J5auGEc5N2pgDgpirAjvyDFsytXuyWfrjWfTPd/H+DZrrED7gK+noW0cAWtxxjdX7/cTSVQ=="], + + "@atproto-labs/fetch": ["@atproto-labs/fetch@0.3.5", "", { "dependencies": { "@atproto-labs/pipe": "^0.2.4" } }, "sha512-iDFZEoNqfL17EVKE+uNzdUD7YF8LWhEhxeBtP6x+PNuAxoXv3ip9vLmB8nNzNxFwVn++FipfDtSiPqmziv1bdA=="], + + "@atproto-labs/fetch-node": ["@atproto-labs/fetch-node@0.3.7", "", { "dependencies": { "@atproto-labs/fetch": "^0.3.5", "@atproto-labs/pipe": "^0.2.4", "ipaddr.js": "^2.1.0", "undici_v6": "npm:undici@^6.x", "undici_v7": "npm:undici@^7.x", "undici_v8": "npm:undici@^8.x" } }, "sha512-WZvjXIeEHGeOPmeckyGiSGeoHhkvzwnKtSrRSxgnHakkNqlKNLmPbUIYOIiJgiYgBSOCbse5NpPLRu3Zdw2+gA=="], + + "@atproto-labs/handle-resolver": ["@atproto-labs/handle-resolver@0.4.8", "", { "dependencies": { "@atproto-labs/simple-store": "^0.5.1", "@atproto-labs/simple-store-memory": "^0.2.6", "@atproto/did": "^0.5.4", "zod": "^3.23.8" } }, "sha512-38I+j8Efs+cCgW/NX9RFKKu7qv/AF+FqxlKS8sWjXlNZSHQZqPj5cnKVQhSsJCs4ypPB84iKch8w4/STd33bLg=="], + + "@atproto-labs/handle-resolver-node": ["@atproto-labs/handle-resolver-node@0.2.8", "", { "dependencies": { "@atproto-labs/fetch-node": "^0.3.7", "@atproto-labs/handle-resolver": "^0.4.8", "@atproto/did": "^0.5.4" } }, "sha512-S4HR3s15TP85LpBvK2pfmJgXRFEX9rPFpcRfBPeW1Rh2Bj8ixN5VZwOgABz2fHTyMidEZLyFGxc0G5HlVh0aWw=="], + + "@atproto-labs/identity-resolver": ["@atproto-labs/identity-resolver@0.4.7", "", { "dependencies": { "@atproto-labs/did-resolver": "^0.3.7", "@atproto-labs/handle-resolver": "^0.4.8" } }, "sha512-lKDaiBHoxrqTOFdSKAAPpplEtbbFrH4djGjnXJtOSMnZ46k7+G7AkCr1xycGGPOBYfyF54zRnTp0Tt8iu86qWg=="], + + "@atproto-labs/pipe": ["@atproto-labs/pipe@0.2.4", "", {}, "sha512-n67jCcrC+ouAeO10cWkpPzzLMlDi/lDCU30Us+LGqhOPhT6c4t5ASdBLQi9W3jUQtRzQBt3G9zipF+xKWNvVbw=="], + + "@atproto-labs/simple-store": ["@atproto-labs/simple-store@0.5.1", "", {}, "sha512-vfvoDhu6ds6BT3Pqe+d2/LBU1WpDRtt69y6zltlfMCoucPm82m1j50/Wb6xTvv+oZ+2j0JyZVXsmXQ12SWYQJg=="], + + "@atproto-labs/simple-store-memory": ["@atproto-labs/simple-store-memory@0.2.6", "", { "dependencies": { "@atproto-labs/simple-store": "^0.5.1", "lru-cache": "^10.2.0" } }, "sha512-DD1v7MEfYF3BAcEpMTTpzfBLWLoI2HuyBhku2YpjHoqPrRrhCtE1alkxfPHjtjJVIjuU/XNU0cF/wB3+5FiKsA=="], + + "@atproto/api": ["@atproto/api@0.20.41", "", { "dependencies": { "@atproto/common-web": "^0.5.9", "@atproto/lexicon": "^0.7.11", "@atproto/syntax": "^0.7.4", "@atproto/xrpc": "^0.8.10", "await-lock": "^3.0.0", "multiformats": "^13.0.0", "tlds": "^1.234.0", "zod": "^3.23.8" } }, "sha512-qNgnB8VE9z9DMQkP3twlwNeIlmosfcmGVAkACqDpArvlUmtlOiXVX1t6xhOC3AjznzJvXxdXJcPmlPIQiGMRRg=="], + + "@atproto/common-web": ["@atproto/common-web@0.5.9", "", { "dependencies": { "@atproto/lex-data": "^0.1.7", "@atproto/lex-json": "^0.1.6", "@atproto/syntax": "^0.7.4", "zod": "^3.23.8" } }, "sha512-2c5C6YV8352JJz9Z5fNfcsstllaKGAb3cQW9aO7unMAGh/FzTGFq+xOwNMW2lTeagO6z530uxYm8AiSRM6O+DQ=="], + + "@atproto/did": ["@atproto/did@0.5.4", "", { "dependencies": { "zod": "^3.23.8" } }, "sha512-BlnwQ+obL+4ZA71KH/EzZ3TY+cpSxnLiUI85mjBJIQwvDF/oN2sQA18wIj9jpduviIt2b/cMtlJuzHjzBkfXvw=="], + + "@atproto/jwk": ["@atproto/jwk@0.7.4", "", { "dependencies": { "multiformats": "^13.0.0", "zod": "^3.23.8" } }, "sha512-tq7TUDmNfe1yDfpRgdGQMJdl9TUlJmREQNCag9yg5w8Evu+TOiFiLgiOCbo7X4ouRPSgd1DpOzXbUa8UyKKMZA=="], + + "@atproto/jwk-jose": ["@atproto/jwk-jose@0.2.4", "", { "dependencies": { "@atproto/jwk": "^0.7.4", "jose": "^5.2.0" } }, "sha512-gzDoA0JTwnc0ZJOBLM7WX9xFxtynRS2K1Bofb8epzoMWDQvyvfbPcfkdPrKFM7NXCFUVpGpBnsCB8KFPTf1rCg=="], + + "@atproto/jwk-webcrypto": ["@atproto/jwk-webcrypto@0.3.4", "", { "dependencies": { "@atproto/jwk": "^0.7.4", "@atproto/jwk-jose": "^0.2.4", "zod": "^3.23.8" } }, "sha512-UsFIUozqnRecXPo6HgKV4PW4FqYHxX1V3iAe0rRV6Q2RSfYD8V2mZ89pv8NpvJynnaqJArBQ7HZlcg0F4tRYhA=="], + + "@atproto/lex-data": ["@atproto/lex-data@0.1.7", "", { "dependencies": { "multiformats": "^13.0.0", "tslib": "^2.8.1", "unicode-segmenter": "^0.14.0" } }, "sha512-kW/dPLqo/WgCLV+XESR4JKwV6c1rZWJGOfuPupZGTjEDAKoBbKXdaEzX9/1vKQYbZ9U3j0DS/n7OFFK7wBugyQ=="], + + "@atproto/lex-json": ["@atproto/lex-json@0.1.6", "", { "dependencies": { "@atproto/lex-data": "^0.1.7", "tslib": "^2.8.1" } }, "sha512-mvrAd0lbyuecIHjyld8QN6MN6CBf4j0GCxLzegsvLh0SvDf+GbYWklkcQqmITL44yFQOwmA/QNIQj0Uvh7+R/g=="], + + "@atproto/lexicon": ["@atproto/lexicon@0.7.11", "", { "dependencies": { "@atproto/common-web": "^0.5.9", "@atproto/syntax": "^0.7.4", "multiformats": "^13.0.0", "zod": "^3.23.8" } }, "sha512-ukCZMCWmiu7fAlssBbLs+IMaf2K2R9mADaTKz2jEeC/xeZ3jT77Ka0S2G+ELBB+Dc9ijBACU0iPMQLhquOkuzQ=="], + + "@atproto/oauth-client": ["@atproto/oauth-client@0.8.3", "", { "dependencies": { "@atproto-labs/did-resolver": "^0.3.7", "@atproto-labs/fetch": "^0.3.5", "@atproto-labs/handle-resolver": "^0.4.8", "@atproto-labs/identity-resolver": "^0.4.7", "@atproto-labs/simple-store": "^0.5.1", "@atproto-labs/simple-store-memory": "^0.2.6", "@atproto/did": "^0.5.4", "@atproto/jwk": "^0.7.4", "@atproto/oauth-types": "^0.7.5", "@atproto/xrpc": "^0.8.10", "core-js": "^3", "multiformats": "^13.0.0", "zod": "^3.23.8" } }, "sha512-26ise6t6jvsCURx6ykR5UiQZjwSDChm1pRFZ0EZ2Cr1aqFXTK0v3pUtzqTDUxSR/i9gSCtBygHrgIV2DfW9roA=="], + + "@atproto/oauth-client-node": ["@atproto/oauth-client-node@0.5.3", "", { "dependencies": { "@atproto-labs/did-resolver": "^0.3.7", "@atproto-labs/handle-resolver-node": "^0.2.8", "@atproto-labs/simple-store": "^0.5.1", "@atproto/did": "^0.5.4", "@atproto/jwk": "^0.7.4", "@atproto/jwk-jose": "^0.2.4", "@atproto/jwk-webcrypto": "^0.3.4", "@atproto/oauth-client": "^0.8.3", "@atproto/oauth-types": "^0.7.5" } }, "sha512-4+r0FuHk3oz4cqRzSvyvVVRLGC/t56B/kWuDUkXi9fBQljSBpvLKUe5mQ0U5tmp298j+wF0PVVvSg17fdDHE4Q=="], + + "@atproto/oauth-types": ["@atproto/oauth-types@0.7.5", "", { "dependencies": { "@atproto/did": "^0.5.4", "@atproto/jwk": "^0.7.4", "zod": "^3.23.8" } }, "sha512-x75O0HsKB1IGfBikAQrrTX6EL8Rt4Q0+wMcwhZQRGPk/N/WqbYbsW3Powj4R8ZJKSfWCpVfaw32Piu1pTi891Q=="], + + "@atproto/syntax": ["@atproto/syntax@0.7.4", "", { "dependencies": { "iso-datestring-validator": "^2.2.2", "tslib": "^2.8.1" } }, "sha512-EHsEHtasH/DGPljBYecVDjweGMQ5eTu6Ns0GZ5z0qdqpOI8ipBvldWnMIxWkA+5HfZ9Dsu4V1MX0WP7wgL8cuA=="], + + "@atproto/xrpc": ["@atproto/xrpc@0.8.10", "", { "dependencies": { "@atproto/lexicon": "^0.7.11", "zod": "^3.23.8" } }, "sha512-++FNpTVF61fF8+3Kk1RYtdTO7xyUTdIc/Cmfk2CMn29Qw9tZD8bmW+M5db1Z0okYWFWK8Wqu7bc9VmF8tUB8Jg=="], + "@auth/core": ["@auth/core@0.41.1", "", { "dependencies": { "@panva/hkdf": "^1.2.1", "jose": "^6.0.6", "oauth4webapi": "^3.3.0", "preact": "10.24.3", "preact-render-to-string": "6.5.11" }, "peerDependencies": { "@simplewebauthn/browser": "^9.0.1", "@simplewebauthn/server": "^9.0.2", "nodemailer": "^7.0.7" }, "optionalPeers": ["@simplewebauthn/browser", "@simplewebauthn/server", "nodemailer"] }, "sha512-t9cJ2zNYAdWMacGRMT6+r4xr1uybIdmYa49calBPeTqwgAFPV/88ac9TEvCR85pvATiSPt8VaNf+Gt24JIT/uw=="], "@auth/prisma-adapter": ["@auth/prisma-adapter@2.11.1", "", { "dependencies": { "@auth/core": "0.41.1" }, "peerDependencies": { "@prisma/client": ">=2.26.0 || >=3 || >=4 || >=5 || >=6" } }, "sha512-Ke7DXP0Fy0Mlmjz/ZJLXwQash2UkA4621xCM0rMtEczr1kppLc/njCbUkHkIQ/PnmILjqSPEKeTjDPsYruvkug=="], @@ -529,6 +577,8 @@ "available-typed-arrays": ["available-typed-arrays@1.0.7", "", { "dependencies": { "possible-typed-array-names": "^1.0.0" } }, "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ=="], + "await-lock": ["await-lock@3.0.0", "", {}, "sha512-eO6fLiSnrJrMdjWMNK8zbVRXPs2TKJg78iKZd9wDpN3na5tcoV6EoeiOlMgk2QaAQ1gIrK1YuMsJHXWqz89tSA=="], + "aws-ssl-profiles": ["aws-ssl-profiles@1.1.2", "", {}, "sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g=="], "axe-core": ["axe-core@4.11.2", "", {}, "sha512-byD6KPdvo72y/wj2T/4zGEvvlis+PsZsn/yPS3pEO+sFpcrqRpX/TJCxvVaEsNeMrfQbCr7w163YqoD9IYwHXw=="], @@ -607,6 +657,8 @@ "cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="], + "core-js": ["core-js@3.50.0", "", {}, "sha512-BRWgOLKkFeCgRudR6zrs8p9XJZcE14grzKMMssoYrk6krtuEZ7MTKPIY5RzOnqsEKIR9kst7wNzphttraT+Yqw=="], + "crc-32": ["crc-32@1.2.2", "", { "bin": { "crc32": "bin/crc32.njs" } }, "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ=="], "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], @@ -855,6 +907,8 @@ "internal-slot": ["internal-slot@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "hasown": "^2.0.2", "side-channel": "^1.1.0" } }, "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw=="], + "ipaddr.js": ["ipaddr.js@2.5.0", "", {}, "sha512-aq+t5NAc+cS6rZQQVWC2x98CPqGtKKTMDd4Gaodv0wShnItdKg/51djkGJ1hqH+Oy0ivDftCbSLCQob8zso01w=="], + "is-alphabetical": ["is-alphabetical@2.0.1", "", {}, "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ=="], "is-alphanumerical": ["is-alphanumerical@2.0.1", "", { "dependencies": { "is-alphabetical": "^2.0.0", "is-decimal": "^2.0.0" } }, "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw=="], @@ -925,6 +979,8 @@ "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], + "iso-datestring-validator": ["iso-datestring-validator@2.2.2", "", {}, "sha512-yLEMkBbLZTlVQqOnQ4FiMujR6T4DEcCb1xizmvXS+OxuhwcbtynoosRzdMA69zZCShCNAbi+gJ71FxZBBXx1SA=="], + "iterator.prototype": ["iterator.prototype@1.1.5", "", { "dependencies": { "define-data-property": "^1.1.4", "es-object-atoms": "^1.0.0", "get-intrinsic": "^1.2.6", "get-proto": "^1.0.0", "has-symbols": "^1.1.0", "set-function-name": "^2.0.2" } }, "sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g=="], "jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="], @@ -1107,6 +1163,8 @@ "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + "multiformats": ["multiformats@13.4.2", "", {}, "sha512-eh6eHCrRi1+POZ3dA+Dq1C6jhP1GNtr9CRINMb67OKzqW9I5DUuZM/3jLPlzhgpGeiNUlEGEbkCYChXMCc/8DQ=="], + "mysql2": ["mysql2@3.15.3", "", { "dependencies": { "aws-ssl-profiles": "^1.1.1", "denque": "^2.1.0", "generate-function": "^2.3.1", "iconv-lite": "^0.7.0", "long": "^5.2.1", "lru.min": "^1.0.0", "named-placeholders": "^1.1.3", "seq-queue": "^0.0.5", "sqlstring": "^2.3.2" } }, "sha512-FBrGau0IXmuqg4haEZRBfHNWB5mUARw6hNwPDXXGg0XzVJ50mr/9hb267lvpVMnhZ1FON3qNd4Xfcez1rbFwSg=="], "named-placeholders": ["named-placeholders@1.1.6", "", { "dependencies": { "lru.min": "^1.1.0" } }, "sha512-Tz09sEL2EEuv5fFowm419c1+a/jSMiBjI9gHxVLrVdbUkkNUUfjsVYs9pVZu5oCon/kmRh9TfLEObFtkVxmY0w=="], @@ -1387,6 +1445,8 @@ "tinyglobby": ["tinyglobby@0.2.16", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg=="], + "tlds": ["tlds@1.261.0", "", { "bin": { "tlds": "bin.js" } }, "sha512-QXqwfEl9ddlGBaRFXIvNKK6OhipSiLXuRuLJX5DErz0o0Q0rYxulWLdFryTkV5PkdZct5iMInwYEGe/eR++1AA=="], + "tldts": ["tldts@7.0.28", "", { "dependencies": { "tldts-core": "^7.0.28" }, "bin": { "tldts": "bin/cli.js" } }, "sha512-+Zg3vWhRUv8B1maGSTFdev9mjoo8Etn2Ayfs4cnjlD3CsGkxXX4QyW3j2WJ0wdjYcYmy7Lx2RDsZMhgCWafKIw=="], "tldts-core": ["tldts-core@7.0.28", "", {}, "sha512-7W5Efjhsc3chVdFhqtaU0KtK32J37Zcr9RKtID54nG+tIpcY79CQK/veYPODxtD/LJ4Lue66jvrQzIX2Z2/pUQ=="], @@ -1427,6 +1487,14 @@ "undici-types": ["undici-types@7.25.0", "", {}, "sha512-AXNgS1Byr27fTI+2bsPEkV9CxkT8H6xNyRI68b3TatlZo3RkzlqQBLL+w7SmGPVpokjHbcuNVQUWE7FRTg+LRA=="], + "undici_v6": ["undici@6.28.0", "", {}, "sha512-LIY910g9TI13YS95lrMFrs8Rm/u/irgHeTWoKCoteeJ04CUJ92eEfj0rVn+7VKMPBpUPiUoBKfhNyLI23EE/KA=="], + + "undici_v7": ["undici@7.25.0", "", {}, "sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ=="], + + "undici_v8": ["undici@8.10.0", "", {}, "sha512-HvltHd7avK13QIw/oLe4qoOLyoVSoafqJ2jYOrtMRBkbYT31eiBQ8O0ehRKZiEZCMEyLFQNIADpgCWC5fALvYQ=="], + + "unicode-segmenter": ["unicode-segmenter@0.14.5", "", {}, "sha512-jHGmj2LUuqDcX3hqY12Ql+uhUTn8huuxNZGq7GvtF6bSybzH3aFgedYu/KTzQStEgt1Ra2F3HxadNXsNjb3m3g=="], + "unified": ["unified@11.0.5", "", { "dependencies": { "@types/unist": "^3.0.0", "bail": "^2.0.0", "devlop": "^1.0.0", "extend": "^3.0.0", "is-plain-obj": "^4.0.0", "trough": "^2.0.0", "vfile": "^6.0.0" } }, "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA=="], "unist-util-is": ["unist-util-is@6.0.1", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g=="], @@ -1503,6 +1571,32 @@ "zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="], + "@atproto-labs/did-resolver/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + + "@atproto-labs/handle-resolver/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + + "@atproto-labs/simple-store-memory/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], + + "@atproto/api/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + + "@atproto/common-web/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + + "@atproto/did/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + + "@atproto/jwk/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + + "@atproto/jwk-jose/jose": ["jose@5.10.0", "", {}, "sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg=="], + + "@atproto/jwk-webcrypto/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + + "@atproto/lexicon/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + + "@atproto/oauth-client/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + + "@atproto/oauth-types/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + + "@atproto/xrpc/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + "@auth/core/jose": ["jose@6.2.2", "", {}, "sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ=="], "@auth/core/preact": ["preact@10.24.3", "", {}, "sha512-Z2dPnBnMUfyQfSQ+GBdsGa16hz35YmLmtTLhM169uW944hYL6xzTYkJjC07j+Wosz733pMWx0fgON3JNw1jJQA=="], diff --git a/components/auth/atproto-sign-in-form.tsx b/components/auth/atproto-sign-in-form.tsx new file mode 100644 index 0000000..f911719 --- /dev/null +++ b/components/auth/atproto-sign-in-form.tsx @@ -0,0 +1,55 @@ +"use client"; + +import { useState } from "react"; +import { ArrowRight, LoaderCircle } from "lucide-react"; + +import { useI18n } from "@/components/i18n-provider"; +import { Button } from "@/components/ui/button"; + +export function AtprotoSignInForm() { + const [isPending, setIsPending] = useState(false); + const [identifier, setIdentifier] = useState(""); + const { t } = useI18n(); + + return ( +
{ + if (!identifier.trim()) { + event.preventDefault(); + return; + } + + setIsPending(true); + }} + > + + + +

+ {t("auth.atprotoHelp")} +

+
+ ); +} diff --git a/components/auth/auth-error-alert.test.tsx b/components/auth/auth-error-alert.test.tsx new file mode 100644 index 0000000..a45ab6c --- /dev/null +++ b/components/auth/auth-error-alert.test.tsx @@ -0,0 +1,25 @@ +import { describe, expect, test } from "bun:test"; +import { cleanup, render, waitFor } from "@testing-library/react"; + +import { AuthErrorAlert } from "./auth-error-alert"; +import { installTestDom } from "@/test/install-dom"; + +describe("AuthErrorAlert", () => { + test("shows the message once and removes authError from the URL", async () => { + const restoreDom = installTestDom(); + + try { + window.history.replaceState(null, "", "/?authError=start_failed&lang=en"); + + const view = render(); + + expect(view.getByRole("alert").textContent).toContain("Sign-in failed"); + await waitFor(() => { + expect(window.location.href).toBe("http://localhost/?lang=en"); + }); + } finally { + cleanup(); + restoreDom(); + } + }); +}); diff --git a/components/auth/auth-error-alert.tsx b/components/auth/auth-error-alert.tsx new file mode 100644 index 0000000..a56110b --- /dev/null +++ b/components/auth/auth-error-alert.tsx @@ -0,0 +1,31 @@ +"use client"; + +import { useEffect } from "react"; +import { AlertCircle } from "lucide-react"; + +export function AuthErrorAlert({ message }: { message: string }) { + useEffect(() => { + const url = new URL(window.location.href); + + if (!url.searchParams.has("authError")) { + return; + } + + url.searchParams.delete("authError"); + window.history.replaceState( + window.history.state, + "", + `${url.pathname}${url.search}${url.hash}`, + ); + }, []); + + return ( +
+
+ ); +} diff --git a/components/auth/google-sign-in-button.tsx b/components/auth/google-sign-in-button.tsx deleted file mode 100644 index 59f1f4d..0000000 --- a/components/auth/google-sign-in-button.tsx +++ /dev/null @@ -1,31 +0,0 @@ -"use client"; - -import { useTransition } from "react"; -import { LoaderCircle, LogIn } from "lucide-react"; -import { signIn } from "next-auth/react"; - -import { useI18n } from "@/components/i18n-provider"; -import { Button } from "@/components/ui/button"; - -export function GoogleSignInButton() { - const [isPending, startTransition] = useTransition(); - const { t } = useI18n(); - - return ( - - ); -} diff --git a/docs/deployment.md b/docs/deployment.md index ac72a0c..53ea322 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -1,6 +1,6 @@ # Deployment and environment setup -This project is a Next.js app backed by PostgreSQL and Auth.js with Google OAuth. +This project is a Next.js app backed by PostgreSQL and Auth.js sessions with ATProto OAuth for creator sign-in. ## Required environment variables @@ -9,9 +9,11 @@ Copy `.env.example` to `.env` and provide values for: ```env DATABASE_URL="postgresql://..." AUTH_SECRET="replace-with-a-long-random-string" -AUTH_GOOGLE_ID="your-google-client-id" -AUTH_GOOGLE_SECRET="your-google-client-secret" -NEXTAUTH_URL="http://localhost:3000" +NEXTAUTH_URL="http://127.0.0.1:3000" +ATPROTO_PUBLIC_URL="http://127.0.0.1:3000" +ATPROTO_CLIENT_ID="http://localhost?redirect_uri=http%3A%2F%2F127.0.0.1%3A3000%2Fapi%2Fauth%2Fatproto%2Fcallback&scope=atproto%20transition%3Ageneric" +ATPROTO_REDIRECT_URI="http://127.0.0.1:3000/api/auth/atproto/callback" +ATPROTO_CLIENT_NAME="Lively Forms" ``` Generate a strong auth secret, for example: @@ -20,6 +22,8 @@ Generate a strong auth secret, for example: openssl rand -base64 32 ``` +For local loopback development, ATProto uses a special `http://localhost?...` client ID that declares the loopback redirect URI and scope in the query string. In production, `ATPROTO_CLIENT_ID` must be an HTTPS URL where the app serves public OAuth client metadata. + ## Local development setup ### 1. Install dependencies @@ -34,7 +38,7 @@ bun install bun run db:up ``` -This uses `compose.yaml` and starts Postgres on `localhost:5432`. +This uses `compose.yaml` and starts Postgres on `127.0.0.1:5432`. ### 3. Run Prisma migrations @@ -48,45 +52,45 @@ bun run prisma:migrate bun run dev ``` -## Google OAuth setup +## ATProto OAuth setup -Create your own Google OAuth client in Google Cloud Console. +ATProto OAuth discovers the user's PDS from the handle or DID submitted on the sign-in form. The app also exposes public OAuth client metadata for HTTPS deployments where PDS authorization servers must fetch client metadata by URL. ### Local OAuth settings Use these values during local development: -**Authorized JavaScript origins** +**Client ID** -- `http://localhost:3000` +- `http://localhost?redirect_uri=http%3A%2F%2F127.0.0.1%3A3000%2Fapi%2Fauth%2Fatproto%2Fcallback&scope=atproto%20transition%3Ageneric` -**Authorized redirect URIs** +**Redirect URI** -- `http://localhost:3000/api/auth/callback/google` +- `http://127.0.0.1:3000/api/auth/atproto/callback` -If these values are missing or incorrect, Google sign-in will fail. +Open the app at `http://127.0.0.1:3000` so the callback host matches the configured loopback redirect URI. ### Production OAuth settings -For a deployed environment, replace `localhost` with your real domain. +For a deployed environment, replace the loopback values with your real HTTPS domain. Example: -**Authorized JavaScript origins** +**Client metadata URL / client ID** -- `https://your-domain.example` +- `https://your-domain.example/api/atproto/client-metadata` -**Authorized redirect URIs** +**Redirect URI** -- `https://your-domain.example/api/auth/callback/google` +- `https://your-domain.example/api/auth/atproto/callback` -Set `NEXTAUTH_URL` to the same public base URL. +Set `NEXTAUTH_URL` and `ATPROTO_PUBLIC_URL` to the same public base URL. If the client metadata URL or redirect URI are missing or incorrect, ATProto sign-in will fail before an app session is created. ## Production deployment checklist 1. Provision a PostgreSQL database. 2. Set the required environment variables. -3. Configure Google OAuth for the production domain. +3. Configure the public ATProto client metadata and callback URLs for the production domain. 4. Install dependencies: ```bash diff --git a/docs/development.md b/docs/development.md index 5e7219a..dd878ab 100644 --- a/docs/development.md +++ b/docs/development.md @@ -7,7 +7,7 @@ This document covers day-to-day local development for Lively Forms. - Bun - Podman + Podman Compose - PostgreSQL container support via `compose.yaml` -- A Google OAuth client for creator sign-in during local development +- ATProto OAuth/client metadata for creator sign-in during local development ## Local setup @@ -20,7 +20,7 @@ bun run prisma:migrate bun run dev ``` -Open `http://localhost:3000`. +Open `http://127.0.0.1:3000`. For environment variables and OAuth setup details, see [deployment.md](./deployment.md). diff --git a/lib/atproto-auth-callback.test.ts b/lib/atproto-auth-callback.test.ts new file mode 100644 index 0000000..4c616fa --- /dev/null +++ b/lib/atproto-auth-callback.test.ts @@ -0,0 +1,151 @@ +import { describe, expect, test } from "bun:test"; + +type CallbackResult = { + session: { did: `did:${string}:${string}` }; + state: string; +}; + +type SessionCreateArgs = { + data: { + sessionToken: string; + userId: string; + expires: Date; + }; +}; + +let callbackResult: CallbackResult; +let existingAccountUserId: string | null; +let createdAccounts: Array<{ + data: { userId: string; provider: string; providerAccountId: string }; +}>; +let createdSessions: SessionCreateArgs[]; +let deletedOAuthSessions: string[]; +let profileSyncs: Array<{ + userId: string; + profile: Record; +}>; + +const { mock } = (await import("bun:test")) as unknown as { + mock: { + module: (specifier: string, factory: () => Record) => void; + }; +}; + +mock.module("@atproto/oauth-client-node", () => ({ + NodeOAuthClient: class NodeOAuthClient { + async callback() { + return callbackResult; + } + }, +})); + +mock.module("@atproto/api", () => ({ + Agent: class Agent { + async getProfile() { + return { + data: { + handle: "mona.example.com", + displayName: "Mona Lisa", + avatar: "https://example.com/avatar.png", + }, + }; + } + }, +})); + +mock.module("@/lib/users", () => ({ + syncAtprotoProfileFields: async ( + userId: string, + profile: Record, + ) => { + profileSyncs.push({ userId, profile }); + }, +})); + +const transactionClient = { + account: { + findUnique: async () => + existingAccountUserId ? { userId: existingAccountUserId } : null, + create: async (args: { + data: { userId: string; provider: string; providerAccountId: string }; + }) => { + createdAccounts.push(args); + }, + }, + user: { + findUniqueOrThrow: async ({ where }: { where: { id: string } }) => ({ + id: where.id, + }), + create: async () => ({ id: "new-user" }), + }, +}; + +mock.module("@/lib/db", () => ({ + db: { + atprotoOAuthState: {}, + atprotoOAuthSession: { + delete: async ({ where }: { where: { sub: string } }) => { + deletedOAuthSessions.push(where.sub); + }, + }, + $transaction: async ( + run: (tx: typeof transactionClient) => Promise, + ) => run(transactionClient), + session: { + create: async (args: SessionCreateArgs) => { + createdSessions.push(args); + }, + }, + }, +})); + +const { completeAtprotoSignIn } = await import("@/lib/atproto-auth"); + +function resetTestState(callbackUrl = "/dashboard") { + callbackResult = { + session: { did: "did:plc:123" }, + state: Buffer.from(JSON.stringify({ callbackUrl })).toString("base64url"), + }; + existingAccountUserId = "existing-user"; + createdAccounts = []; + createdSessions = []; + deletedOAuthSessions = []; + profileSyncs = []; +} + +describe("completeAtprotoSignIn", () => { + test("reuses the DID account and creates an app session", async () => { + resetTestState("/forms/new"); + + const result = await completeAtprotoSignIn(new URLSearchParams("code=ok")); + + expect(result.callbackUrl).toBe("/forms/new"); + expect(result.sessionToken.length).toBe(64); + expect(createdSessions.length).toBe(1); + expect(createdSessions[0]?.data.userId).toBe("existing-user"); + expect(deletedOAuthSessions[0]).toBe("did:plc:123"); + expect(profileSyncs[0]?.userId).toBe("existing-user"); + expect(profileSyncs[0]?.profile.handle).toBe("mona.example.com"); + }); + + test("falls back to the dashboard for an unsafe callback", async () => { + resetTestState("/\\evil.example/path"); + + const result = await completeAtprotoSignIn(new URLSearchParams("code=ok")); + + expect(result.callbackUrl).toBe("/dashboard"); + }); + + test("creates an ATProto account for a new DID", async () => { + resetTestState(); + existingAccountUserId = null; + + await completeAtprotoSignIn(new URLSearchParams("code=ok")); + + expect(createdAccounts.length).toBe(1); + expect(createdAccounts[0]?.data.userId).toBe("new-user"); + expect(createdAccounts[0]?.data.provider).toBe("atproto"); + expect(createdAccounts[0]?.data.providerAccountId).toBe("did:plc:123"); + expect(createdSessions[0]?.data.userId).toBe("new-user"); + }); +}); diff --git a/lib/atproto-auth.test.ts b/lib/atproto-auth.test.ts new file mode 100644 index 0000000..e591ef3 --- /dev/null +++ b/lib/atproto-auth.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, test } from "bun:test"; + +import { createTimeoutFetch, getSafeAtprotoCallbackUrl } from "./atproto-auth"; + +async function captureRejection(promise: Promise) { + try { + await promise; + throw new Error("Expected promise to reject"); + } catch (error) { + return error; + } +} + +describe("createTimeoutFetch", () => { + test("aborts a stalled request after the configured timeout", async () => { + const stalledFetch = ((_input: RequestInfo | URL, init?: RequestInit) => + new Promise((_resolve, reject) => { + init?.signal?.addEventListener( + "abort", + () => reject(init.signal?.reason), + { once: true }, + ); + })) as typeof fetch; + const timedFetch = createTimeoutFetch(stalledFetch, 10); + const error = await captureRejection(timedFetch("https://example.com")); + + expect((error as { name?: string }).name).toBe("TimeoutError"); + }); + + test("preserves an earlier caller abort", async () => { + const stalledFetch = ((_input: RequestInfo | URL, init?: RequestInit) => + new Promise((_resolve, reject) => { + init?.signal?.addEventListener( + "abort", + () => reject(init.signal?.reason), + { once: true }, + ); + })) as typeof fetch; + const timedFetch = createTimeoutFetch(stalledFetch, 1_000); + const controller = new AbortController(); + const request = timedFetch("https://example.com", { + signal: controller.signal, + }); + + controller.abort(new Error("caller aborted")); + const error = await captureRejection(request); + + expect(error instanceof Error).toBe(true); + expect((error as Error).message).toBe("caller aborted"); + }); +}); + +describe("getSafeAtprotoCallbackUrl", () => { + test("keeps same-origin application paths", () => { + expect(getSafeAtprotoCallbackUrl("/forms/new?from=login")).toBe( + "/forms/new?from=login", + ); + }); + + test("rejects absolute and backslash-based external redirects", () => { + expect(getSafeAtprotoCallbackUrl("https://evil.example/path")).toBe( + "/dashboard", + ); + expect(getSafeAtprotoCallbackUrl("//evil.example/path")).toBe("/dashboard"); + expect(getSafeAtprotoCallbackUrl("/\\evil.example/path")).toBe( + "/dashboard", + ); + }); +}); diff --git a/lib/atproto-auth.ts b/lib/atproto-auth.ts new file mode 100644 index 0000000..ab33940 --- /dev/null +++ b/lib/atproto-auth.ts @@ -0,0 +1,312 @@ +import { Agent } from "@atproto/api"; +import { + NodeOAuthClient, + type OAuthClientOptions, + type NodeSavedSession, + type NodeSavedState, + type NodeSavedSessionStore, + type NodeSavedStateStore, +} from "@atproto/oauth-client-node"; +import type { Prisma } from "@prisma/client"; +import { randomBytes } from "node:crypto"; + +import { db } from "@/lib/db"; +import { syncAtprotoProfileFields } from "@/lib/users"; + +const ATPROTO_PROVIDER = "atproto"; +const DEFAULT_SESSION_MAX_AGE_SECONDS = 30 * 24 * 60 * 60; +const OAUTH_FETCH_TIMEOUT_MS = 10_000; +const OAUTH_AUTHORIZATION_TIMEOUT_MS = 15_000; + +type AtprotoOAuthStateValue = NodeSavedState & Prisma.JsonObject; +type AtprotoOAuthSessionValue = NodeSavedSession & Prisma.JsonObject; + +type AtprotoStatePayload = { + callbackUrl?: string; +}; + +export function getAtprotoPublicBaseUrl() { + const baseUrl = + process.env.ATPROTO_PUBLIC_URL ?? + process.env.NEXTAUTH_URL ?? + "http://127.0.0.1:3000"; + + return baseUrl.replace(/\/$/, ""); +} + +export function getAtprotoAuthConfig() { + const baseUrl = getAtprotoPublicBaseUrl(); + const redirectUri = + process.env.ATPROTO_REDIRECT_URI ?? `${baseUrl}/api/auth/atproto/callback`; + const scope = process.env.ATPROTO_SCOPE ?? "atproto transition:generic"; + const defaultClientId = baseUrl.startsWith("http://") + ? `http://localhost?redirect_uri=${encodeURIComponent(redirectUri)}&scope=${encodeURIComponent(scope)}` + : `${baseUrl}/api/atproto/client-metadata`; + const clientId = process.env.ATPROTO_CLIENT_ID ?? defaultClientId; + + return { + baseUrl, + clientId, + redirectUri, + clientName: process.env.ATPROTO_CLIENT_NAME ?? "Lively Forms", + scope, + }; +} + +export function getAtprotoClientMetadata() { + const config = getAtprotoAuthConfig(); + + return { + client_id: config.clientId, + client_name: config.clientName, + client_uri: config.baseUrl, + redirect_uris: [config.redirectUri], + scope: config.scope, + grant_types: ["authorization_code", "refresh_token"], + response_types: ["code"], + application_type: "web", + token_endpoint_auth_method: "none", + dpop_bound_access_tokens: true, + } as unknown as OAuthClientOptions["clientMetadata"]; +} + +const stateStore: NodeSavedStateStore = { + async set(key, value) { + const now = new Date(); + + await db.$transaction([ + db.atprotoOAuthState.deleteMany({ + where: { expiresAt: { lt: now } }, + }), + db.atprotoOAuthState.upsert({ + where: { key }, + create: { + key, + value: value as AtprotoOAuthStateValue, + expiresAt: new Date(now.getTime() + 60 * 60 * 1000), + }, + update: { + value: value as AtprotoOAuthStateValue, + expiresAt: new Date(now.getTime() + 60 * 60 * 1000), + }, + }), + ]); + }, + async get(key) { + const state = await db.atprotoOAuthState.findUnique({ where: { key } }); + + if (!state || state.expiresAt < new Date()) { + if (state) { + await db.atprotoOAuthState.delete({ where: { key } }).catch(() => {}); + } + return undefined; + } + + return state.value as NodeSavedState; + }, + async del(key) { + await db.atprotoOAuthState.delete({ where: { key } }).catch(() => {}); + }, +}; + +const sessionStore: NodeSavedSessionStore = { + async set(sub, value) { + await db.atprotoOAuthSession.upsert({ + where: { sub }, + create: { + sub, + value: value as AtprotoOAuthSessionValue, + }, + update: { + value: value as AtprotoOAuthSessionValue, + }, + }); + }, + async get(sub) { + const session = await db.atprotoOAuthSession.findUnique({ where: { sub } }); + return session?.value as NodeSavedSession | undefined; + }, + async del(sub) { + await db.atprotoOAuthSession.delete({ where: { sub } }).catch(() => {}); + }, +}; + +export function createTimeoutFetch( + fetchImplementation: typeof fetch, + timeoutMs = OAUTH_FETCH_TIMEOUT_MS, +): typeof fetch { + return (input, init) => { + const timeoutSignal = AbortSignal.timeout(timeoutMs); + const signal = init?.signal + ? AbortSignal.any([init.signal, timeoutSignal]) + : timeoutSignal; + + return fetchImplementation(input, { ...init, signal }); + }; +} + +const oauthFetch = createTimeoutFetch(globalThis.fetch); + +let atprotoOAuthClient: NodeOAuthClient | null = null; + +export function getAtprotoOAuthClient() { + atprotoOAuthClient ??= new NodeOAuthClient({ + clientMetadata: getAtprotoClientMetadata(), + stateStore, + sessionStore, + requestLock: async (_key, run) => run(), + fetch: oauthFetch, + }); + + return atprotoOAuthClient; +} + +export function normalizeAtprotoIdentifier(identifier: string) { + return identifier.trim().replace(/^@/, ""); +} + +export async function getAtprotoAuthorizationUrl( + identifier: string, + options: { callbackUrl?: string } = {}, +) { + const cleanIdentifier = normalizeAtprotoIdentifier(identifier); + + if (!cleanIdentifier) { + throw new Error("ATProto identity is required."); + } + + const state = Buffer.from( + JSON.stringify({ + callbackUrl: options.callbackUrl, + } satisfies AtprotoStatePayload), + ).toString("base64url"); + + return getAtprotoOAuthClient().authorize(cleanIdentifier, { + state, + signal: AbortSignal.timeout(OAUTH_AUTHORIZATION_TIMEOUT_MS), + }); +} + +function parseAtprotoState(state: string | null): AtprotoStatePayload { + if (!state) { + return {}; + } + + try { + const parsed = JSON.parse(Buffer.from(state, "base64url").toString("utf8")); + return typeof parsed === "object" && parsed ? parsed : {}; + } catch { + return {}; + } +} + +function isSafeCallbackUrl(value: string | null | undefined): value is string { + if (typeof value !== "string" || !value.startsWith("/")) { + return false; + } + + try { + const baseUrl = new URL(getAtprotoPublicBaseUrl()); + return new URL(value, baseUrl).origin === baseUrl.origin; + } catch { + return false; + } +} + +export function getSafeAtprotoCallbackUrl( + value: string | null | undefined, +): string { + return isSafeCallbackUrl(value) ? value : "/dashboard"; +} + +export function getAtprotoRedirectUrl(path: string) { + return new URL(getSafeAtprotoCallbackUrl(path), getAtprotoPublicBaseUrl()); +} + +function createSessionToken() { + return randomBytes(32).toString("hex"); +} + +export function getNextAuthSessionCookieName() { + const url = process.env.NEXTAUTH_URL ?? process.env.ATPROTO_PUBLIC_URL ?? ""; + const secure = + url.startsWith("https://") || process.env.NODE_ENV === "production"; + return secure + ? "__Secure-next-auth.session-token" + : "next-auth.session-token"; +} + +export async function completeAtprotoSignIn(params: URLSearchParams) { + const { session, state } = await getAtprotoOAuthClient().callback(params, { + redirect_uri: getAtprotoAuthConfig().redirectUri as never, + }); + const did = session.did; + const callbackUrl = getSafeAtprotoCallbackUrl( + parseAtprotoState(state).callbackUrl, + ); + + const agent = new Agent(session); + const profile = await agent.getProfile({ actor: did }).catch(() => null); + const profileData = profile?.data; + const handle = profileData?.handle ?? did; + const displayName = profileData?.displayName ?? null; + const avatar = profileData?.avatar ?? null; + + const user = await db.$transaction(async (tx) => { + const existingAccount = await tx.account.findUnique({ + where: { + provider_providerAccountId: { + provider: ATPROTO_PROVIDER, + providerAccountId: did, + }, + }, + select: { userId: true }, + }); + + if (existingAccount) { + return tx.user.findUniqueOrThrow({ + where: { id: existingAccount.userId }, + }); + } + + const nextUser = await tx.user.create({ data: {} }); + + await tx.account.create({ + data: { + userId: nextUser.id, + type: "oauth", + provider: ATPROTO_PROVIDER, + providerAccountId: did, + token_type: "DPoP", + scope: getAtprotoAuthConfig().scope, + }, + }); + + return nextUser; + }); + + await syncAtprotoProfileFields(user.id, { + did, + handle, + displayName, + avatar, + }); + await sessionStore.del(did); + + const expires = new Date(Date.now() + DEFAULT_SESSION_MAX_AGE_SECONDS * 1000); + const sessionToken = createSessionToken(); + + await db.session.create({ + data: { + sessionToken, + userId: user.id, + expires, + }, + }); + + return { + callbackUrl, + sessionToken, + expires, + }; +} diff --git a/lib/auth.ts b/lib/auth.ts index 721202b..592d4f4 100644 --- a/lib/auth.ts +++ b/lib/auth.ts @@ -5,13 +5,11 @@ import { type NextAuthOptions, type Session, } from "next-auth"; -import GoogleProvider from "next-auth/providers/google"; import { redirect } from "next/navigation"; import { db } from "@/lib/db"; import { normalizeLocale } from "@/lib/i18n"; -import { syncGoogleProfileFields } from "@/lib/users"; export const authOptions: NextAuthOptions = { adapter: PrismaAdapter(db) as Adapter, @@ -22,17 +20,7 @@ export const authOptions: NextAuthOptions = { pages: { signIn: "/", }, - providers: [ - GoogleProvider({ - clientId: process.env.AUTH_GOOGLE_ID ?? "", - clientSecret: process.env.AUTH_GOOGLE_SECRET ?? "", - authorization: { - params: { - prompt: "select_account", - }, - }, - }), - ], + providers: [], callbacks: { async session({ session, user }) { if (session.user) { @@ -48,37 +36,6 @@ export const authOptions: NextAuthOptions = { return session; }, }, - events: { - async signIn({ user, account, profile }) { - if (account?.provider !== "google" || !user.id) { - return; - } - - await syncGoogleProfileFields(user.id, { - name: typeof profile?.name === "string" ? profile.name : null, - given_name: - profile && - typeof (profile as { given_name?: unknown }).given_name === "string" - ? ((profile as { given_name?: string }).given_name ?? null) - : null, - family_name: - profile && - typeof (profile as { family_name?: unknown }).family_name === "string" - ? ((profile as { family_name?: string }).family_name ?? null) - : null, - picture: - profile && - typeof (profile as { picture?: unknown }).picture === "string" - ? ((profile as { picture?: string }).picture ?? null) - : null, - locale: - profile && - typeof (profile as { locale?: unknown }).locale === "string" - ? normalizeLocale((profile as { locale?: string }).locale ?? null) - : null, - }); - }, - }, }; export function getServerAuthSession() { diff --git a/lib/project.ts b/lib/project.ts index 734b338..ed7709c 100644 --- a/lib/project.ts +++ b/lib/project.ts @@ -1,7 +1,7 @@ export const PROJECT_LICENSE = "AGPL-3.0-only"; export const PROJECT_URLS = { - source: "https://codeberg.org/chernigin/lively-forms", + source: "https://tangled.org/chernigin.com/lively-forms", license: "https://codeberg.org/chernigin/lively-forms/src/branch/main/LICENSE", issues: "https://codeberg.org/chernigin/lively-forms/issues", diff --git a/lib/users.test.ts b/lib/users.test.ts new file mode 100644 index 0000000..4d5fdec --- /dev/null +++ b/lib/users.test.ts @@ -0,0 +1,149 @@ +import { describe, expect, test } from "bun:test"; + +import type { AppLocale } from "@/lib/i18n"; + +type UserRecord = { + id: string; + name: string | null; + firstName: string | null; + secondName: string | null; + locale: AppLocale | null; + email: string | null; + image: string | null; +}; + +type UserUpdateArgs = { + where: { id: string }; + data: Record; +}; + +let currentUser: UserRecord | null = null; +let updateCalls: UserUpdateArgs[] = []; + +const { mock } = (await import("bun:test")) as unknown as { + mock: { + module: (specifier: string, factory: () => Record) => void; + }; +}; + +mock.module("@/lib/db", () => ({ + db: { + user: { + findUnique: async () => currentUser, + update: async (args: UserUpdateArgs) => { + updateCalls.push(args); + return currentUser ? { ...currentUser, ...args.data } : args.data; + }, + }, + }, +})); + +const { syncAtprotoProfileFields } = await import("@/lib/users"); + +function user(overrides: Partial = {}): UserRecord { + return { + id: "user-1", + name: null, + firstName: null, + secondName: null, + locale: null, + email: null, + image: null, + ...overrides, + }; +} + +function resetTestState(overrides: Partial = {}) { + currentUser = user(overrides); + updateCalls = []; +} + +describe("syncAtprotoProfileFields", () => { + test("initializes missing profile fields from ATProto profile data", async () => { + resetTestState(); + + await syncAtprotoProfileFields("user-1", { + did: "did:plc:123", + handle: "mona.example.com", + displayName: "Mona Lisa", + avatar: "https://cdn.bsky.app/img/avatar/plain/did:plc:123/avatar@jpeg", + }); + + expect(JSON.stringify(updateCalls)).toBe( + JSON.stringify([ + { + where: { id: "user-1" }, + data: { + firstName: "Mona", + secondName: "Lisa", + name: "Mona Lisa", + image: + "https://cdn.bsky.app/img/avatar/plain/did:plc:123/avatar@jpeg", + }, + }, + ]), + ); + }); + + test("falls back to handle when display name is missing", async () => { + resetTestState(); + + await syncAtprotoProfileFields("user-1", { + did: "did:plc:123", + handle: "octocat.bsky.social", + avatar: "not-a-url", + }); + + expect(JSON.stringify(updateCalls)).toBe( + JSON.stringify([ + { + where: { id: "user-1" }, + data: { + firstName: "octocat.bsky.social", + name: "octocat.bsky.social", + }, + }, + ]), + ); + }); + + test("falls back to DID when profile names are missing", async () => { + resetTestState(); + + await syncAtprotoProfileFields("user-1", { + did: "did:plc:123", + }); + + expect(JSON.stringify(updateCalls)).toBe( + JSON.stringify([ + { + where: { id: "user-1" }, + data: { + firstName: "did:plc:123", + name: "did:plc:123", + }, + }, + ]), + ); + }); + + test("preserves creator-managed profile fields, email, image, and locale", async () => { + resetTestState({ + name: "Custom Name", + firstName: "Custom", + secondName: "Name", + locale: "ru", + email: "custom@example.com", + image: "https://example.com/custom.png", + }); + + await syncAtprotoProfileFields("user-1", { + did: "did:plc:123", + handle: "mona.example.com", + displayName: "Mona Lisa", + avatar: "https://cdn.bsky.app/img/avatar/plain/did:plc:123/avatar@jpeg", + }); + + expect(JSON.stringify(updateCalls)).toBe(JSON.stringify([])); + }); +}); diff --git a/lib/users.ts b/lib/users.ts index 44f9dce..cc31fff 100644 --- a/lib/users.ts +++ b/lib/users.ts @@ -1,5 +1,5 @@ import { db } from "@/lib/db"; -import { AppLocale, DEFAULT_LOCALE, normalizeLocale } from "@/lib/i18n"; +import { normalizeLocale } from "@/lib/i18n"; import { AppError } from "@/lib/errors"; import type { ProfileSettingsUserSummary } from "@/lib/form-types"; import { getComposedName, splitNameParts } from "@/lib/user-identity"; @@ -81,12 +81,11 @@ export async function updateProfileSettings( }); } -type GoogleProfilePayload = { - name?: string | null; - given_name?: string | null; - family_name?: string | null; - picture?: string | null; - locale?: string | null; +type AtprotoProfilePayload = { + did?: string | null; + handle?: string | null; + displayName?: string | null; + avatar?: string | null; }; function clean(value: string | null | undefined, maxLength = 120) { @@ -113,9 +112,9 @@ function normalizeImageUrl(value: string | null | undefined) { return null; } -export async function syncGoogleProfileFields( +export async function syncAtprotoProfileFields( userId: string, - profile: GoogleProfilePayload | null | undefined, + profile: AtprotoProfilePayload | null | undefined, ) { if (!profile) { return; @@ -129,6 +128,7 @@ export async function syncGoogleProfileFields( firstName: true, secondName: true, locale: true, + email: true, image: true, }, }); @@ -137,22 +137,19 @@ export async function syncGoogleProfileFields( return; } - const parsedName = splitNameParts(clean(profile.name)); - const nextFirstName = clean(profile.given_name, 60) ?? parsedName.firstName; - const nextSecondName = - clean(profile.family_name, 60) ?? parsedName.secondName; + const displayName = + clean(profile.displayName) ?? clean(profile.handle) ?? clean(profile.did); + const parsedName = splitNameParts(displayName); + const nextFirstName = parsedName.firstName; + const nextSecondName = parsedName.secondName; const nextName = - clean(profile.name) ?? getComposedName(nextFirstName, nextSecondName); - const nextImage = normalizeImageUrl(profile.picture); - const hasProvidedLocale = - typeof profile.locale === "string" && profile.locale.trim().length > 0; - const nextLocale = hasProvidedLocale ? normalizeLocale(profile.locale) : null; + displayName ?? getComposedName(nextFirstName, nextSecondName); + const nextImage = normalizeImageUrl(profile.avatar); const data: { firstName?: string; secondName?: string; name?: string; - locale?: AppLocale; image?: string; } = {}; @@ -168,10 +165,6 @@ export async function syncGoogleProfileFields( data.name = nextName; } - if (!currentUser.locale && hasProvidedLocale) { - data.locale = nextLocale ?? DEFAULT_LOCALE; - } - if (!currentUser.image?.trim() && nextImage) { data.image = nextImage; } diff --git a/locales/en.yml b/locales/en.yml index 275489e..078afd6 100644 --- a/locales/en.yml +++ b/locales/en.yml @@ -4,6 +4,8 @@ app: goToDashboardAria: Go to dashboard home: description: "Like a seed in good soil, every thoughtful question has the power to make something grow. Build forms that feel alive—gentle invitations to reflect, respond, and uncover what matters most." + welcomeBack: Welcome back + signedInDescription: Your workspace is ready when you are. openDashboard: Open dashboard footer: poweredBy: Built with @@ -18,7 +20,13 @@ meta: joinOrganization: Join organization notFound: Not found auth: - continueWithGoogle: Continue with Google + title: Sign in to Lively Forms + description: Use your Bluesky account or another AT Protocol identity. + continueWithAtproto: Continue with ATProto + atprotoIdentifierLabel: Bluesky handle + atprotoIdentifierPlaceholder: alice.bsky.social + atprotoHelp: Custom domains and DIDs work too. + atprotoError: We couldn't sign you in. Check your handle and try again. workspace: selectAria: Select workspace personal: Personal workspace @@ -59,7 +67,7 @@ settings: firstName: First name secondName: Second name imageUrl: Profile image URL - imageUrlHelp: Google can populate this initially. You can replace it with any public image URL. + imageUrlHelp: ATProto can populate this initially from your profile avatar. You can replace it with any public image URL. locale: Language localeHelp: Choose the language used across the app for your account. save: Save diff --git a/locales/ru.yml b/locales/ru.yml index 76ae916..3578dca 100644 --- a/locales/ru.yml +++ b/locales/ru.yml @@ -4,6 +4,8 @@ app: goToDashboardAria: Перейти в дашборд home: description: "Как семя в хорошей почве, каждый продуманный вопрос может помочь чему-то вырасти. Создавайте формы, которые ощущаются живыми — мягкими приглашениями подумать, ответить и понять, что действительно важно." + welcomeBack: С возвращением + signedInDescription: Ваше рабочее пространство готово. openDashboard: Открыть дашборд footer: poweredBy: Сделано с помощью @@ -18,7 +20,13 @@ meta: joinOrganization: Вступить в организацию notFound: Не найдено auth: - continueWithGoogle: Продолжить с Google + title: Войти в Живые формы + description: Используйте аккаунт Bluesky или другую идентичность AT Protocol. + continueWithAtproto: Продолжить через ATProto + atprotoIdentifierLabel: Хэндл Bluesky + atprotoIdentifierPlaceholder: alice.bsky.social + atprotoHelp: Пользовательские домены и DID тоже поддерживаются. + atprotoError: Не удалось войти. Проверьте хэндл и попробуйте снова. workspace: selectAria: Выбрать рабочее пространство personal: Личное пространство @@ -59,7 +67,7 @@ settings: firstName: Имя secondName: Фамилия imageUrl: URL изображения профиля - imageUrlHelp: Google может заполнить это поле изначально. Позже вы можете заменить его любым публичным URL изображения. + imageUrlHelp: ATProto может сначала заполнить это поле аватаром из вашего профиля. Позже вы можете заменить его любым публичным URL изображения. locale: Язык localeHelp: Выберите язык интерфейса для вашего аккаунта. save: Сохранить diff --git a/openspec/changes/archive/2026-09-04-add-atproto-auth/.openspec.yaml b/openspec/changes/archive/2026-09-04-add-atproto-auth/.openspec.yaml new file mode 100644 index 0000000..4102db8 --- /dev/null +++ b/openspec/changes/archive/2026-09-04-add-atproto-auth/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-08-24 diff --git a/openspec/changes/archive/2026-09-04-add-atproto-auth/design.md b/openspec/changes/archive/2026-09-04-add-atproto-auth/design.md new file mode 100644 index 0000000..ee3e2be --- /dev/null +++ b/openspec/changes/archive/2026-09-04-add-atproto-auth/design.md @@ -0,0 +1,73 @@ +## Context + +Creator authentication currently uses NextAuth/Auth.js with the Prisma adapter and the Google OAuth provider. Authenticated creator access, ownership checks, account menu behavior, and locale lookup all depend on the existing `User`, `Account`, and `Session` records rather than on Google-specific APIs after sign-in. + +ATProto OAuth is not a simple fixed-issuer OAuth provider: users identify with a handle or DID, clients resolve the identity/PDS, and profile data is fetched from AT Protocol records after authorization. The implementation should keep the app's existing database-backed creator session model while replacing the external sign-in flow and profile initialization source. + +## Goals / Non-Goals + +**Goals:** +- Let creators sign in with an ATProto identity associated with their PDS. +- Store/link the authenticated ATProto DID as the provider account identifier. +- Initialize missing creator display name/name parts and avatar from AT Protocol profile data. +- Preserve manually edited profile fields and locale on later sign-ins. +- Update product copy and deployment docs so operators configure ATProto OAuth instead of Google OAuth. + +**Non-Goals:** +- Supporting Google and ATProto side by side in the first version. +- Migrating existing Google `Account` rows to ATProto accounts automatically. +- Adding posting, repo writes, Bluesky social features, or long-lived ATProto API access beyond authentication/profile lookup. +- Persisting the user's PDS endpoint as a first-class profile setting unless needed by the OAuth client library. +- Inferring app locale from ATProto profile data. + +## Decisions + +### Use an ATProto OAuth client flow rather than a static NextAuth OAuth provider + +Implement ATProto sign-in with `@atproto/oauth-client-node` so the app gets handle/DID resolution, PDS authorization-server discovery, PKCE/state handling, and callback validation. The landing page will ask for or accept an ATProto handle/PDS identity, start the OAuth flow, and route the callback through app-owned auth endpoints. + +Alternative considered: model ATProto as a static NextAuth OAuth provider. This is simpler in the current auth configuration shape, but it does not fit ATProto's dynamic identity/PDS discovery model reliably. + +### Preserve the existing app session and authorization model + +After a successful ATProto OAuth callback, upsert the app user/account using the ATProto DID as `Account.providerAccountId` with `provider = "atproto"`, then create the same kind of app session currently used for creator routes. Existing creator authorization code should continue to call the same session helpers and ownership checks. + +Alternative considered: replace NextAuth/Auth.js entirely. That would make the ATProto flow more direct but would force a larger rewrite of creator route protection, account menus, test setup, and database session handling. + +### Fetch profile data from AT Protocol after identity is established + +Fetch the actor profile for the authenticated DID/handle and map `displayName` into `name`, `firstName`, and `secondName` using the existing name-splitting behavior. Map `avatar` into `image` only if it is a usable URL. If no display name is available, fall back to the handle or DID for safe identity display. + +Alternative considered: only trust OAuth claims. ATProto OAuth identity claims may not include the same user-facing profile fields the app needs for creator display, so fetching profile data gives a better first-run experience. + +### Keep profile synchronization additive only + +ATProto sign-in should initialize missing fields but must not overwrite creator-edited `name`, `firstName`, `secondName`, `image`, `email`, or `locale` values. Locale should continue to follow the app's default/user-setting behavior because ATProto profile data does not define the same locale source as the current Google flow. + +Alternative considered: overwrite the profile on every sign-in to stay synced with ATProto. That would surprise creators who use the app's profile settings as the source of truth. + +### Use ATProto-specific configuration and copy + +Replace Google-specific environment guidance and UI copy with ATProto/PDS sign-in language. Configuration should document the public client metadata/client ID, redirect URL, and any server URL/base URL values required by the ATProto OAuth client implementation. Local loopback development uses the ATProto `http://localhost?...` client ID convention with a `127.0.0.1` redirect URI; production deployments use an HTTPS client metadata URL. + +Alternative considered: keep existing Google variable names as generic OAuth placeholders. This would be misleading for deployments and makes troubleshooting harder. + +## Risks / Trade-offs + +- ATProto OAuth client support may require an additional dependency and route-level state storage → choose a maintained ATProto OAuth client package and keep auth state in durable storage or secure cookies as required by the library. +- Existing Google-only users cannot automatically become the same user through ATProto → document that deployments need account linking/backfill if identity continuity matters. +- PDS or handle resolution failures can block sign-in → show clear validation and retry errors before redirecting or after callback failure. +- Avatar URLs may be CDN/blob URLs that change or expire → store only usable profile image URLs and allow creators to override them in settings. +- A custom auth flow can drift from NextAuth internals → isolate bridge code that creates/updates `User`, `Account`, and `Session` records and cover it with focused tests. + +## Migration Plan + +1. Add ATProto OAuth/client configuration to development and production environments. +2. Deploy the ATProto sign-in flow and updated copy/docs. +3. Verify sign-in with a real ATProto handle creates or reuses an `Account` row with `provider = "atproto"` and routes the creator to `/dashboard`. +4. Verify missing profile fields are initialized from AT Protocol profile data and creator-edited fields remain unchanged on subsequent sign-ins. +5. Roll back by redeploying the prior Google-provider version and restoring Google OAuth configuration if ATProto sign-in fails. + +## Open Questions + +- Manual verification still needs to complete a real ATProto authorization flow and confirm the resulting `atproto` account row. diff --git a/openspec/changes/archive/2026-09-04-add-atproto-auth/proposal.md b/openspec/changes/archive/2026-09-04-add-atproto-auth/proposal.md new file mode 100644 index 0000000..34c6ab2 --- /dev/null +++ b/openspec/changes/archive/2026-09-04-add-atproto-auth/proposal.md @@ -0,0 +1,26 @@ +## Why + +The app currently depends on Google OAuth for creator sign-in, but the desired login path is an ATProto identity hosted on the user's PDS. Switching to ATProto OAuth aligns creator identity with Bluesky/AT Protocol accounts while preserving the existing authenticated creator experience. + +## What Changes + +- Replace Google OAuth as the creator sign-in provider with ATProto OAuth using a user-supplied handle/PDS identity flow. +- Initialize missing creator profile fields from AT Protocol profile data, including display name and avatar when available. +- Update sign-in UI, localized copy, environment/config documentation, and troubleshooting text to reference ATProto/PDS sign-in instead of Google OAuth. +- Preserve existing creator-managed profile fields and locale on repeated sign-ins. +- **BREAKING**: Deployments currently configured only for Google OAuth credentials must configure ATProto OAuth/client metadata before creator sign-in works after this change. + +## Capabilities + +### New Capabilities + +### Modified Capabilities +- `creator-auth`: creator authentication switches from Google OAuth profile initialization to ATProto OAuth/PDS identity and profile initialization. + +## Impact + +- Auth configuration in `lib/auth.ts` and the NextAuth provider setup. +- Sign-in UI components and localized auth/profile copy. +- User profile synchronization in `lib/users.ts`. +- Deployment/development documentation and environment variable guidance. +- Unit coverage for ATProto profile synchronization and provider callback behavior where feasible. diff --git a/openspec/changes/archive/2026-09-04-add-atproto-auth/specs/creator-auth/spec.md b/openspec/changes/archive/2026-09-04-add-atproto-auth/specs/creator-auth/spec.md new file mode 100644 index 0000000..d4b3e3b --- /dev/null +++ b/openspec/changes/archive/2026-09-04-add-atproto-auth/specs/creator-auth/spec.md @@ -0,0 +1,62 @@ +## ADDED Requirements + +### Requirement: Creator sign-in uses ATProto OAuth +The system SHALL offer ATProto OAuth as the creator sign-in provider and SHALL route successful ATProto sign-ins into the existing authenticated creator experience. + +#### Scenario: Unauthenticated creator starts ATProto sign-in +- **WHEN** an unauthenticated user starts creator sign-in with an ATProto handle, DID, or PDS-backed identity +- **THEN** the system starts the ATProto OAuth flow for that identity + +#### Scenario: ATProto sign-in succeeds +- **WHEN** ATProto OAuth completes successfully for a creator +- **THEN** the system authenticates the creator and sends them to the creator dashboard + +#### Scenario: Sign-in action is localized +- **WHEN** the sign-in page is shown in a supported locale +- **THEN** the system labels the action as continuing with ATProto or a PDS-backed account in the active locale + +#### Scenario: ATProto sign-in cannot resolve identity +- **WHEN** a user submits an ATProto identity that cannot be resolved or authorized +- **THEN** the system shows a recoverable sign-in error without creating an authenticated creator session + +### Requirement: Authenticated identity initializes profile data from ATProto when available +The system SHALL use AT Protocol profile data to initialize missing creator identity fields, including profile image and available display name data, without overwriting profile values the user has already edited. + +#### Scenario: New creator signs in with ATProto profile data +- **WHEN** a user signs in with ATProto and their stored profile fields are missing +- **THEN** the system stores available AT Protocol display name and avatar data on the user record for creator-facing identity use + +#### Scenario: ATProto profile includes only partial identity data +- **WHEN** a user signs in with ATProto and AT Protocol provides only a handle, DID, display name, or avatar subset +- **THEN** the system initializes only the creator identity fields that can be derived safely from the available AT Protocol data + +#### Scenario: ATProto profile has no locale source +- **WHEN** a user signs in with ATProto and has no stored locale preference +- **THEN** the system uses the application default locale behavior instead of expecting an AT Protocol locale value + +#### Scenario: Returning creator signs in after editing profile settings +- **WHEN** a returning creator signs in with ATProto after manually updating their profile fields or locale preference +- **THEN** the system preserves the creator-managed profile values instead of replacing them with provider values + +## REMOVED Requirements + +### Requirement: Authenticated identity initializes profile data from Google when available +**Reason**: Creator authentication is switching from Google OAuth to ATProto OAuth, so Google-specific profile initialization is no longer part of the authentication contract. + +**Migration**: Use ATProto OAuth profile initialization instead. Deployments must configure ATProto OAuth/client metadata before enabling this change. + +#### Scenario: New creator signs in with Google +- **WHEN** a user signs in with Google and their stored profile fields are missing +- **THEN** the system stores available Google name and profile image data on the user record for creator-facing identity use + +#### Scenario: New creator signs in with a supported Google locale +- **WHEN** a user signs in with Google, has no stored locale preference, and Google provides a supported locale +- **THEN** the system stores that supported locale on the user record for future app localization + +#### Scenario: New creator signs in with an unsupported Google locale +- **WHEN** a user signs in with Google, has no stored locale preference, and Google provides an unsupported locale +- **THEN** the system stores or resolves English as the default locale for that user + +#### Scenario: Returning creator signs in after editing profile settings +- **WHEN** a returning creator signs in with Google after manually updating their profile fields or locale preference +- **THEN** the system preserves the creator-managed profile values instead of replacing them with provider values diff --git a/openspec/changes/archive/2026-09-04-add-atproto-auth/tasks.md b/openspec/changes/archive/2026-09-04-add-atproto-auth/tasks.md new file mode 100644 index 0000000..90a634a --- /dev/null +++ b/openspec/changes/archive/2026-09-04-add-atproto-auth/tasks.md @@ -0,0 +1,26 @@ +## 1. ATProto auth foundation + +- [x] 1.1 Select and add the ATProto OAuth/client dependency and any required storage helpers. +- [x] 1.2 Add ATProto auth configuration parsing for client metadata, redirect URL, public base URL, and required secrets. +- [x] 1.3 Implement auth-start and callback routes for ATProto OAuth with handle/DID input, state/PKCE validation, and recoverable error handling. +- [x] 1.4 Bridge successful ATProto callbacks into existing app `User`, `Account`, and `Session` records using `provider = "atproto"` and the authenticated DID as the provider account ID. + +## 2. Profile synchronization + +- [x] 2.1 Replace Google-specific profile synchronization types/functions with ATProto/provider-neutral naming. +- [x] 2.2 Fetch AT Protocol actor profile data after authentication and map display name/handle/DID/avatar into existing creator profile fields. +- [x] 2.3 Preserve creator-managed profile fields, email, image overrides, and locale on repeated ATProto sign-ins. +- [x] 2.4 Add unit coverage for ATProto profile initialization, fallback identity values, invalid avatar handling, and preservation behavior. + +## 3. User-facing sign-in experience + +- [x] 3.1 Replace the Google sign-in button with an ATProto/PDS sign-in form that accepts a handle or DID and starts the auth flow. +- [x] 3.2 Update English and Russian localization keys/copy from Google sign-in to ATProto/PDS sign-in. +- [x] 3.3 Update auth troubleshooting and profile settings help text to describe ATProto profile/avatar initialization. + +## 4. Documentation and verification + +- [x] 4.1 Update deployment and development docs with ATProto OAuth/client metadata setup and callback URLs. +- [x] 4.2 Search the codebase for remaining user-facing Google OAuth references and remove or intentionally retain them. +- [x] 4.3 Run formatting, tests, lint, typecheck, and build checks. +- [x] 4.4 Manually verify sign-in with a real ATProto identity routes to `/dashboard` and creates an `atproto` account row. diff --git a/openspec/specs/creator-auth/spec.md b/openspec/specs/creator-auth/spec.md index 6f15145..642a9ef 100644 --- a/openspec/specs/creator-auth/spec.md +++ b/openspec/specs/creator-auth/spec.md @@ -26,23 +26,42 @@ The system SHALL allow creators to manage personal forms they own and organizati - **WHEN** an authenticated creator requests a form they do not own personally and that does not belong to an organization they are part of - **THEN** the system denies access to form management, response review, and response export for that form -### Requirement: Authenticated identity initializes profile data from Google when available -The system SHALL use Google OAuth profile data to initialize missing creator identity fields, including profile image, available name data, and locale when available, without overwriting profile values the user has already edited. +### Requirement: Creator sign-in uses ATProto OAuth +The system SHALL offer ATProto OAuth as the creator sign-in provider and SHALL route successful ATProto sign-ins into the existing authenticated creator experience. -#### Scenario: New creator signs in with Google -- **WHEN** a user signs in with Google and their stored profile fields are missing -- **THEN** the system stores available Google name and profile image data on the user record for creator-facing identity use +#### Scenario: Unauthenticated creator starts ATProto sign-in +- **WHEN** an unauthenticated user starts creator sign-in with an ATProto handle, DID, or PDS-backed identity +- **THEN** the system starts the ATProto OAuth flow for that identity -#### Scenario: New creator signs in with a supported Google locale -- **WHEN** a user signs in with Google, has no stored locale preference, and Google provides a supported locale -- **THEN** the system stores that supported locale on the user record for future app localization +#### Scenario: ATProto sign-in succeeds +- **WHEN** ATProto OAuth completes successfully for a creator +- **THEN** the system authenticates the creator and sends them to the creator dashboard -#### Scenario: New creator signs in with an unsupported Google locale -- **WHEN** a user signs in with Google, has no stored locale preference, and Google provides an unsupported locale -- **THEN** the system stores or resolves English as the default locale for that user +#### Scenario: Sign-in action is localized +- **WHEN** the sign-in page is shown in a supported locale +- **THEN** the system labels the action as continuing with ATProto or a PDS-backed account in the active locale + +#### Scenario: ATProto sign-in cannot resolve identity +- **WHEN** a user submits an ATProto identity that cannot be resolved or authorized +- **THEN** the system shows a recoverable sign-in error without creating an authenticated creator session + +### Requirement: Authenticated identity initializes profile data from ATProto when available +The system SHALL use AT Protocol profile data to initialize missing creator identity fields, including profile image and available display name data, without overwriting profile values the user has already edited. + +#### Scenario: New creator signs in with ATProto profile data +- **WHEN** a user signs in with ATProto and their stored profile fields are missing +- **THEN** the system stores available AT Protocol display name and avatar data on the user record for creator-facing identity use + +#### Scenario: ATProto profile includes only partial identity data +- **WHEN** a user signs in with ATProto and AT Protocol provides only a handle, DID, display name, or avatar subset +- **THEN** the system initializes only the creator identity fields that can be derived safely from the available AT Protocol data + +#### Scenario: ATProto profile has no locale source +- **WHEN** a user signs in with ATProto and has no stored locale preference +- **THEN** the system uses the application default locale behavior instead of expecting an AT Protocol locale value #### Scenario: Returning creator signs in after editing profile settings -- **WHEN** a returning creator signs in with Google after manually updating their profile fields or locale preference +- **WHEN** a returning creator signs in with ATProto after manually updating their profile fields or locale preference - **THEN** the system preserves the creator-managed profile values instead of replacing them with provider values ### Requirement: Creator shell exposes avatar-based account access diff --git a/package.json b/package.json index 6479fbd..d921714 100644 --- a/package.json +++ b/package.json @@ -32,7 +32,8 @@ "prisma:generate": "prisma generate", "prisma:migrate": "prisma migrate dev", "prisma:studio": "prisma studio", - "postinstall": "prisma generate" + "postinstall": "prisma generate", + "deploy": "bash scripts/deploy.sh" }, "devDependencies": { "@playwright/test": "^1.55.0", @@ -52,6 +53,8 @@ "typescript": "6.0.2" }, "dependencies": { + "@atproto/api": "^0.20.41", + "@atproto/oauth-client-node": "^0.5.3", "@auth/prisma-adapter": "2.11.1", "@dnd-kit/core": "6.3.1", "@dnd-kit/sortable": "10.0.0", diff --git a/prisma/migrations/20260628193000_add_atproto_oauth_storage/migration.sql b/prisma/migrations/20260628193000_add_atproto_oauth_storage/migration.sql new file mode 100644 index 0000000..f375163 --- /dev/null +++ b/prisma/migrations/20260628193000_add_atproto_oauth_storage/migration.sql @@ -0,0 +1,23 @@ +-- CreateTable +CREATE TABLE "AtprotoOAuthState" ( + "key" TEXT NOT NULL, + "value" JSONB NOT NULL, + "expiresAt" TIMESTAMP(3) NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "AtprotoOAuthState_pkey" PRIMARY KEY ("key") +); + +-- CreateTable +CREATE TABLE "AtprotoOAuthSession" ( + "sub" TEXT NOT NULL, + "value" JSONB NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "AtprotoOAuthSession_pkey" PRIMARY KEY ("sub") +); + +-- CreateIndex +CREATE INDEX "AtprotoOAuthState_expiresAt_idx" ON "AtprotoOAuthState"("expiresAt"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index ca3c56c..f3468bc 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -85,6 +85,23 @@ model VerificationToken { @@unique([identifier, token]) } +model AtprotoOAuthState { + key String @id + value Json + expiresAt DateTime + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([expiresAt]) +} + +model AtprotoOAuthSession { + sub String @id + value Json + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} + model Form { id String @id @default(cuid()) userId String diff --git a/scripts/deploy.sh b/scripts/deploy.sh new file mode 100755 index 0000000..298aac6 --- /dev/null +++ b/scripts/deploy.sh @@ -0,0 +1,138 @@ +#!/usr/bin/env bash +set -euo pipefail + +DEPLOY_HOST="${DEPLOY_HOST:-root@10.72.30.114}" +DEPLOY_DIR="${DEPLOY_DIR:-/opt/lively-forms}" +PORT="${PORT:-8000}" +# Stage is public at forms.alivetech.org. Caddy terminates HTTPS there and +# proxies requests over the private network to http://10.72.30.114:8000. +# OAuth URLs must always use the public HTTPS origin, never the upstream URL. +PUBLIC_URL="${PUBLIC_URL:-https://forms.alivetech.org}" +PROXY_UPSTREAM="${PROXY_UPSTREAM:-http://10.72.30.114:8000}" +SERVICE_NAME="${SERVICE_NAME:-lively-forms}" + +echo "Deploying to ${DEPLOY_HOST}:${DEPLOY_DIR} on port ${PORT}" +echo "Public URL: ${PUBLIC_URL} (proxied to ${PROXY_UPSTREAM})" + +ssh "${DEPLOY_HOST}" "mkdir -p '${DEPLOY_DIR}'" + +COPYFILE_DISABLE=1 tar \ + --no-xattrs \ + --exclude='.git' \ + --exclude='.env' \ + --exclude='.next' \ + --exclude='node_modules' \ + --exclude='coverage' \ + --exclude='test-results' \ + --exclude='playwright-report' \ + --exclude='.DS_Store' \ + -czf - . | ssh "${DEPLOY_HOST}" "tar -xzf - -C '${DEPLOY_DIR}'" + +ssh "${DEPLOY_HOST}" bash -s -- "${DEPLOY_DIR}" "${PORT}" "${PUBLIC_URL}" "${SERVICE_NAME}" <<'REMOTE' +set -euo pipefail + +DEPLOY_DIR="$1" +PORT="$2" +PUBLIC_URL="$3" +SERVICE_NAME="$4" + +if ! command -v podman >/dev/null 2>&1 || ! command -v podman-compose >/dev/null 2>&1 || ! command -v unzip >/dev/null 2>&1 || ! command -v openssl >/dev/null 2>&1; then + apt-get update + apt-get install -y podman podman-compose unzip openssl +fi + +if ! command -v node >/dev/null 2>&1 || ! node -e 'process.exit(Number(process.versions.node.split(".")[0]) >= 22 ? 0 : 1)'; then + apt-get update + . /etc/os-release + if [ "${ID:-}" = "altlinux" ]; then + apt-get install -y node + else + apt-get install -y ca-certificates curl gnupg + curl -fsSL https://deb.nodesource.com/setup_22.x | bash - + apt-get install -y nodejs + fi +fi + +export BUN_INSTALL="${HOME}/.bun" +export PATH="${BUN_INSTALL}/bin:${PATH}" +if ! command -v bun >/dev/null 2>&1; then + curl -fsSL https://bun.sh/install | bash +fi + +cd "${DEPLOY_DIR}" + +if [ ! -f .env ]; then + AUTH_SECRET="$(openssl rand -base64 32)" + cat > .env < "/etc/systemd/system/${SERVICE_NAME}.service" <