Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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 } 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 { createAdminRouter } from "./router";

Expand All @@ -36,6 +37,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 @@ -46,9 +49,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 @@ -66,11 +71,13 @@ export function AdminApp({
<ThemeProvider>
<I18nProvider i18n={i18n}>
<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>
</I18nProvider>
</ThemeProvider>
Expand Down
168 changes: 71 additions & 97 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,8 +203,11 @@ 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="text-center">
Expand All @@ -280,12 +225,15 @@ export function LoginPage({ redirectUrl = "/_emdash/admin" }: LoginPageProps) {
<div className="text-center mb-8">
<LogoLockup 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 @@ -294,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 @@ -314,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="ml-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 @@ -342,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)}
>
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