From 3061bc24c1932c8a95cfe57652840452c8531046 Mon Sep 17 00:00:00 2001 From: Jim Ray Date: Thu, 9 Apr 2026 15:44:45 -0400 Subject: [PATCH 1/6] feat(auth): add ATProto OAuth login backend Adds AT Protocol OAuth support so users can sign in with a Bluesky handle or any PDS-hosted identity. Uses @atproto/oauth-client-node with PKCE (public client, minimal "atproto" scope). Core changes: - Migration 034: adds atproto_did column to users table - Store adapters: state and session stores backed by auth_challenges table (no new tables needed) - OAuth client: NodeOAuthClient with cached instances, loopback client ID support for localhost dev (RFC 8252) - Routes: client-metadata.json, login (POST handle), callback (token exchange + user provisioning) - Config: atproto boolean on EmDashConfig, atprotoEnabled in manifest - Middleware: ATProto auth routes and manifest endpoint public, complete-profile page bypasses auth - Route injection: ATProto routes in injectBuiltinAuthRoutes() User provisioning follows the same pattern as GitHub/Google OAuth: DID lookup via oauth_accounts, email auto-linking, allowed_domains gating for new signups. Also registers previously unregistered migration 033 in runner.ts. --- packages/core/package.json | 1 + packages/core/src/astro/integration/index.ts | 1 + packages/core/src/astro/integration/routes.ts | 26 ++ .../core/src/astro/integration/runtime.ts | 17 ++ packages/core/src/astro/middleware/auth.ts | 5 +- .../astro/routes/api/auth/atproto/callback.ts | 258 ++++++++++++++++++ .../api/auth/atproto/client-metadata.json.ts | 38 +++ .../astro/routes/api/auth/atproto/login.ts | 59 ++++ .../core/src/astro/routes/api/manifest.ts | 4 + packages/core/src/astro/types.ts | 5 + packages/core/src/auth/atproto/client.ts | 128 +++++++++ packages/core/src/auth/atproto/stores.ts | 139 ++++++++++ .../database/migrations/034_atproto_auth.ts | 28 ++ .../core/src/database/migrations/runner.ts | 3 + .../core/src/database/repositories/user.ts | 1 + packages/core/src/database/types.ts | 1 + pnpm-lock.yaml | 245 ++++++++++++++++- 17 files changed, 951 insertions(+), 8 deletions(-) create mode 100644 packages/core/src/astro/routes/api/auth/atproto/callback.ts create mode 100644 packages/core/src/astro/routes/api/auth/atproto/client-metadata.json.ts create mode 100644 packages/core/src/astro/routes/api/auth/atproto/login.ts create mode 100644 packages/core/src/auth/atproto/client.ts create mode 100644 packages/core/src/auth/atproto/stores.ts create mode 100644 packages/core/src/database/migrations/034_atproto_auth.ts diff --git a/packages/core/package.json b/packages/core/package.json index 660facd0a..976be0bd9 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -154,6 +154,7 @@ "test:integration": "vitest run --config vitest.integration.config.ts" }, "dependencies": { + "@atproto/oauth-client-node": "^0.3.17", "@emdash-cms/admin": "workspace:*", "@emdash-cms/auth": "workspace:*", "@emdash-cms/gutenberg-to-portable-text": "workspace:*", diff --git a/packages/core/src/astro/integration/index.ts b/packages/core/src/astro/integration/index.ts index 3ed69a2d2..b70a1269d 100644 --- a/packages/core/src/astro/integration/index.ts +++ b/packages/core/src/astro/integration/index.ts @@ -153,6 +153,7 @@ export function emdash(config: EmDashConfig = {}): AstroIntegration { auth: resolvedConfig.auth, marketplace: resolvedConfig.marketplace, passkeyPublicOrigin: resolvedConfig.passkeyPublicOrigin, + atproto: resolvedConfig.atproto, }; // Determine auth mode for route injection diff --git a/packages/core/src/astro/integration/routes.ts b/packages/core/src/astro/integration/routes.ts index a3eb49d53..edf5c2dcc 100644 --- a/packages/core/src/astro/integration/routes.ts +++ b/packages/core/src/astro/integration/routes.ts @@ -805,6 +805,32 @@ export function injectBuiltinAuthRoutes(injectRoute: InjectRoute): void { entrypoint: resolveRoute("api/auth/oauth/[provider]/callback.ts"), }); + // ATProto OAuth routes + injectRoute({ + pattern: "/_emdash/api/auth/atproto/client-metadata.json", + entrypoint: resolveRoute("api/auth/atproto/client-metadata.json.ts"), + }); + + injectRoute({ + pattern: "/_emdash/api/auth/atproto/login", + entrypoint: resolveRoute("api/auth/atproto/login.ts"), + }); + + injectRoute({ + pattern: "/_emdash/api/auth/atproto/callback", + entrypoint: resolveRoute("api/auth/atproto/callback.ts"), + }); + + injectRoute({ + pattern: "/_emdash/api/auth/atproto/complete-profile", + entrypoint: resolveRoute("api/auth/atproto/complete-profile.ts"), + }); + + injectRoute({ + pattern: "/_emdash/api/auth/atproto/verify-email", + entrypoint: resolveRoute("api/auth/atproto/verify-email.ts"), + }); + // Self-signup routes injectRoute({ pattern: "/_emdash/api/auth/signup/request", diff --git a/packages/core/src/astro/integration/runtime.ts b/packages/core/src/astro/integration/runtime.ts index 7b000d738..52d8dcb1c 100644 --- a/packages/core/src/astro/integration/runtime.ts +++ b/packages/core/src/astro/integration/runtime.ts @@ -222,6 +222,23 @@ export interface EmDashConfig { */ auth?: AuthDescriptor; + /** + * Enable ATProto OAuth login (Bluesky / AT Protocol handles). + * + * When true, the admin login page shows a handle input for signing in + * with a Bluesky handle or any AT Protocol PDS-hosted identity. + * + * @default false + * + * @example + * ```ts + * emdash({ + * atproto: true, + * }) + * ``` + */ + atproto?: boolean; + /** * Enable the MCP (Model Context Protocol) server endpoint. * diff --git a/packages/core/src/astro/middleware/auth.ts b/packages/core/src/astro/middleware/auth.ts index 76228d1c6..7c7e709a0 100644 --- a/packages/core/src/astro/middleware/auth.ts +++ b/packages/core/src/astro/middleware/auth.ts @@ -95,6 +95,7 @@ const PUBLIC_API_PREFIXES = [ "/_emdash/api/auth/invite/accept", "/_emdash/api/auth/invite/complete", "/_emdash/api/auth/oauth/", + "/_emdash/api/auth/atproto/", "/_emdash/api/oauth/device/token", "/_emdash/api/oauth/device/code", "/_emdash/api/oauth/token", @@ -108,6 +109,7 @@ const PUBLIC_API_EXACT = new Set([ "/_emdash/api/auth/passkey/verify", "/_emdash/api/oauth/token", "/_emdash/api/snapshot", + "/_emdash/api/manifest", ]); function isPublicEmDashRoute(pathname: string): boolean { @@ -268,6 +270,7 @@ async function handleEmDashAuth( const { emdash } = locals; const isLoginRoute = url.pathname.startsWith("/_emdash/admin/login"); + const isCompleteProfileRoute = url.pathname.startsWith("/_emdash/admin/complete-profile"); const isApiRoute = url.pathname.startsWith("/_emdash/api"); if (!emdash?.db) { @@ -293,7 +296,7 @@ async function handleEmDashAuth( } // Passkey authentication (default) - if (isLoginRoute) { + if (isLoginRoute || isCompleteProfileRoute) { return next(); } diff --git a/packages/core/src/astro/routes/api/auth/atproto/callback.ts b/packages/core/src/astro/routes/api/auth/atproto/callback.ts new file mode 100644 index 000000000..94ee79c68 --- /dev/null +++ b/packages/core/src/astro/routes/api/auth/atproto/callback.ts @@ -0,0 +1,258 @@ +/** + * GET /_emdash/api/auth/atproto/callback + * + * Handles the redirect from the PDS authorization server after the user + * approves the ATProto OAuth request. Exchanges the code for tokens, + * provisions or links the user, and creates an EmDash session. + */ + +import type { APIRoute } from "astro"; +import { ulid } from "ulidx"; + +import { createAtprotoClient, cleanupAtprotoEntries } from "#auth/atproto/client.js"; + +export const prerender = false; + +const PENDING_TTL_MS = 15 * 60 * 1000; // 15 minutes for email collection + +/** Create a redirect response with mutable headers (unlike Response.redirect) */ +function redirectTo(url: string, status = 302): Response { + return new Response(null, { + status, + headers: { Location: url }, + }); +} + +export const GET: APIRoute = async ({ request, locals, session }) => { + const { emdash } = locals; + + if (!emdash?.db) { + return redirectTo( + `/_emdash/admin/login?error=server_error&message=${encodeURIComponent("Database not configured")}`, + ); + } + + if (!emdash.config?.atproto) { + return redirectTo( + `/_emdash/admin/login?error=not_configured&message=${encodeURIComponent("ATProto authentication is not enabled")}`, + ); + } + + const url = new URL(request.url); + const params = url.searchParams; + + // In loopback mode, the PDS redirects to 127.0.0.1 but the user browses + // on localhost. We use two origins: + // - localhostOrigin: for UI redirects (login page, complete-profile) where + // the SPA runs and existing cookies live + // - url.origin: for session-setting redirects (successful login) so the + // session cookie is set on the same host as the response + const hostname = url.hostname; + const isLoopback = hostname === "127.0.0.1" || hostname === "[::1]"; + const localhostOrigin = isLoopback ? `http://localhost:${url.port}` : url.origin; + // For successful auth, stay on the callback host so session cookie is valid + const sessionOrigin = url.origin; + + // Handle errors from the PDS + const error = params.get("error"); + if (error) { + const desc = params.get("error_description") || error; + return redirectTo( + `${localhostOrigin}/_emdash/admin/login?error=oauth_denied&message=${encodeURIComponent(desc)}`, + ); + } + + try { + const client = createAtprotoClient({ + publicUrl: localhostOrigin, + db: emdash.db, + allowHttp: import.meta.env.DEV, + }); + + // Exchange code for tokens via SDK + const { session: oauthSession } = await client.callback(params); + + const did = oauthSession.did; + + // Resolve handle from DID + let handle: string | undefined; + try { + const identity = await client.identityResolver.resolve(did); + handle = identity.handle !== "handle.invalid" ? identity.handle : undefined; + } catch { + // Handle resolution failure is not fatal — we still have the DID + } + + // Try to get email from the token info + // ATProto doesn't standardize email in token responses, + // so this may not be available + let email: string | undefined; + try { + const tokenInfo = await oauthSession.getTokenInfo(); + // Check if email is available in token metadata + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const info = tokenInfo as any; + if (typeof info.email === "string" && info.email) { + email = info.email; + } + } catch { + // Email not available from token — will need interstitial + } + + // === User provisioning === + + // Step 1: Look up by DID in oauth_accounts + const existingAccount = await emdash.db + .selectFrom("oauth_accounts") + .selectAll() + .where("provider", "=", "atproto") + .where("provider_account_id", "=", did) + .executeTakeFirst(); + + if (existingAccount) { + const user = await emdash.db + .selectFrom("users") + .selectAll() + .where("id", "=", existingAccount.user_id) + .executeTakeFirst(); + + if (!user) { + return redirectTo( + `${localhostOrigin}/_emdash/admin/login?error=user_not_found&message=${encodeURIComponent("Linked user account not found")}`, + ); + } + + if (user.disabled) { + return redirectTo( + `${localhostOrigin}/_emdash/admin/login?error=account_disabled&message=${encodeURIComponent("Your account has been disabled")}`, + ); + } + + // Update handle if it changed + if (handle && user.atproto_did === did) { + // User already has DID set, just update if needed + } + + if (session) { + session.set("user", { id: user.id }); + } + + await cleanupAtprotoEntries(emdash.db); + return redirectTo(`${sessionOrigin}/_emdash/admin`); + } + + // Step 2: If we have email, try to link by email + if (email) { + const existingUser = await emdash.db + .selectFrom("users") + .selectAll() + .where("email", "=", email.toLowerCase()) + .executeTakeFirst(); + + if (existingUser) { + if (existingUser.disabled) { + return redirectTo( + `${localhostOrigin}/_emdash/admin/login?error=account_disabled&message=${encodeURIComponent("Your account has been disabled")}`, + ); + } + + // Link DID to existing user + await emdash.db + .updateTable("users") + .set({ atproto_did: did }) + .where("id", "=", existingUser.id) + .execute(); + + await emdash.db + .insertInto("oauth_accounts") + .values({ + provider: "atproto", + provider_account_id: did, + user_id: existingUser.id, + }) + .execute(); + + if (session) { + session.set("user", { id: existingUser.id }); + } + + await cleanupAtprotoEntries(emdash.db); + return redirectTo(`${sessionOrigin}/_emdash/admin`); + } + + // Step 3: Check allowed_domains for self-signup + const domain = email.split("@")[1]?.toLowerCase(); + if (domain) { + const allowedDomain = await emdash.db + .selectFrom("allowed_domains") + .selectAll() + .where("domain", "=", domain) + .where("enabled", "=", 1) + .executeTakeFirst(); + + if (allowedDomain) { + const userId = ulid(); + await emdash.db + .insertInto("users") + .values({ + id: userId, + email: email.toLowerCase(), + name: handle || null, + avatar_url: null, + role: allowedDomain.default_role, + email_verified: 1, + data: null, + atproto_did: did, + }) + .execute(); + + await emdash.db + .insertInto("oauth_accounts") + .values({ + provider: "atproto", + provider_account_id: did, + user_id: userId, + }) + .execute(); + + if (session) { + session.set("user", { id: userId }); + } + + await cleanupAtprotoEntries(emdash.db); + return redirectTo(`${sessionOrigin}/_emdash/admin`); + } + } + + // No allowed domain — signup not permitted + return redirectTo( + `${localhostOrigin}/_emdash/admin/login?error=signup_not_allowed&message=${encodeURIComponent("Self-signup is not allowed for your email domain. Please contact an administrator.")}`, + ); + } + + // Step 4: No email available — redirect to complete-profile interstitial + const pendingKey = ulid(); + const expiresAt = new Date(Date.now() + PENDING_TTL_MS).toISOString(); + + await emdash.db + .insertInto("auth_challenges") + .values({ + challenge: pendingKey, + type: "atproto_pending", + user_id: null, + data: JSON.stringify({ did, handle }), + expires_at: expiresAt, + }) + .execute(); + + return redirectTo( + `${localhostOrigin}/_emdash/admin/complete-profile?state=${encodeURIComponent(pendingKey)}`, + ); + } catch (callbackError) { + console.error("ATProto callback error:", callbackError); + + return redirectTo( + `${localhostOrigin}/_emdash/admin/login?error=atproto_error&message=${encodeURIComponent("Authentication failed. Please try again.")}`, + ); + } +}; diff --git a/packages/core/src/astro/routes/api/auth/atproto/client-metadata.json.ts b/packages/core/src/astro/routes/api/auth/atproto/client-metadata.json.ts new file mode 100644 index 000000000..285481025 --- /dev/null +++ b/packages/core/src/astro/routes/api/auth/atproto/client-metadata.json.ts @@ -0,0 +1,38 @@ +/** + * GET /_emdash/api/auth/atproto/client-metadata.json + * + * Serves the OAuth client metadata document for ATProto OAuth. + * This URL IS the client_id (ATProto convention for public clients). + * + * Must be publicly accessible — PDS authorization servers fetch this + * to validate the client during the OAuth flow. + */ + +import type { APIRoute } from "astro"; + +export const prerender = false; + +export const GET: APIRoute = async ({ request }) => { + const url = new URL(request.url); + const origin = url.origin; + + const metadata = { + client_id: `${origin}/_emdash/api/auth/atproto/client-metadata.json`, + client_name: "EmDash CMS", + client_uri: origin, + redirect_uris: [`${origin}/_emdash/api/auth/atproto/callback`], + grant_types: ["authorization_code"], + response_types: ["code"], + token_endpoint_auth_method: "none", + scope: "atproto", + dpop_bound_access_tokens: true, + application_type: "web", + }; + + return Response.json(metadata, { + headers: { + "Cache-Control": "public, max-age=3600", + "Content-Type": "application/json", + }, + }); +}; diff --git a/packages/core/src/astro/routes/api/auth/atproto/login.ts b/packages/core/src/astro/routes/api/auth/atproto/login.ts new file mode 100644 index 000000000..8f6e77681 --- /dev/null +++ b/packages/core/src/astro/routes/api/auth/atproto/login.ts @@ -0,0 +1,59 @@ +/** + * POST /_emdash/api/auth/atproto/login + * + * Initiates ATProto OAuth login flow. + * Accepts a Bluesky handle, resolves it to a PDS, generates PKCE state, + * and returns the authorization URL for the user to be redirected to. + */ + +import type { APIRoute } from "astro"; + +import { apiError, handleError } from "#api/error.js"; +import { createAtprotoClient } from "#auth/atproto/client.js"; + +export const prerender = false; + +export const POST: APIRoute = async ({ request, locals }) => { + const { emdash } = locals; + + if (!emdash?.db) { + return apiError("NOT_CONFIGURED", "EmDash is not initialized", 500); + } + + if (!emdash.config?.atproto) { + return apiError("NOT_CONFIGURED", "ATProto authentication is not enabled", 403); + } + + let handle: string; + try { + const body = (await request.json()) as { handle?: string }; + handle = typeof body.handle === "string" ? body.handle.trim().toLowerCase() : ""; + } catch { + return apiError("VALIDATION_ERROR", "Invalid request body", 400); + } + + if (!handle || !handle.includes(".")) { + return apiError( + "VALIDATION_ERROR", + "Please enter a valid handle (e.g., alice.bsky.social)", + 400, + ); + } + + try { + const url = new URL(request.url); + const client = createAtprotoClient({ + publicUrl: url.origin, + db: emdash.db, + allowHttp: import.meta.env.DEV, + }); + + const authUrl = await client.authorize(handle); + + return Response.json({ data: { redirectUrl: authUrl.toString() } }); + } catch (error) { + const message = error instanceof Error ? error.message : "Unknown error"; + console.error("[ATPROTO_LOGIN_ERROR]", message); + return apiError("ATPROTO_LOGIN_ERROR", "Failed to initiate ATProto login", 500); + } +}; diff --git a/packages/core/src/astro/routes/api/manifest.ts b/packages/core/src/astro/routes/api/manifest.ts index da9cd77be..45de127b6 100644 --- a/packages/core/src/astro/routes/api/manifest.ts +++ b/packages/core/src/astro/routes/api/manifest.ts @@ -36,11 +36,14 @@ export const GET: APIRoute = async ({ locals }) => { } } + const atprotoEnabled = !!emdash?.config?.atproto; + const manifest: EmDashManifest = emdashManifest ? { ...emdashManifest, authMode: authMode.type === "external" ? authMode.providerType : "passkey", signupEnabled, + atprotoEnabled, } : { version: "0.1.0", @@ -49,6 +52,7 @@ export const GET: APIRoute = async ({ locals }) => { plugins: {}, authMode: "passkey", signupEnabled, + atprotoEnabled, }; return Response.json( diff --git a/packages/core/src/astro/types.ts b/packages/core/src/astro/types.ts index e5b8b6f7c..a5dd4a2d3 100644 --- a/packages/core/src/astro/types.ts +++ b/packages/core/src/astro/types.ts @@ -111,6 +111,11 @@ export interface EmDashManifest { * Used by the login page to conditionally show the "Sign up" link. */ signupEnabled?: boolean; + /** + * Whether ATProto OAuth login is enabled. + * When true, the login page shows a handle input for ATProto auth. + */ + atprotoEnabled?: boolean; /** * i18n configuration from Astro config. * Only present when i18n is enabled (multiple locales configured). diff --git a/packages/core/src/auth/atproto/client.ts b/packages/core/src/auth/atproto/client.ts new file mode 100644 index 000000000..aa81d6d97 --- /dev/null +++ b/packages/core/src/auth/atproto/client.ts @@ -0,0 +1,128 @@ +/** + * ATProto OAuth client + * + * Creates and configures a NodeOAuthClient for AT Protocol OAuth login. + * Uses PKCE (public client) — no JWKS endpoint or key management needed. + * DPoP keys are managed by the SDK internally. + */ + +import { NodeOAuthClient } from "@atproto/oauth-client-node"; +import type { Kysely } from "kysely"; + +import type { Database } from "../../database/types.js"; +import { createAtprotoSessionStore, createAtprotoStateStore } from "./stores.js"; + +export interface AtprotoClientOptions { + /** Public URL of the site (e.g., "https://example.com") */ + publicUrl: string; + /** Database instance for state/session stores */ + db: Kysely; + /** Allow HTTP for development (default: false) */ + allowHttp?: boolean; +} + +/** + * Check if a URL is a loopback/localhost address. + */ +function isLoopback(url: string): boolean { + try { + const { hostname } = new URL(url); + return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "[::1]"; + } catch { + return false; + } +} + +/** Cached client instances keyed by publicUrl */ +const clientCache = new Map(); + +/** + * Get or create an ATProto OAuth client for the given options. + * + * The client is cached per publicUrl since the configuration (stores, metadata) + * doesn't change between requests. The db reference is stable (single runtime + * connection), so stores created from it are safe to reuse. + * + * For production (HTTPS), the client ID is the URL of the client-metadata.json + * endpoint, which is an ATProto convention for public clients. + * + * For localhost development, ATProto uses a special loopback client ID format: + * `http://localhost?redirect_uri=...` with redirect URIs using + * `http://127.0.0.1` (not localhost) per RFC 8252 §7.3. + */ +export function createAtprotoClient(options: AtprotoClientOptions): NodeOAuthClient { + const { publicUrl, db, allowHttp = false } = options; + + const cached = clientCache.get(publicUrl); + if (cached) return cached; + + const stateStore = createAtprotoStateStore(db); + const sessionStore = createAtprotoSessionStore(db); + + let client: NodeOAuthClient; + + if (isLoopback(publicUrl)) { + // Localhost development: use ATProto loopback client ID format. + // Redirect URI must use 127.0.0.1, not localhost (per ATProto spec). + const { port } = new URL(publicUrl); + const redirectUri = `http://127.0.0.1:${port}/_emdash/api/auth/atproto/callback`; + // Scope is "atproto" (the default), so only redirect_uri needs to be in the query + const clientId = `http://localhost?redirect_uri=${encodeURIComponent(redirectUri)}`; + + client = new NodeOAuthClient({ + clientMetadata: { + client_id: clientId, + client_name: "EmDash CMS", + client_uri: publicUrl, + redirect_uris: [redirectUri], + grant_types: ["authorization_code"], + response_types: ["code"], + token_endpoint_auth_method: "none", + scope: "atproto", + dpop_bound_access_tokens: true, + application_type: "web", + }, + stateStore, + sessionStore, + allowHttp: true, + }); + } else { + // Production: client ID is the client-metadata.json URL + const clientId = `${publicUrl}/_emdash/api/auth/atproto/client-metadata.json`; + const redirectUri = `${publicUrl}/_emdash/api/auth/atproto/callback`; + + client = new NodeOAuthClient({ + clientMetadata: { + client_id: clientId, + client_name: "EmDash CMS", + client_uri: publicUrl, + redirect_uris: [redirectUri], + grant_types: ["authorization_code"], + response_types: ["code"], + token_endpoint_auth_method: "none", + scope: "atproto", + dpop_bound_access_tokens: true, + application_type: "web", + }, + stateStore, + sessionStore, + allowHttp, + }); + } + + clientCache.set(publicUrl, client); + return client; +} + +/** + * Clean up expired ATProto state and session entries from auth_challenges. + * Call periodically or after successful login. + */ +export async function cleanupAtprotoEntries(db: Kysely): Promise { + const now = new Date().toISOString(); + await db + .deleteFrom("auth_challenges") + .where("type", "in", ["atproto", "atproto_session", "atproto_pending"]) + .where("expires_at", "<", now) + .execute(); +} diff --git a/packages/core/src/auth/atproto/stores.ts b/packages/core/src/auth/atproto/stores.ts new file mode 100644 index 000000000..df070fabd --- /dev/null +++ b/packages/core/src/auth/atproto/stores.ts @@ -0,0 +1,139 @@ +/** + * ATProto OAuth store adapters + * + * Implements the NodeSavedStateStore and NodeSavedSessionStore interfaces + * required by @atproto/oauth-client-node, backed by the existing + * auth_challenges table. + * + * State entries (type="atproto") are short-lived PKCE state during the + * OAuth flow (~10 min TTL). Session entries (type="atproto_session") hold + * DPoP keys and tokens; they are deleted after login completes. + */ + +import type { NodeSavedSession, NodeSavedState } from "@atproto/oauth-client-node"; +import type { Kysely } from "kysely"; + +import type { Database } from "../../database/types.js"; + +type SimpleStore = { + get(key: K): Promise; + set(key: K, value: V): Promise; + del(key: K): Promise; +}; + +const STATE_TTL_MS = 10 * 60 * 1000; // 10 minutes +const SESSION_TTL_MS = 60 * 60 * 1000; // 1 hour (safety net for abandoned flows) + +export function createAtprotoStateStore(db: Kysely): SimpleStore { + return { + async get(key: string): Promise { + const row = await db + .selectFrom("auth_challenges") + .selectAll() + .where("challenge", "=", key) + .where("type", "=", "atproto") + .executeTakeFirst(); + + if (!row?.data) return undefined; + + if (new Date(row.expires_at).getTime() < Date.now()) { + await this.del(key); + return undefined; + } + + try { + return JSON.parse(row.data) as NodeSavedState; + } catch { + return undefined; + } + }, + + async set(key: string, value: NodeSavedState): Promise { + const expiresAt = new Date(Date.now() + STATE_TTL_MS).toISOString(); + + await db + .insertInto("auth_challenges") + .values({ + challenge: key, + type: "atproto", + user_id: null, + data: JSON.stringify(value), + expires_at: expiresAt, + }) + .onConflict((oc) => + oc.column("challenge").doUpdateSet({ + type: "atproto", + data: JSON.stringify(value), + expires_at: expiresAt, + }), + ) + .execute(); + }, + + async del(key: string): Promise { + await db + .deleteFrom("auth_challenges") + .where("challenge", "=", key) + .where("type", "=", "atproto") + .execute(); + }, + }; +} + +export function createAtprotoSessionStore( + db: Kysely, +): SimpleStore { + return { + async get(key: string): Promise { + const row = await db + .selectFrom("auth_challenges") + .selectAll() + .where("challenge", "=", key) + .where("type", "=", "atproto_session") + .executeTakeFirst(); + + if (!row?.data) return undefined; + + if (new Date(row.expires_at).getTime() < Date.now()) { + await this.del(key); + return undefined; + } + + try { + return JSON.parse(row.data) as NodeSavedSession; + } catch { + return undefined; + } + }, + + async set(key: string, value: NodeSavedSession): Promise { + const expiresAt = new Date(Date.now() + SESSION_TTL_MS).toISOString(); + + await db + .insertInto("auth_challenges") + .values({ + challenge: key, + type: "atproto_session", + user_id: null, + data: JSON.stringify(value), + expires_at: expiresAt, + }) + .onConflict((oc) => + oc.column("challenge").doUpdateSet({ + type: "atproto_session", + data: JSON.stringify(value), + expires_at: expiresAt, + }), + ) + .execute(); + }, + + async del(key: string): Promise { + await db + .deleteFrom("auth_challenges") + .where("challenge", "=", key) + .where("type", "=", "atproto_session") + .execute(); + }, + }; +} diff --git a/packages/core/src/database/migrations/034_atproto_auth.ts b/packages/core/src/database/migrations/034_atproto_auth.ts new file mode 100644 index 000000000..3b0ad9720 --- /dev/null +++ b/packages/core/src/database/migrations/034_atproto_auth.ts @@ -0,0 +1,28 @@ +import { sql, type Kysely } from "kysely"; + +/** + * Migration: ATProto OAuth login support. + * + * Adds atproto_did column to users table so ATProto identities (DIDs) + * can be associated with user accounts. This enables login via Bluesky + * handle or any AT Protocol PDS. + * + * ATProto OAuth state and session data reuse the existing auth_challenges + * table with type="atproto" and type="atproto_session". + */ +export async function up(db: Kysely): Promise { + await db.schema.alterTable("users").addColumn("atproto_did", "text").execute(); + + // Unique index — each DID maps to exactly one user + await db.schema + .createIndex("idx_users_atproto_did") + .on("users") + .column("atproto_did") + .unique() + .execute(); +} + +export async function down(db: Kysely): Promise { + await db.schema.dropIndex("idx_users_atproto_did").execute(); + await db.schema.alterTable("users").dropColumn("atproto_did").execute(); +} diff --git a/packages/core/src/database/migrations/runner.ts b/packages/core/src/database/migrations/runner.ts index 48205dfb7..13d1c7995 100644 --- a/packages/core/src/database/migrations/runner.ts +++ b/packages/core/src/database/migrations/runner.ts @@ -34,6 +34,7 @@ import * as m030 from "./030_widen_scheduled_index.js"; import * as m031 from "./031_bylines.js"; import * as m032 from "./032_rate_limits.js"; import * as m033 from "./033_optimize_content_indexes.js"; +import * as m034 from "./034_atproto_auth.js"; const MIGRATIONS: Readonly> = Object.freeze({ "001_initial": m001, @@ -67,6 +68,8 @@ const MIGRATIONS: Readonly> = Object.freeze({ "030_widen_scheduled_index": m030, "031_bylines": m031, "032_rate_limits": m032, + "033_optimize_content_indexes": m033, + "034_atproto_auth": m034, }); /** Total number of registered migrations. Exported for use in tests. */ diff --git a/packages/core/src/database/repositories/user.ts b/packages/core/src/database/repositories/user.ts index bf67b05e5..b0530a6d2 100644 --- a/packages/core/src/database/repositories/user.ts +++ b/packages/core/src/database/repositories/user.ts @@ -61,6 +61,7 @@ export class UserRepository { avatar_url: input.avatarUrl ?? null, email_verified: 0, data: input.data ? JSON.stringify(input.data) : null, + atproto_did: null, }; await this.db.insertInto("users").values(row).execute(); diff --git a/packages/core/src/database/types.ts b/packages/core/src/database/types.ts index d13f9bd05..3b1e06179 100644 --- a/packages/core/src/database/types.ts +++ b/packages/core/src/database/types.ts @@ -65,6 +65,7 @@ export interface UserTable { email_verified: number; // 0 or 1 data: string | null; // JSON disabled: Generated; // 0 or 1 + atproto_did: string | null; // AT Protocol DID (did:plc:* or did:web:*) created_at: Generated; updated_at: Generated; } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 65158c06c..08ff70a1d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -808,6 +808,9 @@ importers: '@astrojs/react': specifier: '>=5.0.0-beta.0' version: 5.0.0-beta.4(@types/node@24.10.13)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(jiti@2.6.1)(lightningcss@1.31.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(tsx@4.21.0)(yaml@2.8.2) + '@atproto/oauth-client-node': + specifier: ^0.3.17 + version: 0.3.17 '@emdash-cms/admin': specifier: workspace:* version: link:../admin @@ -873,10 +876,10 @@ importers: version: 3.7.0 astro: specifier: '>=6.0.0-beta.0' - version: 6.0.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(rollup@4.55.2)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2) + version: 6.1.3(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(rollup@4.55.2)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2) astro-portabletext: specifier: ^0.11.0 - version: 0.11.4(astro@6.0.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(rollup@4.55.2)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2)) + version: 0.11.4(astro@6.1.3(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(rollup@4.55.2)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2)) better-sqlite3: specifier: 'catalog:' version: 11.10.0 @@ -1718,30 +1721,99 @@ packages: '@astrojs/yaml2ts@0.2.2': resolution: {integrity: sha512-GOfvSr5Nqy2z5XiwqTouBBpy5FyI6DEe+/g/Mk5am9SjILN1S5fOEvYK0GuWHg98yS/dobP4m8qyqw/URW35fQ==} + '@atproto-labs/did-resolver@0.2.6': + resolution: {integrity: sha512-2K1bC04nI2fmgNcvof+yA28IhGlpWn2JKYlPa7To9JTKI45FINCGkQSGiL2nyXlyzDJJ34fZ1aq6/IRFIOIiqg==} + + '@atproto-labs/fetch-node@0.2.0': + resolution: {integrity: sha512-Krq09nH/aeoiU2s9xdHA0FjTEFWG9B5FFenipv1iRixCcPc7V3DhTNDawxG9gI8Ny0k4dBVS9WTRN/IDzBx86Q==} + engines: {node: '>=18.7.0'} + + '@atproto-labs/fetch@0.2.3': + resolution: {integrity: sha512-NZtbJOCbxKUFRFKMpamT38PUQMY0hX0p7TG5AEYOPhZKZEP7dHZ1K2s1aB8MdVH0qxmqX7nQleNrrvLf09Zfdw==} + + '@atproto-labs/handle-resolver-node@0.1.25': + resolution: {integrity: sha512-NY9WYM2VLd3IuMGRkkmvGBg8xqVEaK/fitv1vD8SMXqFTekdpjOLCCyv7EFtqVHouzmDcL83VOvWRfHVa8V9Yw==} + engines: {node: '>=18.7.0'} + + '@atproto-labs/handle-resolver@0.3.6': + resolution: {integrity: sha512-qnSTXvOBNj1EHhp2qTWSX8MS5q3AwYU5LKlt5fBvSbCjgmTr2j0URHCv+ydrwO55KvsojIkTMgeMOh4YuY4fCA==} + + '@atproto-labs/identity-resolver@0.3.6': + resolution: {integrity: sha512-qoWqBDRobln0NR8L8dQjSp79E0chGkBhibEgxQa2f9WD+JbJdjQ0YvwwO5yeQn05pJoJmAwmI2wyJ45zjU7aWg==} + + '@atproto-labs/pipe@0.1.1': + resolution: {integrity: sha512-hdNw2oUs2B6BN1lp+32pF7cp8EMKuIN5Qok2Vvv/aOpG/3tNSJ9YkvfI0k6Zd188LeDDYRUpYpxcoFIcGH/FNg==} + + '@atproto-labs/simple-store-memory@0.1.4': + resolution: {integrity: sha512-3mKY4dP8I7yKPFj9VKpYyCRzGJOi5CEpOLPlRhoJyLmgs3J4RzDrjn323Oakjz2Aj2JzRU/AIvWRAZVhpYNJHw==} + + '@atproto-labs/simple-store@0.3.0': + resolution: {integrity: sha512-nOb6ONKBRJHRlukW1sVawUkBqReLlLx6hT35VS3imaNPwiXDxLnTK7lxw3Lrl9k5yugSBDQAkZAq3MPTEFSUBQ==} + '@atproto/api@0.13.35': resolution: {integrity: sha512-vsEfBj0C333TLjDppvTdTE0IdKlXuljKSveAeI4PPx/l6eUKNnDTsYxvILtXUVzwUlTDmSRqy5O4Ryh78n1b7g==} '@atproto/common-web@0.4.12': resolution: {integrity: sha512-3aCJemqM/fkHQrVPbTCHCdiVstKFI+2LkFLvUhO6XZP0EqUZa/rg/CIZBKTFUWu9I5iYiaEiXL9VwcDRpEevSw==} + '@atproto/common-web@0.4.19': + resolution: {integrity: sha512-3BTi58p5WpT+9/zb6UZrdsXcfPo5P45UJm0E4iwHLILr+jc37CuBj9JReDSZ4U0i9RTrI3ZkfySyZ9bd+LnMsw==} + + '@atproto/did@0.3.0': + resolution: {integrity: sha512-raUPzUGegtW/6OxwCmM8bhZvuIMzxG5t9oWsth6Tp91Kb5fTnHV2h/KKNF1C82doeA4BdXCErTyg7ISwLbQkzA==} + + '@atproto/jwk-jose@0.1.11': + resolution: {integrity: sha512-i4Fnr2sTBYmMmHXl7NJh8GrCH+tDQEVWrcDMDnV5DjJfkgT17wIqvojIw9SNbSL4Uf0OtfEv6AgG0A+mgh8b5Q==} + + '@atproto/jwk-webcrypto@0.2.0': + resolution: {integrity: sha512-UmgRrrEAkWvxwhlwe30UmDOdTEFidlIzBC7C3cCbeJMcBN1x8B3KH+crXrsTqfWQBG58mXgt8wgSK3Kxs2LhFg==} + + '@atproto/jwk@0.6.0': + resolution: {integrity: sha512-bDoJPvt7TrQVi/rBfBrSSpGykhtIriKxeYCYQTiPRKFfyRhbgpElF0wPXADjIswnbzZdOwbY63az4E/CFVT3Tw==} + + '@atproto/lex-data@0.0.14': + resolution: {integrity: sha512-53DUa9664SS76nGAMYopWsO10OH0AAdf7P/HSKB6Wzx3iqe6lk/K61QZnKxOG1LreYl5CfvIJU6eNf4txI6GlQ==} + '@atproto/lex-data@0.0.8': resolution: {integrity: sha512-1Y5tz7BkS7380QuLNXaE8GW8Xba+mRWugt8BKM4BUFYjjUZdmirU8lr72iM4XlEBrzRu8Cfvj+MbsbYaZv+IgA==} + '@atproto/lex-json@0.0.14': + resolution: {integrity: sha512-6lPkDKqe7teEu4WrN5q7400cvZKgYS3uwUMvzG3F9XkgVYhOwSDCtouV/nSLBbpvo3l9OP0kiigtclcNcyekww==} + '@atproto/lex-json@0.0.8': resolution: {integrity: sha512-w1Qmkae1QhmNz+i1Zm3xr3jp0UPPRENmdlpU0qIrdxWDo9W4Mzkeyc3eSoa+Zs+zN8xkRSQw7RLZte/B7Ipdwg==} '@atproto/lexicon@0.4.14': resolution: {integrity: sha512-jiKpmH1QER3Gvc7JVY5brwrfo+etFoe57tKPQX/SmPwjvUsFnJAow5xLIryuBaJgFAhnTZViXKs41t//pahGHQ==} + '@atproto/lexicon@0.6.2': + resolution: {integrity: sha512-p3Ly6hinVZW0ETuAXZMeUGwuMm3g8HvQMQ41yyEE6AL0hAkfeKFaZKos6BdBrr6CjkpbrDZqE8M+5+QOceysMw==} + + '@atproto/oauth-client-node@0.3.17': + resolution: {integrity: sha512-67LNuKAlC35Exe7CB5S0QCAnEqr6fKV9Nvp64jAHFof1N+Vc9Ltt1K9oekE5Ctf7dvpGByrHRF0noUw9l9sWLA==} + engines: {node: '>=18.7.0'} + + '@atproto/oauth-client@0.6.0': + resolution: {integrity: sha512-F7ZTKzFptXgyihMkd7QTdRSkrh4XqrS+qTw+V81k5Q6Bh3MB1L3ypvfSJ6v7SSUJa6XxoZYJTCahHC1e+ndE6Q==} + + '@atproto/oauth-types@0.6.3': + resolution: {integrity: sha512-jdKuoPknJuh/WjI+mYk7agSbx9mNVMbS6Dr3k1z2YMY2oRiCQjxYBuo4MLKATbxj05nMQaZRWlHRUazoAu5Cng==} + '@atproto/syntax@0.3.4': resolution: {integrity: sha512-8CNmi5DipOLaVeSMPggMe7FCksVag0aO6XZy9WflbduTKM4dFZVCs4686UeMLfGRXX+X966XgwECHoLYrovMMg==} '@atproto/syntax@0.4.2': resolution: {integrity: sha512-X9XSRPinBy/0VQ677j8VXlBsYSsUXaiqxWVpGGxJYsAhugdQRb0jqaVKJFtm6RskeNkV6y9xclSUi9UYG/COrA==} + '@atproto/syntax@0.5.3': + resolution: {integrity: sha512-gzhlHOJHm5KXdCc17fXi1fXM81ccs5jJfNgCui84ay9JGvczxegpYHNqdMlv+iBuhtBzFIjgx6ChjRxN/kO8kQ==} + '@atproto/xrpc@0.6.12': resolution: {integrity: sha512-Ut3iISNLujlmY9Gu8sNU+SPDJDvqlVzWddU8qUr0Yae5oD4SguaUFjjhireMGhQ3M5E0KljQgDbTmnBo1kIZ3w==} + '@atproto/xrpc@0.7.7': + resolution: {integrity: sha512-K1ZyO/BU8JNtXX5dmPp7b5UrkLMMqpsIa/Lrj5D3Su+j1Xwq1m6QJ2XJ1AgjEjkI1v4Muzm7klianLE6XGxtmA==} + '@axe-core/playwright@4.11.1': resolution: {integrity: sha512-mKEfoUIB1MkVTht0BGZFXtSAEKXMJoDkyV5YZ9jbBmZCcWDz71tegNsdTkIN8zc/yMi5Gm2kx7Z5YQ9PfWNAWw==} peerDependencies: @@ -5252,6 +5324,9 @@ packages: resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} engines: {node: '>=18'} + core-js@3.49.0: + resolution: {integrity: sha512-es1U2+YTtzpwkxVLwAFdSpaIMyQaq0PBgm3YD1W3Qpsn1NAmO3KSgZfu+oGSWVu6NvLHoHCV/aYcsE5wiB7ALg==} + cors@2.8.6: resolution: {integrity: sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==} engines: {node: '>= 0.10'} @@ -5950,6 +6025,10 @@ packages: resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} engines: {node: '>= 0.10'} + ipaddr.js@2.3.0: + resolution: {integrity: sha512-Zv/pA+ciVFbCSBBjGfaKUya/CcGmUHzTydLMaTwrUUEM2DIEO3iZvueGxmacvmN50fGpGVKeTXpb2LcYQxeVdg==} + engines: {node: '>= 10'} + iron-webcrypto@1.2.1: resolution: {integrity: sha512-feOM6FaSr6rEABp/eDfVseKyTMDt+KGpeB35SkVn9Tyn0CqvVsY3EwI0v5i8nMHyJnzCIQf7nsy3p41TPkJZhg==} @@ -6039,6 +6118,9 @@ packages: resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} hasBin: true + jose@5.10.0: + resolution: {integrity: sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==} + jose@6.1.3: resolution: {integrity: sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ==} @@ -7792,6 +7874,10 @@ packages: undici-types@7.24.6: resolution: {integrity: sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==} + undici@6.24.1: + resolution: {integrity: sha512-sC+b0tB1whOCzbtlx20fx3WgCXwkW627p4EA9uM+/tNNPkSS+eSEld6pAs9nDv7WbY1UUljBMYPtu9BCOrCWKA==} + engines: {node: '>=18.17'} + undici@7.18.2: resolution: {integrity: sha512-y+8YjDFzWdQlSE9N5nzKMT3g4a5UBX1HKowfdXh0uvAnTaqqwqB92Jt4UXBAeKekDs5IaDKyJFR4X1gYVCgXcw==} engines: {node: '>=20.18.1'} @@ -8547,7 +8633,7 @@ snapshots: dependencies: '@astrojs/internal-helpers': 0.8.0 '@astrojs/underscore-redirects': 1.0.3 - '@cloudflare/vite-plugin': 1.26.1(vite@7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2))(workerd@1.20260401.1)(wrangler@4.80.0) + '@cloudflare/vite-plugin': 1.26.1(vite@7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2))(workerd@1.20260401.1)(wrangler@4.80.0(@cloudflare/workers-types@4.20260305.1)) astro: 6.0.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(rollup@4.55.2)(tsx@4.21.0)(typescript@6.0.0-beta)(yaml@2.8.2) piccolore: 0.1.3 tinyglobby: 0.2.15 @@ -8573,7 +8659,7 @@ snapshots: dependencies: '@astrojs/internal-helpers': 0.8.0 '@astrojs/underscore-redirects': 1.0.3 - '@cloudflare/vite-plugin': 1.26.1(vite@7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2))(workerd@1.20260401.1)(wrangler@4.80.0) + '@cloudflare/vite-plugin': 1.26.1(vite@7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2))(workerd@1.20260401.1)(wrangler@4.80.0(@cloudflare/workers-types@4.20260305.1)) astro: 6.1.3(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(rollup@4.55.2)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2) piccolore: 0.1.3 tinyglobby: 0.2.15 @@ -8873,6 +8959,53 @@ snapshots: dependencies: yaml: 2.8.2 + '@atproto-labs/did-resolver@0.2.6': + dependencies: + '@atproto-labs/fetch': 0.2.3 + '@atproto-labs/pipe': 0.1.1 + '@atproto-labs/simple-store': 0.3.0 + '@atproto-labs/simple-store-memory': 0.1.4 + '@atproto/did': 0.3.0 + zod: 3.25.76 + + '@atproto-labs/fetch-node@0.2.0': + dependencies: + '@atproto-labs/fetch': 0.2.3 + '@atproto-labs/pipe': 0.1.1 + ipaddr.js: 2.3.0 + undici: 6.24.1 + + '@atproto-labs/fetch@0.2.3': + dependencies: + '@atproto-labs/pipe': 0.1.1 + + '@atproto-labs/handle-resolver-node@0.1.25': + dependencies: + '@atproto-labs/fetch-node': 0.2.0 + '@atproto-labs/handle-resolver': 0.3.6 + '@atproto/did': 0.3.0 + + '@atproto-labs/handle-resolver@0.3.6': + dependencies: + '@atproto-labs/simple-store': 0.3.0 + '@atproto-labs/simple-store-memory': 0.1.4 + '@atproto/did': 0.3.0 + zod: 3.25.76 + + '@atproto-labs/identity-resolver@0.3.6': + dependencies: + '@atproto-labs/did-resolver': 0.2.6 + '@atproto-labs/handle-resolver': 0.3.6 + + '@atproto-labs/pipe@0.1.1': {} + + '@atproto-labs/simple-store-memory@0.1.4': + dependencies: + '@atproto-labs/simple-store': 0.3.0 + lru-cache: 10.4.3 + + '@atproto-labs/simple-store@0.3.0': {} + '@atproto/api@0.13.35': dependencies: '@atproto/common-web': 0.4.12 @@ -8890,6 +9023,40 @@ snapshots: '@atproto/lex-json': 0.0.8 zod: 3.25.76 + '@atproto/common-web@0.4.19': + dependencies: + '@atproto/lex-data': 0.0.14 + '@atproto/lex-json': 0.0.14 + '@atproto/syntax': 0.5.3 + zod: 3.25.76 + + '@atproto/did@0.3.0': + dependencies: + zod: 3.25.76 + + '@atproto/jwk-jose@0.1.11': + dependencies: + '@atproto/jwk': 0.6.0 + jose: 5.10.0 + + '@atproto/jwk-webcrypto@0.2.0': + dependencies: + '@atproto/jwk': 0.6.0 + '@atproto/jwk-jose': 0.1.11 + zod: 3.25.76 + + '@atproto/jwk@0.6.0': + dependencies: + multiformats: 9.9.0 + zod: 3.25.76 + + '@atproto/lex-data@0.0.14': + dependencies: + multiformats: 9.9.0 + tslib: 2.8.1 + uint8arrays: 3.0.0 + unicode-segmenter: 0.14.5 + '@atproto/lex-data@0.0.8': dependencies: '@atproto/syntax': 0.4.2 @@ -8898,6 +9065,11 @@ snapshots: uint8arrays: 3.0.0 unicode-segmenter: 0.14.5 + '@atproto/lex-json@0.0.14': + dependencies: + '@atproto/lex-data': 0.0.14 + tslib: 2.8.1 + '@atproto/lex-json@0.0.8': dependencies: '@atproto/lex-data': 0.0.8 @@ -8911,15 +9083,66 @@ snapshots: multiformats: 9.9.0 zod: 3.25.76 + '@atproto/lexicon@0.6.2': + dependencies: + '@atproto/common-web': 0.4.19 + '@atproto/syntax': 0.5.3 + iso-datestring-validator: 2.2.2 + multiformats: 9.9.0 + zod: 3.25.76 + + '@atproto/oauth-client-node@0.3.17': + dependencies: + '@atproto-labs/did-resolver': 0.2.6 + '@atproto-labs/handle-resolver-node': 0.1.25 + '@atproto-labs/simple-store': 0.3.0 + '@atproto/did': 0.3.0 + '@atproto/jwk': 0.6.0 + '@atproto/jwk-jose': 0.1.11 + '@atproto/jwk-webcrypto': 0.2.0 + '@atproto/oauth-client': 0.6.0 + '@atproto/oauth-types': 0.6.3 + + '@atproto/oauth-client@0.6.0': + dependencies: + '@atproto-labs/did-resolver': 0.2.6 + '@atproto-labs/fetch': 0.2.3 + '@atproto-labs/handle-resolver': 0.3.6 + '@atproto-labs/identity-resolver': 0.3.6 + '@atproto-labs/simple-store': 0.3.0 + '@atproto-labs/simple-store-memory': 0.1.4 + '@atproto/did': 0.3.0 + '@atproto/jwk': 0.6.0 + '@atproto/oauth-types': 0.6.3 + '@atproto/xrpc': 0.7.7 + core-js: 3.49.0 + multiformats: 9.9.0 + zod: 3.25.76 + + '@atproto/oauth-types@0.6.3': + dependencies: + '@atproto/did': 0.3.0 + '@atproto/jwk': 0.6.0 + zod: 3.25.76 + '@atproto/syntax@0.3.4': {} '@atproto/syntax@0.4.2': {} + '@atproto/syntax@0.5.3': + dependencies: + tslib: 2.8.1 + '@atproto/xrpc@0.6.12': dependencies: '@atproto/lexicon': 0.4.14 zod: 3.25.76 + '@atproto/xrpc@0.7.7': + dependencies: + '@atproto/lexicon': 0.6.2 + zod: 3.25.76 + '@axe-core/playwright@4.11.1(playwright-core@1.58.2)': dependencies: axe-core: 4.11.1 @@ -9328,7 +9551,7 @@ snapshots: optionalDependencies: workerd: 1.20260401.1 - '@cloudflare/vite-plugin@1.26.1(vite@7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2))(workerd@1.20260401.1)(wrangler@4.80.0)': + '@cloudflare/vite-plugin@1.26.1(vite@7.3.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(tsx@4.21.0)(yaml@2.8.2))(workerd@1.20260401.1)(wrangler@4.80.0(@cloudflare/workers-types@4.20260305.1))': dependencies: '@cloudflare/unenv-preset': 2.15.0(unenv@2.0.0-rc.24)(workerd@1.20260401.1) miniflare: 4.20260301.1 @@ -12089,11 +12312,11 @@ snapshots: astro: 6.1.3(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(rollup@4.55.2)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2) rehype-expressive-code: 0.41.6 - astro-portabletext@0.11.4(astro@6.0.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(rollup@4.55.2)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2)): + astro-portabletext@0.11.4(astro@6.1.3(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(rollup@4.55.2)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2)): dependencies: '@portabletext/toolkit': 3.0.3 '@portabletext/types': 2.0.15 - astro: 6.0.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(rollup@4.55.2)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2) + astro: 6.1.3(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(rollup@4.55.2)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2) astro@6.0.0-beta.20(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(rollup@4.55.2)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2): dependencies: @@ -12684,6 +12907,8 @@ snapshots: cookie@1.1.1: {} + core-js@3.49.0: {} + cors@2.8.6: dependencies: object-assign: 4.1.1 @@ -13546,6 +13771,8 @@ snapshots: ipaddr.js@1.9.1: {} + ipaddr.js@2.3.0: {} + iron-webcrypto@1.2.1: {} is-alphabetical@2.0.1: {} @@ -13607,6 +13834,8 @@ snapshots: jiti@2.6.1: {} + jose@5.10.0: {} + jose@6.1.3: {} jpeg-js@0.4.4: {} @@ -15852,6 +16081,8 @@ snapshots: undici-types@7.24.6: optional: true + undici@6.24.1: {} + undici@7.18.2: {} undici@7.24.4: {} From fbad5541837d916677ce8d29900bd25370dac475 Mon Sep 17 00:00:00 2001 From: Jim Ray Date: Thu, 9 Apr 2026 15:44:51 -0400 Subject: [PATCH 2/6] feat(admin): add ATProto login UI and complete-profile page - LoginPage: handle input form with "Sign in via the Atmosphere" divider, conditioned on manifest.atprotoEnabled - Router: /complete-profile route (standalone, no Shell wrapper) - AdminManifest: atprotoEnabled field --- packages/admin/src/components/LoginPage.tsx | 83 +++++++++++++++++++++ packages/admin/src/lib/api/client.ts | 5 ++ packages/admin/src/router.tsx | 9 +++ 3 files changed, 97 insertions(+) diff --git a/packages/admin/src/components/LoginPage.tsx b/packages/admin/src/components/LoginPage.tsx index 6d028a71c..8379f599a 100644 --- a/packages/admin/src/components/LoginPage.tsx +++ b/packages/admin/src/components/LoginPage.tsx @@ -206,6 +206,71 @@ function MagicLinkForm({ onBack }: MagicLinkFormProps) { ); } +// ============================================================================ +// ATProto Login Form +// ============================================================================ + +function AtprotoLoginForm() { + const [handle, setHandle] = React.useState(""); + const [isLoading, setIsLoading] = React.useState(false); + const [error, setError] = React.useState(null); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setError(null); + setIsLoading(true); + + try { + const response = await apiFetch("/_emdash/api/auth/atproto/login", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ handle: handle.trim() }), + }); + + if (!response.ok) { + const body: { error?: { message?: string } } = await response.json().catch(() => ({})); + throw new Error(body?.error?.message || "Failed to start login"); + } + + const result: { data?: { redirectUrl?: string } } = await response.json(); + if (result.data?.redirectUrl) { + window.location.href = result.data.redirectUrl; + } + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to start login"); + setIsLoading(false); + } + }; + + return ( +
+
+ setHandle(e.target.value)} + placeholder="alice.bsky.social" + disabled={isLoading} + autoComplete="username" + className="flex-1" + /> + +
+ {error && ( +
{error}
+ )} +
+ ); +} + // ============================================================================ // Main Component // ============================================================================ @@ -321,6 +386,24 @@ export function LoginPage({ redirectUrl = "/_emdash/admin" }: LoginPageProps) { ))} + {/* ATProto Login */} + {manifest?.atprotoEnabled && ( + <> +
+
+ +
+
+ + Sign in via the Atmosphere + +
+
+ + + + )} + {/* Magic Link Option */} + + +

