Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
bb7a760
feat: pluggable auth providers — add AT Protocol, refactor GitHub/Google
simnaut Apr 9, 2026
18f2f20
fix: address PR review — TOCTOU race, ensureTable memoization
simnaut Apr 9, 2026
6664e0e
style: format
emdashbot[bot] Apr 9, 2026
5f8b24e
fix: address PR review — split auth-atproto package, add provider sto…
simnaut Apr 10, 2026
468e85c
Merge branch 'main' into claude/atproto-pds-auth-setup-LWtQo
simnaut Apr 11, 2026
73daa24
fix: CI failures — typecheck, lint, bundle, and i18n catalog
simnaut Apr 11, 2026
41d7500
Merge branch 'main' of upstream into claude/atproto-pds-auth-setup-LWtQo
simnaut Apr 11, 2026
7a66314
fix: update tests and plugin config for pluggable auth changes
simnaut Apr 11, 2026
27c0597
Merge branch 'main' into claude/atproto-pds-auth-setup-LWtQo
simnaut Apr 11, 2026
d171168
Merge branch 'main' into claude/atproto-pds-auth-setup-LWtQo
simnaut Apr 11, 2026
666642e
Merge branch 'main' into claude/atproto-pds-auth-setup-LWtQo
simnaut Apr 11, 2026
bd11cd3
Merge branch 'main' into claude/atproto-pds-auth-setup-LWtQo
simnaut Apr 11, 2026
6306fe6
fix(i18n): wrap missing "Back to login" string with Lingui t tag
simnaut Apr 11, 2026
a45beee
Merge branch 'main' into claude/atproto-pds-auth-setup-LWtQo
ascorbic Apr 12, 2026
33372b6
Merge branch 'main' into claude/atproto-pds-auth-setup-LWtQo
simnaut Apr 12, 2026
cdcc225
fix: address PR review — remove singleton, use getPublicOrigin
simnaut Apr 12, 2026
49d3bb0
fix(test): add missing virtual:emdash/config mock in mcp-discovery-po…
simnaut Apr 12, 2026
517d916
Merge remote-tracking branch 'upstream/main' into claude/atproto-pds-…
simnaut Apr 12, 2026
ad7b254
Merge upstream/main — resolve i18n conflicts in SetupWizard and runtime
simnaut Apr 14, 2026
910410a
fix(auth-atproto): suppress no-unsafe-type-assertion lint warnings
simnaut Apr 14, 2026
9de9bfc
Merge branch 'main' into claude/atproto-pds-auth-setup-LWtQo
ascorbic Apr 14, 2026
2d5b305
style: format
emdashbot[bot] Apr 14, 2026
5d26efb
fix: address PR review — use Kumo components, rename label to Atmosphere
simnaut Apr 15, 2026
995bd46
Merge branch 'main' into claude/atproto-pds-auth-setup-LWtQo
ascorbic Apr 16, 2026
c418088
Merge remote-tracking branch 'upstream/main' into claude/atproto-pds-…
simnaut Apr 22, 2026
57a2287
Merge remote-tracking branch 'upstream/main' into claude/atproto-pds-…
simnaut Apr 22, 2026
4faa43b
fix(auth-atproto): use Button not LinkButton for LoginButton
simnaut Apr 22, 2026
97c4081
Merge remote-tracking branch 'upstream/main' into claude/atproto-pds-…
simnaut Apr 25, 2026
6687606
fix(auth): restore findOrCreateOAuthUser export after upstream merge
simnaut Apr 25, 2026
2ec5dce
Merge branch 'main' into claude/atproto-pds-auth-setup-LWtQo
simnaut Apr 27, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .changeset/stale-knives-fix.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
"emdash": minor
"@emdash-cms/admin": minor
"@emdash-cms/auth-atproto": minor
"@emdash-cms/auth": patch
---

Adds pluggable auth provider system with AT Protocol as the first plugin-based provider. Refactors GitHub and Google OAuth from hardcoded buttons into the same `AuthProviderDescriptor` interface. All auth methods (passkey, AT Protocol, GitHub, Google) are equal options on the login page and setup wizard.
4 changes: 4 additions & 0 deletions demos/simple/astro.config.mjs
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
import node from "@astrojs/node";
import react from "@astrojs/react";
import { atproto } from "@emdash-cms/auth-atproto";
import { auditLogPlugin } from "@emdash-cms/plugin-audit-log";
import { defineConfig, fontProviders } from "astro/config";
import emdash, { local } from "emdash/astro";
import { github } from "emdash/auth/providers/github";
import { google } from "emdash/auth/providers/google";
import { sqlite } from "emdash/db";

