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
6 changes: 6 additions & 0 deletions .changeset/itchy-walls-smell.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@emdash-cms/admin": minor
"emdash": minor
---

Adds AT Protocol OAuth login for signing into the CMS with an Atmosphere handle
58 changes: 58 additions & 0 deletions docs/src/content/docs/guides/authentication.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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

<Steps>

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

</Steps>

### 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)

<Aside>
In development, verification emails are logged to the server console instead of being sent.
Look for `[atproto] Verification URL:` in the logs.
</Aside>

### 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

<Aside type="caution">
ATProto login doesn't bypass your access controls. An admin must either invite the user,
add their email domain to allowed domains, or create their account before they can sign in.
</Aside>

### 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:
Expand Down
16 changes: 16 additions & 0 deletions docs/src/content/docs/reference/configuration.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
83 changes: 83 additions & 0 deletions packages/admin/src/components/LoginPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string | null>(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 (
<form onSubmit={handleSubmit} className="space-y-3">
<div className="flex gap-2">
<Input
type="text"
value={handle}
onChange={(e) => setHandle(e.target.value)}
placeholder="alice.bsky.social"
disabled={isLoading}
autoComplete="username"
className="flex-1"
/>
<Button
type="submit"
variant="outline"
loading={isLoading}
disabled={!handle.trim() || !handle.includes(".")}
className="shrink-0"
>
Sign in
</Button>
</div>
{error && (
<div className="rounded-lg bg-kumo-danger/10 p-3 text-sm text-kumo-danger">{error}</div>
)}
</form>
);
}

// ============================================================================
// Main Component
// ============================================================================
Expand Down Expand Up @@ -321,6 +386,24 @@ export function LoginPage({ redirectUrl = "/_emdash/admin" }: LoginPageProps) {
))}
</div>

{/* ATProto Login */}
{manifest?.atprotoEnabled && (
<>
<div className="relative">
<div className="absolute inset-0 flex items-center">
<span className="w-full border-t" />
</div>
<div className="relative flex justify-center text-xs uppercase">
<span className="bg-kumo-base px-2 text-kumo-subtle">
Sign in via the Atmosphere
</span>
</div>
</div>

<AtprotoLoginForm />
</>
)}

{/* Magic Link Option */}
<Button
variant="ghost"
Expand Down
186 changes: 186 additions & 0 deletions packages/admin/src/components/auth/CompleteProfilePage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
/**
* Complete Profile Page - Collects email when ATProto PDS doesn't return one
*
* Standalone page (not wrapped in admin Shell), like LoginPage and SignupPage.
* Shown after ATProto OAuth callback when the PDS didn't provide an email.
*
* Sends a verification email — the user must click the link to complete sign-in.
*/

import { Button, Input } from "@cloudflare/kumo";
import * as React from "react";

import { apiFetch } from "../../lib/api";
import { LogoLockup } from "../Logo.js";

export function CompleteProfilePage() {
const [email, setEmail] = React.useState("");
const [isLoading, setIsLoading] = React.useState(false);
const [error, setError] = React.useState<string | null>(null);
const [emailSent, setEmailSent] = React.useState(false);

// Get state from URL params
const state = React.useMemo(() => {
const params = new URLSearchParams(window.location.search);
return params.get("state") || "";
}, []);

const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
setIsLoading(true);

try {
const response = await apiFetch("/_emdash/api/auth/atproto/complete-profile", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email: email.trim().toLowerCase(), state }),
});

if (!response.ok) {
const body: { error?: { message?: string } } = await response.json().catch(() => ({}));
throw new Error(body?.error?.message || "Failed to send verification email");
}

setEmailSent(true);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to send verification email");
setIsLoading(false);
}
};

if (!state) {
return (
<div className="min-h-screen flex items-center justify-center bg-kumo-base p-4">
<div className="w-full max-w-md text-center">
<LogoLockup className="h-10 mx-auto mb-4" />
<p className="text-kumo-subtle">
Invalid or missing session. Please try logging in again.
</p>
<a
href="/_emdash/admin/login"
className="text-kumo-brand hover:underline mt-4 inline-block"
>
Back to login
</a>
</div>
</div>
);
}

if (emailSent) {
return (
<div className="min-h-screen flex items-center justify-center bg-kumo-base p-4">
<div className="w-full max-w-md">
<div className="text-center mb-8">
<LogoLockup className="h-10 mx-auto mb-2" />
</div>

<div className="bg-kumo-base border rounded-lg shadow-sm p-6 space-y-6 text-center">
<div className="inline-flex items-center justify-center w-16 h-16 rounded-full bg-kumo-brand/10 mx-auto">
<svg
className="w-8 h-8 text-kumo-brand"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"
/>
</svg>
</div>

<div>
<h2 className="text-xl font-semibold">Check your email</h2>
<p className="text-kumo-subtle mt-2">
We sent a verification link to{" "}
<span className="font-medium text-kumo-default">{email}</span>.
</p>
</div>

<div className="text-sm text-kumo-subtle">
<p>Click the link in the email to complete sign-in.</p>
<p className="mt-2">The link will expire in 15 minutes.</p>
</div>

<Button
variant="outline"
onClick={() => {
setEmailSent(false);
setIsLoading(false);
}}
className="mt-4 w-full justify-center"
>
Use a different email
</Button>
</div>

<p className="text-center mt-6 text-sm text-kumo-subtle">
<a href="/_emdash/admin/login" className="text-kumo-brand hover:underline">
Back to login
</a>
</p>
</div>
</div>
);
}

return (
<div className="min-h-screen flex items-center justify-center bg-kumo-base p-4">
<div className="w-full max-w-md">
{/* Header */}
<div className="text-center mb-8">
<LogoLockup className="h-10 mx-auto mb-2" />
<h1 className="text-2xl font-semibold text-kumo-default">Almost there</h1>
<p className="text-kumo-subtle mt-2">
Enter your email to complete sign-in. We'll send a verification link.
</p>
</div>

{/* Form */}
<div className="bg-kumo-base border rounded-lg shadow-sm p-6">
<form onSubmit={handleSubmit} className="space-y-4">
<Input
label="Email address"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="you@example.com"
className={error ? "border-kumo-danger" : ""}
disabled={isLoading}
autoComplete="email"
autoFocus
required
/>

{error && (
<div className="rounded-lg bg-kumo-danger/10 p-3 text-sm text-kumo-danger">
{error}
</div>
)}

<Button
type="submit"
className="w-full justify-center"
variant="primary"
loading={isLoading}
disabled={!email}
>
{isLoading ? "Sending..." : "Send verification email"}
</Button>
</form>
</div>

{/* Back link */}
<p className="text-center mt-6 text-sm text-kumo-subtle">
<a href="/_emdash/admin/login" className="text-kumo-brand hover:underline">
Back to login
</a>
</p>
</div>
</div>
);
}
5 changes: 5 additions & 0 deletions packages/admin/src/lib/api/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,11 @@ export interface AdminManifest {
* 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. Present when multiple locales are configured.
*/
Expand Down
Loading
Loading