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); + }); +});