export default defineConfig({
Expand All @@ -29,6 +32,7 @@ export default defineConfig({
directory: "./uploads",
baseUrl: "/_emdash/api/media/file",
}),
authProviders: [github(), google(), atproto()],
plugins: [auditLogPlugin()],
// HTTPS reverse proxy: uncomment so all origin-dependent features match browser
// siteUrl: "https://emdash.local:8443",
Expand Down
2 changes: 2 additions & 0 deletions demos/simple/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@
"dependencies": {
"@astrojs/node": "catalog:",
"@astrojs/react": "catalog:",
"@emdash-cms/auth-atproto": "workspace:*",
"@emdash-cms/plugin-atproto": "workspace:*",
"@emdash-cms/plugin-audit-log": "workspace:*",
"@emdash-cms/plugin-color": "workspace:*",
"astro": "catalog:",
Expand Down
4 changes: 2 additions & 2 deletions e2e/tests/passkey-full-setup-virtual-auth.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,12 +60,12 @@ test.describe("Setup wizard passkey with virtual authenticator (localhost)", ()
await page.getByLabel("Your Name").fill("Virtual Auth User");
await page.getByRole("button", { name: "Continue" }).click();

await expect(page.locator("text=Set up your passkey")).toBeVisible();
await expect(page.locator("text=Choose how to sign in")).toBeVisible();
await page.getByRole("button", { name: "Create Passkey" }).click();

// admin-verify creates the user but does not set a session; wizard sends user to /_emdash/admin and auth redirects to login.
await expect(page).toHaveURL(ADMIN_AFTER_SETUP_URL, { timeout: 60_000 });
await expect(page.locator("text=Set up your passkey")).toHaveCount(0);
await expect(page.locator("text=Choose how to sign in")).toHaveCount(0);
await expect(page.locator("text=Registration was cancelled or timed out")).toHaveCount(0);
await expect(page.locator("text=Invalid origin")).toHaveCount(0);
} finally {
Expand Down
2 changes: 1 addition & 1 deletion e2e/tests/setup-wizard.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ test.describe("Setup Wizard", () => {
await admin.page.getByRole("button", { name: "Continue" }).click();

await expect(admin.page.locator("text=Secure your account")).toBeVisible();
await expect(admin.page.locator("text=Set up your passkey")).toBeVisible();
await expect(admin.page.locator("text=Choose how to sign in")).toBeVisible();
});

test("setup wizard not accessible after setup complete", async ({ admin }) => {
Expand Down
17 changes: 12 additions & 5 deletions packages/admin/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { RouterProvider } from "@tanstack/react-router";
import * as React from "react";

import { ThemeProvider } from "./components/ThemeProvider";
import { AuthProviderProvider, type AuthProviders } from "./lib/auth-provider-context";
import { PluginAdminProvider, type PluginAdmins } from "./lib/plugin-context";
import { LocaleDirectionProvider } from "./locales/index.js";
import { createAdminRouter } from "./router";
Expand All @@ -37,6 +38,8 @@ const router = createAdminRouter(queryClient);
export interface AdminAppProps {
/** Plugin admin modules keyed by plugin ID */
pluginAdmins?: PluginAdmins;
/** Auth provider UI modules keyed by provider ID */
authProviders?: AuthProviders;
/** Active locale code */
locale?: string;
/** Compiled Lingui messages for the active locale */
Expand All @@ -47,9 +50,11 @@ export interface AdminAppProps {
* Main Admin Application
*/
const EMPTY_PLUGINS: PluginAdmins = {};
const EMPTY_AUTH_PROVIDERS: AuthProviders = {};

export function AdminApp({
pluginAdmins = EMPTY_PLUGINS,
authProviders = EMPTY_AUTH_PROVIDERS,
locale = "en",
messages = {},
}: AdminAppProps) {
Expand All @@ -68,11 +73,13 @@ export function AdminApp({
<I18nProvider i18n={i18n}>
<LocaleDirectionProvider>
<Toasty>
<PluginAdminProvider pluginAdmins={pluginAdmins}>
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} />
</QueryClientProvider>
</PluginAdminProvider>
<AuthProviderProvider authProviders={authProviders}>
<PluginAdminProvider pluginAdmins={pluginAdmins}>
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} />
</QueryClientProvider>
</PluginAdminProvider>
</AuthProviderProvider>
</Toasty>
</LocaleDirectionProvider>
</I18nProvider>
Expand Down
180 changes: 73 additions & 107 deletions packages/admin/src/components/LoginPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@
* It's a standalone page for authentication.
*
* Supports:
* - Passkey authentication (primary)
* - OAuth (GitHub, Google) when configured
* - Passkey authentication (always available)
* - Pluggable auth providers (AT Protocol, GitHub, Google, etc.) when configured
* - Magic link (email) when configured
*
* When external auth (e.g., Cloudflare Access) is configured, this page
Expand All @@ -19,7 +19,8 @@ import { useQuery } from "@tanstack/react-query";
import { Link } from "@tanstack/react-router";
import * as React from "react";

import { apiFetch, fetchManifest } from "../lib/api";
import { apiFetch, fetchAuthMode } from "../lib/api";
import { useAuthProviderList } from "../lib/auth-provider-context";
import { sanitizeRedirectUrl } from "../lib/url";
import { SUPPORTED_LOCALES } from "../locales/index.js";
import { useLocale } from "../locales/useLocale.js";
Expand All @@ -37,64 +38,6 @@ interface LoginPageProps {

type LoginMethod = "passkey" | "magic-link";

interface OAuthProvider {
id: string;
name: string;
icon: React.ReactNode;
}

// ============================================================================
// OAuth Icons
// ============================================================================

function GitHubIcon({ className }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 24 24" fill="currentColor">
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z" />
</svg>
);
}

function GoogleIcon({ className }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 24 24">
<path
fill="#4285F4"
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
/>
<path
fill="#34A853"
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
/>
<path
fill="#FBBC05"
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
/>
<path
fill="#EA4335"
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
/>
</svg>
);
}

// ============================================================================
// OAuth Providers
// ============================================================================

const OAUTH_PROVIDERS: OAuthProvider[] = [
{
id: "github",
name: "GitHub",
icon: <GitHubIcon className="h-5 w-5" />,
},
{
id: "google",
name: "Google",
icon: <GoogleIcon className="h-5 w-5" />,
},
];

// ============================================================================
// Components
// ============================================================================
Expand Down Expand Up @@ -217,33 +160,32 @@ function MagicLinkForm({ onBack }: MagicLinkFormProps) {
// Main Component
// ============================================================================

function handleOAuthClick(providerId: string) {
// Redirect to OAuth endpoint
window.location.href = `/_emdash/api/auth/oauth/${providerId}`;
}

export function LoginPage({ redirectUrl = "/_emdash/admin" }: LoginPageProps) {
// Defense-in-depth: sanitize even if the caller already validated
const safeRedirectUrl = sanitizeRedirectUrl(redirectUrl);
const { t } = useLingui();
const { locale, setLocale } = useLocale();
const [method, setMethod] = React.useState<LoginMethod>("passkey");
const [urlError, setUrlError] = React.useState<string | null>(null);
const [activeProvider, setActiveProvider] = React.useState<string | null>(null);

// Fetch manifest to check auth mode
const { data: manifest, isLoading: manifestLoading } = useQuery({
queryKey: ["manifest"],
queryFn: fetchManifest,
// Auth provider components from virtual module (via context)
const authProviderList = useAuthProviderList();

// Fetch auth mode from public endpoint (works without authentication)
const { data: authInfo, isLoading: authModeLoading } = useQuery({
queryKey: ["authMode"],
queryFn: fetchAuthMode,
});

// Redirect to admin when using external auth (authentication is handled externally)
React.useEffect(() => {
if (manifest?.authMode && manifest.authMode !== "passkey") {
if (authInfo?.authMode && authInfo.authMode !== "passkey") {
window.location.href = safeRedirectUrl;
}
}, [manifest, safeRedirectUrl]);
}, [authInfo, safeRedirectUrl]);

// Check for error in URL (from OAuth redirect)
// Check for error in URL (from OAuth/provider redirect)
React.useEffect(() => {
const params = new URLSearchParams(window.location.search);
const error = params.get("error");
Expand All @@ -261,16 +203,15 @@ export function LoginPage({ redirectUrl = "/_emdash/admin" }: LoginPageProps) {
window.location.href = safeRedirectUrl;
};

// All providers with a LoginButton show in the button grid
const buttonProviders = authProviderList.filter((p) => p.LoginButton);

// Show loading state while checking auth mode
if (manifestLoading || (manifest?.authMode && manifest.authMode !== "passkey")) {
if (authModeLoading || (authInfo?.authMode && authInfo.authMode !== "passkey")) {
return (
<div className="min-h-screen flex items-center justify-center bg-kumo-base p-4">
<div className="flex flex-col items-center">
<BrandLogo
logoUrl={manifest?.admin?.logo}
siteName={manifest?.admin?.siteName}
className="h-10 mb-4"
/>
<BrandLogo className="h-10 mb-4" />
<Loader />
</div>
</div>
Expand All @@ -282,18 +223,17 @@ export function LoginPage({ redirectUrl = "/_emdash/admin" }: LoginPageProps) {
<div className="w-full max-w-md">
{/* Header */}
<div className="text-center mb-8">
<BrandLogo
logoUrl={manifest?.admin?.logo}
siteName={manifest?.admin?.siteName}
className="h-10 mx-auto mb-2"
/>
<BrandLogo className="h-10 mx-auto mb-2" />
<h1 className="text-2xl font-semibold text-kumo-default">
{method === "passkey" && t`Sign in to your site`}
{method === "magic-link" && t`Sign in with email`}
{method === "magic-link"
? t`Sign in with email`
: activeProvider
? t`Sign in with ${authProviderList.find((p) => p.id === activeProvider)?.label ?? activeProvider}`
: t`Sign in to your site`}
</h1>
</div>

{/* Error from URL (OAuth failure) */}
{/* Error from URL (provider failure) */}
{urlError && (
<div className="mb-6 rounded-lg bg-kumo-danger/10 border border-kumo-danger/20 p-4 text-sm text-kumo-danger">
{urlError}
Expand All @@ -302,7 +242,7 @@ export function LoginPage({ redirectUrl = "/_emdash/admin" }: LoginPageProps) {

{/* Login Card */}
<div className="bg-kumo-base border rounded-lg shadow-sm p-6">
{method === "passkey" && (
{method === "passkey" && !activeProvider && (
<div className="space-y-6">
{/* Passkey Login */}
<PasskeyLogin
Expand All @@ -322,21 +262,23 @@ export function LoginPage({ redirectUrl = "/_emdash/admin" }: LoginPageProps) {
</div>
</div>

{/* OAuth Providers */}
<div className="grid grid-cols-2 gap-3">
{OAUTH_PROVIDERS.map((provider) => (
<Button
key={provider.id}
variant="outline"
type="button"
onClick={() => handleOAuthClick(provider.id)}
className="w-full justify-center"
>
{provider.icon}
<span className="ms-2">{provider.name}</span>
</Button>
))}
</div>
{/* Auth provider buttons */}
{buttonProviders.length > 0 && (
<div
className={`grid gap-3 ${buttonProviders.length === 1 ? "grid-cols-1" : "grid-cols-2"}`}
>
{buttonProviders.map((provider) => {
const Btn = provider.LoginButton!;
const hasForm = !!provider.LoginForm;
const selectProvider = () => setActiveProvider(provider.id);
return (
<div key={provider.id} onClick={hasForm ? selectProvider : undefined}>
<Btn />
</div>
);
})}
</div>
)}

{/* Magic Link Option */}
<Button
Expand All @@ -350,18 +292,42 @@ export function LoginPage({ redirectUrl = "/_emdash/admin" }: LoginPageProps) {
</div>
)}

{/* Provider form (full card replacement, like magic link) */}
{method === "passkey" &&
activeProvider &&
(() => {
const provider = authProviderList.find((p) => p.id === activeProvider);
if (!provider?.LoginForm) return null;
const Form = provider.LoginForm;
return (
<div className="space-y-4">
<Form />
<Button
type="button"
variant="ghost"
className="w-full justify-center"
onClick={() => setActiveProvider(null)}
>
{t`Back to login`}
</Button>
</div>
);
})()}

{method === "magic-link" && <MagicLinkForm onBack={() => setMethod("passkey")} />}
</div>

{/* Help text */}
<p className="text-center mt-6 text-sm text-kumo-subtle">
{method === "passkey"
? t`Use your registered passkey to sign in securely.`
: t`We'll send you a link to sign in without a password.`}
{method === "magic-link"
? t`We'll send you a link to sign in without a password.`
: activeProvider
? t`Enter your handle to sign in.`
: t`Use your registered passkey to sign in securely.`}
</p>

{/* Signup link — only shown when self-signup is enabled */}
{manifest?.signupEnabled && (
{authInfo?.signupEnabled && (
<p className="text-center mt-4 text-sm text-kumo-subtle">
<Trans>
Don't have an account?{" "}
Expand Down
Loading
Loading