diff --git a/.changeset/oauth-acceptable-invites.md b/.changeset/oauth-acceptable-invites.md
new file mode 100644
index 0000000000..eccd8f5e72
--- /dev/null
+++ b/.changeset/oauth-acceptable-invites.md
@@ -0,0 +1,7 @@
+---
+"@emdash-cms/auth": minor
+"emdash": minor
+"@emdash-cms/admin": minor
+---
+
+Invited users can now accept their invite by signing in with Google or GitHub, instead of only creating a passkey.
diff --git a/packages/admin/src/components/InviteAcceptPage.tsx b/packages/admin/src/components/InviteAcceptPage.tsx
index 2c86a5563f..4ddd13b84d 100644
--- a/packages/admin/src/components/InviteAcceptPage.tsx
+++ b/packages/admin/src/components/InviteAcceptPage.tsx
@@ -10,6 +10,7 @@ import { useSearch } from "@tanstack/react-router";
import * as React from "react";
import { validateInviteToken, type InviteVerifyResult } from "../lib/api";
+import { useAuthProviderList } from "../lib/auth-provider-context";
import { PasskeyRegistration } from "./auth/PasskeyRegistration";
import { LogoLockup } from "./Logo.js";
import { RouterLinkButton } from "./RouterLinkButton.js";
@@ -28,6 +29,7 @@ function handleInviteSuccess() {
function RegisterStep({ inviteData, token }: RegisterStepProps) {
const { t } = useLingui();
const [name, setName] = React.useState("");
+ const buttonProviders = useAuthProviderList().filter((p) => p.LoginButton);
return (
@@ -82,6 +84,36 @@ function RegisterStep({ inviteData, token }: RegisterStepProps) {
additionalData={{ token, name: name || undefined }}
/>
+
+ {buttonProviders.length > 0 && (
+ <>
+ {/* Divider */}
+
+
+
+
+
+ {t`Or continue with`}
+
+
+
+ {/* Accept the invite via an OAuth provider. The button carries the
+ invite token; the callback only completes the invite when the
+ provider-verified email matches the invited address. */}
+
+ {buttonProviders.map((provider) => {
+ const Btn = provider.LoginButton!;
+ return (
+
+
+
+ );
+ })}
+
+ >
+ )}
);
}
diff --git a/packages/admin/src/lib/auth-provider-context.tsx b/packages/admin/src/lib/auth-provider-context.tsx
index 539be0231c..54840f9d30 100644
--- a/packages/admin/src/lib/auth-provider-context.tsx
+++ b/packages/admin/src/lib/auth-provider-context.tsx
@@ -13,8 +13,12 @@ import { createContext, useContext } from "react";
export interface AuthProviderModule {
id: string;
label: string;
- /** Compact button for the login page (icon + label) */
- LoginButton?: React.ComponentType;
+ /**
+ * Compact button for the login page (icon + label). When rendered on the
+ * invite-accept page it receives the invite token so the button can start an
+ * invite-completing OAuth flow.
+ */
+ LoginButton?: React.ComponentType<{ inviteToken?: string }>;
/** Full form if the provider needs custom input (e.g., handle field) */
LoginForm?: React.ComponentType;
/** Component for the setup wizard admin creation step */
diff --git a/packages/auth/src/index.ts b/packages/auth/src/index.ts
index 32be9e695b..58cb856ac9 100644
--- a/packages/auth/src/index.ts
+++ b/packages/auth/src/index.ts
@@ -110,6 +110,7 @@ export {
createAuthorizationUrl,
handleOAuthCallback,
findOrCreateOAuthUser,
+ acceptInviteViaOAuth,
OAuthError,
github,
google,
diff --git a/packages/auth/src/oauth/consumer.ts b/packages/auth/src/oauth/consumer.ts
index 524f7c94f2..123778eccb 100644
--- a/packages/auth/src/oauth/consumer.ts
+++ b/packages/auth/src/oauth/consumer.ts
@@ -6,6 +6,8 @@ import { sha256 } from "@oslojs/crypto/sha2";
import { encodeBase64urlNoPadding } from "@oslojs/encoding";
import { z } from "zod";
+import { completeInvite, InviteError, validateInvite } from "../invite.js";
+import { hashToken } from "../tokens.js";
import type { AuthAdapter, User, RoleLevel } from "../types.js";
import { github, fetchGitHubEmail } from "./providers/github.js";
import { google } from "./providers/google.js";
@@ -32,6 +34,10 @@ export async function createAuthorizationUrl(
config: OAuthConsumerConfig,
providerName: "github" | "google",
stateStore: StateStore,
+ options?: {
+ /** When set, this flow accepts an invite and the callback completes it. */
+ inviteToken?: string;
+ },
): Promise<{ url: string; state: string }> {
const providerConfig = config.providers[providerName];
if (!providerConfig) {
@@ -54,6 +60,7 @@ export async function createAuthorizationUrl(
provider: providerName,
redirectUri,
codeVerifier,
+ ...(options?.inviteToken ? { inviteToken: options.inviteToken } : {}),
});
// Build authorization URL
@@ -110,10 +117,101 @@ export async function handleOAuthCallback(
// Fetch user profile
const profile = await fetchProfile(provider, tokens.accessToken, providerName);
+ // When the flow carried an invite token, complete the invite instead of
+ // applying the self-signup policy.
+ if (storedState.inviteToken) {
+ return acceptInviteViaOAuth(adapter, providerName, profile, storedState.inviteToken);
+ }
+
// Find or create user
return findOrCreateOAuthUser(adapter, providerName, profile, config.canSelfSignup);
}
+/**
+ * Complete an invite using an OAuth identity.
+ *
+ * The invite is only accepted when the provider has verified the email AND it
+ * matches the invited address (case-insensitive), so a login for a different
+ * account cannot consume someone else's invite. On success the user is created
+ * with the invited role and the OAuth account is linked.
+ */
+export async function acceptInviteViaOAuth(
+ adapter: AuthAdapter,
+ providerName: string,
+ profile: OAuthProfile,
+ inviteToken: string,
+): Promise {
+ let invite: { email: string; role: RoleLevel };
+ try {
+ invite = await validateInvite(adapter, inviteToken);
+ } catch (error) {
+ if (error instanceof InviteError) {
+ throw new OAuthError("invite_invalid", error.message);
+ }
+ throw error;
+ }
+
+ if (!profile.emailVerified) {
+ throw new OAuthError(
+ "invite_email_unverified",
+ "Cannot accept invite: email not verified by provider",
+ );
+ }
+
+ if (profile.email.toLowerCase() !== invite.email.toLowerCase()) {
+ throw new OAuthError(
+ "invite_email_mismatch",
+ "This invite was sent to a different email address than your account.",
+ );
+ }
+
+ // Already linked (e.g. a retried callback): consume the invite and return.
+ const existingAccount = await adapter.getOAuthAccount(providerName, profile.id);
+ if (existingAccount) {
+ const user = await adapter.getUserById(existingAccount.userId);
+ if (!user) {
+ throw new OAuthError("user_not_found", "Linked user not found");
+ }
+ // The linked user must actually own the invited email; otherwise a
+ // provider identity whose EmDash email was changed after linking could
+ // consume an invite issued to someone else.
+ if (user.email.toLowerCase() !== invite.email.toLowerCase()) {
+ throw new OAuthError(
+ "invite_email_mismatch",
+ "This invite was sent to a different email address than your account.",
+ );
+ }
+ await adapter.deleteToken(hashToken(inviteToken));
+ return user;
+ }
+
+ // The invited email already belongs to a user (invite already accepted, or a
+ // pre-existing account): link the OAuth account and consume the invite rather
+ // than failing, so the single-use token cannot be replayed.
+ const existingUser = await adapter.getUserByEmail(profile.email);
+ if (existingUser) {
+ await adapter.createOAuthAccount({
+ provider: providerName,
+ providerAccountId: profile.id,
+ userId: existingUser.id,
+ });
+ await adapter.deleteToken(hashToken(inviteToken));
+ return existingUser;
+ }
+
+ // Consume the invite: create the user with the invited role, then link.
+ const user = await completeInvite(adapter, inviteToken, {
+ name: profile.name ?? undefined,
+ avatarUrl: profile.avatarUrl ?? undefined,
+ });
+ await adapter.createOAuthAccount({
+ provider: providerName,
+ providerAccountId: profile.id,
+ userId: user.id,
+ });
+ return user;
+}
+
/**
* Exchange authorization code for tokens
*/
@@ -331,7 +429,10 @@ export class OAuthError extends Error {
| "token_exchange_failed"
| "profile_fetch_failed"
| "user_not_found"
- | "signup_not_allowed",
+ | "signup_not_allowed"
+ | "invite_invalid"
+ | "invite_email_mismatch"
+ | "invite_email_unverified",
message: string,
) {
super(message);
diff --git a/packages/auth/src/oauth/types.ts b/packages/auth/src/oauth/types.ts
index 08c91558d3..de33a87d0f 100644
--- a/packages/auth/src/oauth/types.ts
+++ b/packages/auth/src/oauth/types.ts
@@ -33,4 +33,11 @@ export interface OAuthState {
redirectUri: string;
codeVerifier?: string; // For PKCE
nonce?: string;
+ /**
+ * When present, this OAuth flow is accepting an invite. The callback
+ * completes the invite (creating the user with the invited role and linking
+ * the OAuth account) instead of falling back to the self-signup policy, but
+ * only when the provider-verified email matches the invited address.
+ */
+ inviteToken?: string;
}
diff --git a/packages/core/src/astro/routes/api/auth/oauth/[provider].ts b/packages/core/src/astro/routes/api/auth/oauth/[provider].ts
index c9a29dc852..1a1e7502a6 100644
--- a/packages/core/src/astro/routes/api/auth/oauth/[provider].ts
+++ b/packages/core/src/astro/routes/api/auth/oauth/[provider].ts
@@ -17,6 +17,9 @@ type ProviderName = "github" | "google";
const VALID_PROVIDERS = new Set(["github", "google"]);
+/** Invite tokens are base64url; clamp shape and length before persisting to state. */
+const INVITE_TOKEN_REGEX = /^[A-Za-z0-9_-]{1,256}$/;
+
function isValidProvider(provider: string): provider is ProviderName {
return VALID_PROVIDERS.has(provider);
}
@@ -113,7 +116,16 @@ export const GET: APIRoute = async ({ params, request, locals, redirect }) => {
const stateStore = createOAuthStateStore(emdash.db);
- const { url: authUrl } = await createAuthorizationUrl(config, provider, stateStore);
+ // When the flow starts from an invite link, carry the invite token so the
+ // callback can complete the invite for a matching, verified email. Validate
+ // its shape/length first: this endpoint is unauthenticated, so we avoid
+ // persisting arbitrary or oversized values into the short-lived state store.
+ const rawInvite = url.searchParams.get("invite");
+ const inviteToken = rawInvite && INVITE_TOKEN_REGEX.test(rawInvite) ? rawInvite : undefined;
+
+ const { url: authUrl } = await createAuthorizationUrl(config, provider, stateStore, {
+ inviteToken,
+ });
return redirect(authUrl);
} catch (error) {
diff --git a/packages/core/src/astro/routes/api/auth/oauth/[provider]/callback.ts b/packages/core/src/astro/routes/api/auth/oauth/[provider]/callback.ts
index a9775edd29..95fc6cf46b 100644
--- a/packages/core/src/astro/routes/api/auth/oauth/[provider]/callback.ts
+++ b/packages/core/src/astro/routes/api/auth/oauth/[provider]/callback.ts
@@ -216,6 +216,16 @@ export const GET: APIRoute = async ({ params, request, locals, session, redirect
case "signup_not_allowed":
message = "Self-signup is not allowed for your email. Please contact an administrator.";
break;
+ case "invite_invalid":
+ message = "This invite link is invalid or has expired. Please ask for a new one.";
+ break;
+ case "invite_email_mismatch":
+ message = "This invite was sent to a different email address than your account.";
+ break;
+ case "invite_email_unverified":
+ message =
+ "Your account's email is not verified by the provider. Please verify it and try again.";
+ break;
case "user_not_found":
message = "Your account was not found. It may have been deleted.";
break;
diff --git a/packages/core/src/auth/oauth-state-store.ts b/packages/core/src/auth/oauth-state-store.ts
index 43d8915e00..97cc8ac516 100644
--- a/packages/core/src/auth/oauth-state-store.ts
+++ b/packages/core/src/auth/oauth-state-store.ts
@@ -79,6 +79,9 @@ export function createOAuthStateStore(db: Kysely): StateStore {
if ("nonce" in parsed && typeof parsed.nonce === "string") {
oauthState.nonce = parsed.nonce;
}
+ if ("inviteToken" in parsed && typeof parsed.inviteToken === "string") {
+ oauthState.inviteToken = parsed.inviteToken;
+ }
return oauthState;
} catch {
return null;
diff --git a/packages/core/src/auth/providers/github-admin.tsx b/packages/core/src/auth/providers/github-admin.tsx
index 79283bd69e..04fab61fcc 100644
--- a/packages/core/src/auth/providers/github-admin.tsx
+++ b/packages/core/src/auth/providers/github-admin.tsx
@@ -15,13 +15,12 @@ function GitHubIcon({ className }: { className?: string }) {
);
}
-export function LoginButton() {
+export function LoginButton({ inviteToken }: { inviteToken?: string } = {}) {
+ const href = inviteToken
+ ? `/_emdash/api/auth/oauth/github?invite=${encodeURIComponent(inviteToken)}`
+ : "/_emdash/api/auth/oauth/github";
return (
-
+
GitHub
diff --git a/packages/core/src/auth/providers/google-admin.tsx b/packages/core/src/auth/providers/google-admin.tsx
index 5284f09993..e04c6e06d8 100644
--- a/packages/core/src/auth/providers/google-admin.tsx
+++ b/packages/core/src/auth/providers/google-admin.tsx
@@ -30,13 +30,12 @@ function GoogleIcon({ className }: { className?: string }) {
);
}
-export function LoginButton() {
+export function LoginButton({ inviteToken }: { inviteToken?: string } = {}) {
+ const href = inviteToken
+ ? `/_emdash/api/auth/oauth/google?invite=${encodeURIComponent(inviteToken)}`
+ : "/_emdash/api/auth/oauth/google";
return (
-
+
Google
diff --git a/packages/core/tests/unit/auth/oauth-invite.test.ts b/packages/core/tests/unit/auth/oauth-invite.test.ts
new file mode 100644
index 0000000000..0d28652a5a
--- /dev/null
+++ b/packages/core/tests/unit/auth/oauth-invite.test.ts
@@ -0,0 +1,178 @@
+import type { AuthAdapter, OAuthProfile } from "@emdash-cms/auth";
+import { Role, acceptInviteViaOAuth, createInviteToken, OAuthError } from "@emdash-cms/auth";
+import { createKyselyAdapter } from "@emdash-cms/auth/adapters/kysely";
+import type { Kysely } from "kysely";
+import { describe, it, expect, beforeEach, afterEach } from "vitest";
+
+import type { Database } from "../../../src/database/types.js";
+import { setupTestDatabase, teardownTestDatabase } from "../../utils/test-db.js";
+
+const TOKEN_EXTRACT_REGEX = /token=([a-zA-Z0-9_-]+)/;
+
+function makeProfile(overrides: Partial = {}): OAuthProfile {
+ return {
+ id: "google-123",
+ email: "invitee@example.com",
+ name: "Invitee",
+ avatarUrl: null,
+ emailVerified: true,
+ ...overrides,
+ };
+}
+
+describe("acceptInviteViaOAuth", () => {
+ let db: Kysely;
+ let adapter: AuthAdapter;
+ let adminId: string;
+
+ beforeEach(async () => {
+ db = await setupTestDatabase();
+ adapter = createKyselyAdapter(db);
+ const admin = await adapter.createUser({
+ email: "admin@example.com",
+ name: "Admin",
+ role: Role.ADMIN,
+ emailVerified: true,
+ });
+ adminId = admin.id;
+ });
+
+ afterEach(async () => {
+ await teardownTestDatabase(db);
+ });
+
+ async function invite(email: string, role: number = Role.AUTHOR): Promise {
+ const { url } = await createInviteToken(
+ { baseUrl: "https://example.com/_emdash" },
+ adapter,
+ email,
+ role,
+ adminId,
+ );
+ const match = url.match(TOKEN_EXTRACT_REGEX);
+ if (!match) throw new Error("could not extract invite token");
+ return match[1];
+ }
+
+ it("completes the invite, sets the invited role, links the account, and consumes the token", async () => {
+ const token = await invite("invitee@example.com", Role.EDITOR);
+
+ const user = await acceptInviteViaOAuth(adapter, "google", makeProfile(), token);
+
+ expect(user.email).toBe("invitee@example.com");
+ expect(user.role).toBe(Role.EDITOR);
+
+ const account = await adapter.getOAuthAccount("google", "google-123");
+ expect(account?.userId).toBe(user.id);
+
+ // Single-use: the consumed token can no longer be replayed.
+ await expect(
+ acceptInviteViaOAuth(adapter, "google", makeProfile({ id: "google-999" }), token),
+ ).rejects.toMatchObject({ code: "invite_invalid" });
+ });
+
+ it("matches the invited email case-insensitively", async () => {
+ const token = await invite("Invitee@Example.com");
+
+ // Invited as "Invitee@Example.com", provider reports "invitee@example.com":
+ // the differing case still completes the invite (email is normalized on store).
+ const user = await acceptInviteViaOAuth(
+ adapter,
+ "google",
+ makeProfile({ email: "invitee@example.com" }),
+ token,
+ );
+
+ expect(user.email.toLowerCase()).toBe("invitee@example.com");
+ expect(await adapter.getOAuthAccount("google", "google-123")).not.toBeNull();
+ });
+
+ it("rejects when the OAuth email does not match the invite", async () => {
+ const token = await invite("invitee@example.com");
+
+ await expect(
+ acceptInviteViaOAuth(
+ adapter,
+ "google",
+ makeProfile({ email: "someone-else@example.com" }),
+ token,
+ ),
+ ).rejects.toMatchObject({ code: "invite_email_mismatch" });
+
+ // No account was created for the mismatched email.
+ expect(await adapter.getUserByEmail("someone-else@example.com")).toBeNull();
+ });
+
+ it("rejects with a distinct code when the provider has not verified the email", async () => {
+ const token = await invite("invitee@example.com");
+
+ await expect(
+ acceptInviteViaOAuth(adapter, "google", makeProfile({ emailVerified: false }), token),
+ ).rejects.toMatchObject({ code: "invite_email_unverified" });
+
+ expect(await adapter.getUserByEmail("invitee@example.com")).toBeNull();
+ });
+
+ it("consumes the invite token when linking to a pre-existing account", async () => {
+ const token = await invite("invitee@example.com");
+ // A user with the invited email is created another way after the invite is
+ // issued (e.g. an admin-created user or a passkey-accept race).
+ await adapter.createUser({
+ email: "invitee@example.com",
+ name: "Invitee",
+ role: Role.AUTHOR,
+ emailVerified: true,
+ });
+
+ const user = await acceptInviteViaOAuth(adapter, "google", makeProfile(), token);
+ expect(user.email).toBe("invitee@example.com");
+ expect(await adapter.getOAuthAccount("google", "google-123")).not.toBeNull();
+
+ // Single-use: the linked-and-consumed token cannot be replayed.
+ await expect(
+ acceptInviteViaOAuth(adapter, "google", makeProfile({ id: "google-777" }), token),
+ ).rejects.toMatchObject({ code: "invite_invalid" });
+ });
+
+ it("rejects (and does not consume) when the already-linked account's user has a different email", async () => {
+ const token = await invite("invitee@example.com");
+ // An OAuth identity is already linked to a user whose EmDash email differs
+ // from the invited address (e.g. the email was changed after linking).
+ const other = await adapter.createUser({
+ email: "other@example.com",
+ name: "Other",
+ role: Role.AUTHOR,
+ emailVerified: true,
+ });
+ await adapter.createOAuthAccount({
+ provider: "google",
+ providerAccountId: "google-linked",
+ userId: other.id,
+ });
+
+ await expect(
+ acceptInviteViaOAuth(
+ adapter,
+ "google",
+ makeProfile({ id: "google-linked", email: "invitee@example.com" }),
+ token,
+ ),
+ ).rejects.toMatchObject({ code: "invite_email_mismatch" });
+
+ // The invite was not consumed: a fresh, correctly-matched identity still works.
+ const user = await acceptInviteViaOAuth(adapter, "google", makeProfile(), token);
+ expect(user.email).toBe("invitee@example.com");
+ });
+
+ it("rejects an invalid or unknown invite token", async () => {
+ await expect(
+ acceptInviteViaOAuth(adapter, "google", makeProfile(), "not-a-real-token"),
+ ).rejects.toMatchObject({ code: "invite_invalid" });
+ });
+
+ it("throws OAuthError (not InviteError) so the callback maps it to a message", async () => {
+ await expect(
+ acceptInviteViaOAuth(adapter, "google", makeProfile(), "not-a-real-token"),
+ ).rejects.toBeInstanceOf(OAuthError);
+ });
+});