Skip to content
Merged
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
7 changes: 7 additions & 0 deletions .changeset/oauth-acceptable-invites.md
Original file line number Diff line number Diff line change
@@ -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.
32 changes: 32 additions & 0 deletions packages/admin/src/components/InviteAcceptPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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 (
<div className="space-y-6">
Expand Down Expand Up @@ -82,6 +84,36 @@ function RegisterStep({ inviteData, token }: RegisterStepProps) {
additionalData={{ token, name: name || undefined }}
/>
</div>

{buttonProviders.length > 0 && (
<>
{/* Divider */}
<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">{t`Or continue with`}</span>
</div>
</div>

{/* 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. */}
<div
className={`grid gap-3 ${buttonProviders.length === 1 ? "grid-cols-1" : "grid-cols-2"}`}
>
{buttonProviders.map((provider) => {
const Btn = provider.LoginButton!;
return (
<div key={provider.id}>
<Btn inviteToken={token} />
Comment thread
afonsojramos marked this conversation as resolved.
</div>
);
})}
</div>
</>
)}
</div>
);
}
Expand Down
8 changes: 6 additions & 2 deletions packages/admin/src/lib/auth-provider-context.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand Down
1 change: 1 addition & 0 deletions packages/auth/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,7 @@ export {
createAuthorizationUrl,
handleOAuthCallback,
findOrCreateOAuthUser,
acceptInviteViaOAuth,
OAuthError,
github,
google,
Expand Down
103 changes: 102 additions & 1 deletion packages/auth/src/oauth/consumer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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) {
Expand All @@ -54,6 +60,7 @@ export async function createAuthorizationUrl(
provider: providerName,
redirectUri,
codeVerifier,
...(options?.inviteToken ? { inviteToken: options.inviteToken } : {}),
});

// Build authorization URL
Expand Down Expand Up @@ -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<User> {
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;
Comment thread
afonsojramos marked this conversation as resolved.
}

// 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
*/
Expand Down Expand Up @@ -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);
Expand Down
7 changes: 7 additions & 0 deletions packages/auth/src/oauth/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
14 changes: 13 additions & 1 deletion packages/core/src/astro/routes/api/auth/oauth/[provider].ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@ type ProviderName = "github" | "google";

const VALID_PROVIDERS = new Set<string>(["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);
}
Expand Down Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Comment thread
afonsojramos marked this conversation as resolved.
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;
Expand Down
3 changes: 3 additions & 0 deletions packages/core/src/auth/oauth-state-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,9 @@ export function createOAuthStateStore(db: Kysely<Database>): 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;
Expand Down
11 changes: 5 additions & 6 deletions packages/core/src/auth/providers/github-admin.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<LinkButton
href="/_emdash/api/auth/oauth/github"
variant="outline"
className="w-full justify-center"
>
<LinkButton href={href} variant="outline" className="w-full justify-center">
Comment thread
afonsojramos marked this conversation as resolved.
<GitHubIcon className="h-5 w-5" />
<span>GitHub</span>
</LinkButton>
Comment thread
afonsojramos marked this conversation as resolved.
Expand Down
11 changes: 5 additions & 6 deletions packages/core/src/auth/providers/google-admin.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<LinkButton
href="/_emdash/api/auth/oauth/google"
variant="outline"
className="w-full justify-center"
>
<LinkButton href={href} variant="outline" className="w-full justify-center">
<GoogleIcon className="h-5 w-5" />
<span>Google</span>
</LinkButton>
Comment thread
afonsojramos marked this conversation as resolved.
Comment thread
afonsojramos marked this conversation as resolved.
Expand Down
Loading
Loading