+ + Back to login + +

+ + + ); + } + + return ( +
+
+ {/* Header */} +
+ +

Almost there

+

+ Enter your email to complete sign-in. We'll send a verification link. +

+
+ + {/* Form */} +
+
+ setEmail(e.target.value)} + placeholder="you@example.com" + className={error ? "border-kumo-danger" : ""} + disabled={isLoading} + autoComplete="email" + autoFocus + required + /> + + {error && ( +
+ {error} +
+ )} + + +
+
+ + {/* Back link */} +

+ + Back to login + +

+
+
+ ); +} diff --git a/packages/core/src/astro/routes/api/auth/atproto/complete-profile.ts b/packages/core/src/astro/routes/api/auth/atproto/complete-profile.ts new file mode 100644 index 000000000..4037d3aad --- /dev/null +++ b/packages/core/src/astro/routes/api/auth/atproto/complete-profile.ts @@ -0,0 +1,132 @@ +/** + * POST /_emdash/api/auth/atproto/complete-profile + * + * Collects email when the PDS authorization server didn't return one. + * Sends a verification email instead of immediately creating the user. + * The user must click the link in the email to complete sign-in. + */ + +import { generateTokenWithHash } from "@emdash-cms/auth"; +import { createKyselyAdapter } from "@emdash-cms/auth/adapters/kysely"; +import type { APIRoute } from "astro"; + +import { apiError, handleError } from "#api/error.js"; + +export const prerender = false; + +const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; +const TOKEN_EXPIRY_MS = 15 * 60 * 1000; // 15 minutes + +export const POST: APIRoute = async ({ request, locals }) => { + const { emdash } = locals; + + if (!emdash?.db) { + return apiError("NOT_CONFIGURED", "EmDash is not initialized", 500); + } + + let email: string; + let state: string; + try { + const body = (await request.json()) as { email?: string; state?: string }; + email = typeof body.email === "string" ? body.email.trim().toLowerCase() : ""; + state = typeof body.state === "string" ? body.state.trim() : ""; + } catch { + return apiError("VALIDATION_ERROR", "Invalid request body", 400); + } + + if (!state) { + return apiError("VALIDATION_ERROR", "Missing state parameter", 400); + } + + if (!email || !EMAIL_RE.test(email)) { + return apiError("VALIDATION_ERROR", "Please enter a valid email address", 400); + } + + try { + // Retrieve pending ATProto state + const pending = await emdash.db + .selectFrom("auth_challenges") + .selectAll() + .where("challenge", "=", state) + .where("type", "=", "atproto_pending") + .executeTakeFirst(); + + if (!pending?.data) { + return apiError( + "INVALID_STATE", + "Session expired or invalid. Please try logging in again.", + 400, + ); + } + + // Check expiration + if (new Date(pending.expires_at).getTime() < Date.now()) { + await emdash.db.deleteFrom("auth_challenges").where("challenge", "=", state).execute(); + return apiError("EXPIRED", "Session expired. Please try logging in again.", 400); + } + + // Check if email pipeline is available + if (!emdash.email?.isAvailable()) { + return apiError( + "EMAIL_NOT_CONFIGURED", + "Email is not configured. Please contact an administrator.", + 500, + ); + } + + // Generate verification token + const { token, hash } = generateTokenWithHash(); + const adapter = createKyselyAdapter(emdash.db); + + await adapter.createToken({ + hash, + userId: null, // No user yet + email, + type: "email_verify", + expiresAt: new Date(Date.now() + TOKEN_EXPIRY_MS), + }); + + // Build verification URL with both the token and the atproto state + const url = new URL(request.url); + const verifyUrl = new URL("/_emdash/api/auth/atproto/verify-email", url.origin); + verifyUrl.searchParams.set("token", token); + verifyUrl.searchParams.set("state", state); + + // Log full verification URL in dev mode + if (import.meta.env.DEV) { + console.log(`[atproto] Verification URL: ${verifyUrl.toString()}`); + } + + // Send verification email + const siteName = "EmDash"; + await emdash.email.send( + { + to: email, + subject: `Verify your email for ${siteName}`, + text: `Click this link to verify your email and complete sign-in:\n\n${verifyUrl.toString()}\n\nThis link expires in 15 minutes.\n\nIf you didn't request this, you can safely ignore this email.`, + html: ` + + + + + + + +

Verify your email

+

Click the button below to verify your email and complete sign-in:

+

+ Verify email +

+

This link expires in 15 minutes.

+

If you didn't request this, you can safely ignore this email.

+ +`, + }, + "system", + ); + + return Response.json({ data: { emailSent: true } }); + } catch (error) { + return handleError(error, "Failed to send verification email", "COMPLETE_PROFILE_ERROR"); + } +}; diff --git a/packages/core/src/astro/routes/api/auth/atproto/verify-email.ts b/packages/core/src/astro/routes/api/auth/atproto/verify-email.ts new file mode 100644 index 000000000..555f6c330 --- /dev/null +++ b/packages/core/src/astro/routes/api/auth/atproto/verify-email.ts @@ -0,0 +1,211 @@ +/** + * GET /_emdash/api/auth/atproto/verify-email + * + * Handles the email verification link clicked from the verification email. + * Verifies the token, creates or links the user, and establishes a session. + */ + +import { hashToken } from "@emdash-cms/auth"; +import { createKyselyAdapter } from "@emdash-cms/auth/adapters/kysely"; +import type { APIRoute } from "astro"; +import { ulid } from "ulidx"; + +import { cleanupAtprotoEntries } from "#auth/atproto/client.js"; + +export const prerender = false; + +/** Create a redirect response with mutable headers */ +function redirectTo(url: string, status = 302): Response { + return new Response(null, { + status, + headers: { Location: url }, + }); +} + +export const GET: APIRoute = async ({ request, locals, session }) => { + const { emdash } = locals; + const url = new URL(request.url); + const token = url.searchParams.get("token"); + const state = url.searchParams.get("state"); + + if (!emdash?.db) { + return redirectTo( + `/_emdash/admin/login?error=server_error&message=${encodeURIComponent("Database not configured")}`, + ); + } + + if (!token || !state) { + return redirectTo( + `/_emdash/admin/login?error=invalid_link&message=${encodeURIComponent("Invalid or incomplete verification link")}`, + ); + } + + try { + const adapter = createKyselyAdapter(emdash.db); + + // Verify the email token + const hash = hashToken(token); + const authToken = await adapter.getToken(hash, "email_verify"); + + if (!authToken) { + return redirectTo( + `/_emdash/admin/login?error=invalid_token&message=${encodeURIComponent("Invalid or expired verification link")}`, + ); + } + + if (authToken.expiresAt < new Date()) { + await adapter.deleteToken(hash); + return redirectTo( + `/_emdash/admin/login?error=token_expired&message=${encodeURIComponent("Verification link has expired. Please try again.")}`, + ); + } + + const email = authToken.email; + if (!email) { + await adapter.deleteToken(hash); + return redirectTo( + `/_emdash/admin/login?error=invalid_token&message=${encodeURIComponent("Invalid verification token")}`, + ); + } + + // Delete token (single-use) + await adapter.deleteToken(hash); + + // Retrieve pending ATProto state + const pending = await emdash.db + .selectFrom("auth_challenges") + .selectAll() + .where("challenge", "=", state) + .where("type", "=", "atproto_pending") + .executeTakeFirst(); + + if (!pending?.data) { + return redirectTo( + `/_emdash/admin/login?error=invalid_state&message=${encodeURIComponent("ATProto session expired. Please try logging in again.")}`, + ); + } + + if (new Date(pending.expires_at).getTime() < Date.now()) { + await emdash.db.deleteFrom("auth_challenges").where("challenge", "=", state).execute(); + return redirectTo( + `/_emdash/admin/login?error=expired&message=${encodeURIComponent("ATProto session expired. Please try logging in again.")}`, + ); + } + + const { did, handle } = JSON.parse(pending.data) as { + did: string; + handle?: string; + }; + + // === User provisioning (same logic as before, but email is now verified) === + + // Check if DID already linked (race condition guard) + const existingByDid = await emdash.db + .selectFrom("oauth_accounts") + .selectAll() + .where("provider", "=", "atproto") + .where("provider_account_id", "=", did) + .executeTakeFirst(); + + if (existingByDid) { + if (session) { + session.set("user", { id: existingByDid.user_id }); + } + await emdash.db.deleteFrom("auth_challenges").where("challenge", "=", state).execute(); + await cleanupAtprotoEntries(emdash.db); + return redirectTo("/_emdash/admin"); + } + + // Check if user with this email exists — link DID + const existingUser = await emdash.db + .selectFrom("users") + .selectAll() + .where("email", "=", email) + .executeTakeFirst(); + + if (existingUser) { + if (existingUser.disabled) { + return redirectTo( + `/_emdash/admin/login?error=account_disabled&message=${encodeURIComponent("Your account has been disabled")}`, + ); + } + + await emdash.db + .updateTable("users") + .set({ atproto_did: did }) + .where("id", "=", existingUser.id) + .execute(); + + await emdash.db + .insertInto("oauth_accounts") + .values({ + provider: "atproto", + provider_account_id: did, + user_id: existingUser.id, + }) + .execute(); + + if (session) { + session.set("user", { id: existingUser.id }); + } + + await emdash.db.deleteFrom("auth_challenges").where("challenge", "=", state).execute(); + await cleanupAtprotoEntries(emdash.db); + return redirectTo("/_emdash/admin"); + } + + // Check allowed_domains for self-signup + const domain = email.split("@")[1]?.toLowerCase(); + if (domain) { + const allowedDomain = await emdash.db + .selectFrom("allowed_domains") + .selectAll() + .where("domain", "=", domain) + .where("enabled", "=", 1) + .executeTakeFirst(); + + if (allowedDomain) { + const userId = ulid(); + await emdash.db + .insertInto("users") + .values({ + id: userId, + email, + name: handle || null, + avatar_url: null, + role: allowedDomain.default_role, + email_verified: 1, // Verified by clicking the email link + data: null, + atproto_did: did, + }) + .execute(); + + await emdash.db + .insertInto("oauth_accounts") + .values({ + provider: "atproto", + provider_account_id: did, + user_id: userId, + }) + .execute(); + + if (session) { + session.set("user", { id: userId }); + } + + await emdash.db.deleteFrom("auth_challenges").where("challenge", "=", state).execute(); + await cleanupAtprotoEntries(emdash.db); + return redirectTo("/_emdash/admin"); + } + } + + return redirectTo( + `/_emdash/admin/login?error=signup_not_allowed&message=${encodeURIComponent("Self-signup is not allowed for your email domain. Please contact an administrator.")}`, + ); + } catch (error) { + console.error("[VERIFY_EMAIL_ERROR]", error instanceof Error ? error.message : error); + return redirectTo( + `/_emdash/admin/login?error=verify_error&message=${encodeURIComponent("Verification failed. Please try again.")}`, + ); + } +}; From f1378ef423ebda82e02926c5b2910176ef7d6f55 Mon Sep 17 00:00:00 2001 From: Jim Ray Date: Thu, 9 Apr 2026 15:45:06 -0400 Subject: [PATCH 4/6] test(auth): add tests for ATProto state and session store adapters Covers get/set/del, conflict handling, TTL expiration, and cross-store isolation (state store doesn't see session entries and vice versa). --- .../tests/unit/auth/atproto-stores.test.ts | 190 ++++++++++++++++++ 1 file changed, 190 insertions(+) create mode 100644 packages/core/tests/unit/auth/atproto-stores.test.ts diff --git a/packages/core/tests/unit/auth/atproto-stores.test.ts b/packages/core/tests/unit/auth/atproto-stores.test.ts new file mode 100644 index 000000000..1b8376fd3 --- /dev/null +++ b/packages/core/tests/unit/auth/atproto-stores.test.ts @@ -0,0 +1,190 @@ +import type { Kysely } from "kysely"; +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; + +import { + createAtprotoStateStore, + createAtprotoSessionStore, +} from "../../../src/auth/atproto/stores.js"; +import type { Database } from "../../../src/database/types.js"; +import { setupTestDatabase } from "../../utils/test-db.js"; + +// Minimal mock data matching the SDK's NodeSavedState shape +const mockState = { + iss: "https://bsky.social", + dpopJwk: { kty: "EC", crv: "P-256", x: "test", y: "test" }, + authMethod: "none" as const, + verifier: "test-pkce-verifier", + appState: "test-app-state", +}; + +// Minimal mock data matching the SDK's NodeSavedSession shape +const mockSession = { + dpopJwk: { kty: "EC", crv: "P-256", x: "test", y: "test" }, + authMethod: "none" as const, + tokenSet: { + iss: "https://bsky.social", + sub: "did:plc:test123" as `did:${string}`, + aud: "https://bsky.social", + scope: "atproto", + access_token: "test-access-token", + token_type: "DPoP" as const, + }, +}; + +describe("ATProto State Store", () => { + let db: Kysely; + + beforeEach(async () => { + db = await setupTestDatabase(); + }); + + afterEach(async () => { + await db.destroy(); + }); + + it("stores and retrieves state", async () => { + const store = createAtprotoStateStore(db); + + await store.set("state-key-1", mockState); + const result = await store.get("state-key-1"); + + expect(result).toEqual(mockState); + }); + + it("returns undefined for non-existent key", async () => { + const store = createAtprotoStateStore(db); + + const result = await store.get("does-not-exist"); + expect(result).toBeUndefined(); + }); + + it("deletes state", async () => { + const store = createAtprotoStateStore(db); + + await store.set("to-delete", mockState); + await store.del("to-delete"); + + const result = await store.get("to-delete"); + expect(result).toBeUndefined(); + }); + + it("overwrites existing state on conflict", async () => { + const store = createAtprotoStateStore(db); + + await store.set("overwrite-key", mockState); + + const updated = { ...mockState, verifier: "updated-verifier" }; + await store.set("overwrite-key", updated); + + const result = await store.get("overwrite-key"); + expect(result?.verifier).toBe("updated-verifier"); + }); + + it("returns undefined for expired state", async () => { + vi.useFakeTimers(); + const store = createAtprotoStateStore(db); + + await store.set("expiring-key", mockState); + + // Advance past the 10-minute TTL + vi.advanceTimersByTime(11 * 60 * 1000); + + const result = await store.get("expiring-key"); + expect(result).toBeUndefined(); + + vi.useRealTimers(); + }); + + it("does not return session store entries", async () => { + const stateStore = createAtprotoStateStore(db); + const sessionStore = createAtprotoSessionStore(db); + + await sessionStore.set("shared-key", mockSession); + + const result = await stateStore.get("shared-key"); + expect(result).toBeUndefined(); + }); + + it("does not throw when deleting non-existent key", async () => { + const store = createAtprotoStateStore(db); + await expect(store.del("non-existent")).resolves.not.toThrow(); + }); +}); + +describe("ATProto Session Store", () => { + let db: Kysely; + + beforeEach(async () => { + db = await setupTestDatabase(); + }); + + afterEach(async () => { + await db.destroy(); + }); + + it("stores and retrieves session", async () => { + const store = createAtprotoSessionStore(db); + + await store.set("did:plc:test123", mockSession); + const result = await store.get("did:plc:test123"); + + expect(result).toEqual(mockSession); + }); + + it("returns undefined for non-existent key", async () => { + const store = createAtprotoSessionStore(db); + + const result = await store.get("did:plc:nonexistent"); + expect(result).toBeUndefined(); + }); + + it("deletes session", async () => { + const store = createAtprotoSessionStore(db); + + await store.set("did:plc:to-delete", mockSession); + await store.del("did:plc:to-delete"); + + const result = await store.get("did:plc:to-delete"); + expect(result).toBeUndefined(); + }); + + it("overwrites existing session on conflict", async () => { + const store = createAtprotoSessionStore(db); + + await store.set("did:plc:overwrite", mockSession); + + const updated = { + ...mockSession, + tokenSet: { ...mockSession.tokenSet, access_token: "new-token" }, + }; + await store.set("did:plc:overwrite", updated); + + const result = await store.get("did:plc:overwrite"); + expect(result?.tokenSet.access_token).toBe("new-token"); + }); + + it("returns undefined for expired session", async () => { + vi.useFakeTimers(); + const store = createAtprotoSessionStore(db); + + await store.set("did:plc:expiring", mockSession); + + // Advance past the 1-hour TTL + vi.advanceTimersByTime(61 * 60 * 1000); + + const result = await store.get("did:plc:expiring"); + expect(result).toBeUndefined(); + + vi.useRealTimers(); + }); + + it("does not return state store entries", async () => { + const stateStore = createAtprotoStateStore(db); + const sessionStore = createAtprotoSessionStore(db); + + await stateStore.set("shared-key", mockState); + + const result = await sessionStore.get("shared-key"); + expect(result).toBeUndefined(); + }); +}); From 9517212df085c16db26f0fb2f1705d8cb3923a25 Mon Sep 17 00:00:00 2001 From: Jim Ray Date: Thu, 9 Apr 2026 15:49:46 -0400 Subject: [PATCH 5/6] chore: add changeset for atproto auth --- .changeset/itchy-walls-smell.md | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 .changeset/itchy-walls-smell.md diff --git a/.changeset/itchy-walls-smell.md b/.changeset/itchy-walls-smell.md new file mode 100644 index 000000000..865fa0888 --- /dev/null +++ b/.changeset/itchy-walls-smell.md @@ -0,0 +1,6 @@ +--- +"@emdash-cms/admin": minor +"emdash": minor +--- + +Adds AT Protocol OAuth login for signing into the CMS with an Atmosphere handle From ae627d0d3fb4f27eef4c209612aa0942beb79137 Mon Sep 17 00:00:00 2001 From: Jim Ray Date: Thu, 9 Apr 2026 15:55:18 -0400 Subject: [PATCH 6/6] docs: add ATProto OAuth login documentation Adds ATProto login section to the Authentication guide covering setup, email verification flow, access control, and identity storage. Adds atproto config option to the Configuration reference. --- .../content/docs/guides/authentication.mdx | 58 +++++++++++++++++++ .../content/docs/reference/configuration.mdx | 16 +++++ 2 files changed, 74 insertions(+) diff --git a/docs/src/content/docs/guides/authentication.mdx b/docs/src/content/docs/guides/authentication.mdx index 12b77e4fe..50255c236 100644 --- a/docs/src/content/docs/guides/authentication.mdx +++ b/docs/src/content/docs/guides/authentication.mdx @@ -93,6 +93,64 @@ EmDash supports OAuth login with GitHub and Google when configured. Users can li See the [Configuration guide](/reference/configuration#oauth) for setup instructions. +## ATProto Login + +EmDash supports login via the [AT Protocol](https://atproto.com/) (the protocol behind Bluesky). Users can sign in with any AT Protocol handle — whether on `bsky.social` or a self-hosted PDS. + +### How It Works + + + +1. Enable ATProto in your config: + + ```js title="astro.config.mjs" + import emdash from "emdash/astro"; + + emdash({ + atproto: true, + }) + ``` + +2. The login page shows a "Sign in via the Atmosphere" section with a handle input + +3. The user enters their handle (e.g., `alice.bsky.social`) and clicks **Sign in** + +4. They're redirected to their PDS to authorize the login + +5. After authorization, they're redirected back to EmDash + + + +### Email Verification + +ATProto doesn't provide email addresses during OAuth. After authorizing on their PDS, users are asked to enter an email address and verify it via a confirmation link. This email is used for: + +- Matching with existing EmDash accounts +- Checking against [allowed domains](#self-signup) for self-signup +- Account communication (invites, magic links) + + + +### Access Control + +ATProto login follows the same access rules as GitHub/Google OAuth: + +- **Existing users** — If the email matches an existing user, the ATProto identity is linked to that account +- **Allowed domains** — New users can sign up if their email domain is in the allowed domains list (with the domain's default role) +- **No match** — Users without a matching account or allowed domain are rejected + + + +### Identity Storage + +When a user signs in via ATProto, their DID (Decentralized Identifier) is stored on their user profile. This is separate from the existing [ATProto plugin](/plugins/overview) which handles content syndication to Bluesky using app passwords. + ## User Roles EmDash uses role-based access control with five levels: diff --git a/docs/src/content/docs/reference/configuration.mdx b/docs/src/content/docs/reference/configuration.mdx index e1d537604..19a3ad0e9 100644 --- a/docs/src/content/docs/reference/configuration.mdx +++ b/docs/src/content/docs/reference/configuration.mdx @@ -173,6 +173,22 @@ oauth: { } ``` +### `atproto` + +**Optional.** Enable AT Protocol OAuth login. When `true`, the login page shows a handle input for signing in with a Bluesky handle or any PDS-hosted identity. + +```js +emdash({ + atproto: true, +}) +``` + +ATProto login is additive — it works alongside passkeys, OAuth, and magic links. It does not replace or disable any existing auth method. + +Users who sign in via ATProto must verify their email address before their account is created. Access is gated by the same [allowed domains](#authselfsignup) rules as other auth methods. + +See the [Authentication guide](/guides/authentication#atproto-login) for details. + #### `auth.session` Session configuration.