Skip to content

Commit 4ae6465

Browse files
afonsojramosemdashbot[bot]ascorbic
authored andcommitted
feat: accept invites via OAuth (Google/GitHub), not just passkeys (emdash-cms#1868)
* feat: accept invites via OAuth (Google/GitHub), not just passkeys An invited user can complete their invite by signing in with a configured OAuth provider. The invite token is carried through the OAuth state, and the callback completes the invite (creating the user with the invited role and linking the account) only when the provider-verified email matches the invited address. The invite-accept page renders the configured providers under an 'Or continue with' divider. * fix: consume invite token when linking existing account; distinct unverified-email error Addresses review on emdash-cms#1868: - acceptInviteViaOAuth now deletes the invite token in the existing-user and existing-account branches, preserving the single-use guarantee. - Unverified provider email throws a new invite_email_unverified code with its own callback message, instead of the misleading email-mismatch message. * fix: address review — linked-user email guard, invite-token validation, changeset - acceptInviteViaOAuth: in the already-linked branch, require the linked user's email to match the invite before consuming, so an identity whose email changed after linking can't consume someone else's invite. - oauth/[provider]: validate the ?invite= token shape/length before persisting to the (unauthenticated) OAuth state store. - changeset: rewrite as a single user-facing sentence. * ci: update query-count snapshots --------- Co-authored-by: emdashbot[bot] <emdashbot[bot]@users.noreply.github.com> Co-authored-by: Matt Kane <mkane@cloudflare.com>
1 parent e33adaa commit 4ae6465

12 files changed

Lines changed: 369 additions & 16 deletions

File tree

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
"@emdash-cms/auth": minor
3+
"emdash": minor
4+
"@emdash-cms/admin": minor
5+
---
6+
7+
Invited users can now accept their invite by signing in with Google or GitHub, instead of only creating a passkey.

packages/admin/src/components/InviteAcceptPage.tsx

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { useSearch } from "@tanstack/react-router";
1010
import * as React from "react";
1111

1212
import { validateInviteToken, type InviteVerifyResult } from "../lib/api";
13+
import { useAuthProviderList } from "../lib/auth-provider-context";
1314
import { PasskeyRegistration } from "./auth/PasskeyRegistration";
1415
import { LogoLockup } from "./Logo.js";
1516
import { RouterLinkButton } from "./RouterLinkButton.js";
@@ -28,6 +29,7 @@ function handleInviteSuccess() {
2829
function RegisterStep({ inviteData, token }: RegisterStepProps) {
2930
const { t } = useLingui();
3031
const [name, setName] = React.useState("");
32+
const buttonProviders = useAuthProviderList().filter((p) => p.LoginButton);
3133

3234
return (
3335
<div className="space-y-6">
@@ -82,6 +84,36 @@ function RegisterStep({ inviteData, token }: RegisterStepProps) {
8284
additionalData={{ token, name: name || undefined }}
8385
/>
8486
</div>
87+
88+
{buttonProviders.length > 0 && (
89+
<>
90+
{/* Divider */}
91+
<div className="relative">
92+
<div className="absolute inset-0 flex items-center">
93+
<span className="w-full border-t" />
94+
</div>
95+
<div className="relative flex justify-center text-xs uppercase">
96+
<span className="bg-kumo-base px-2 text-kumo-subtle">{t`Or continue with`}</span>
97+
</div>
98+
</div>
99+
100+
{/* Accept the invite via an OAuth provider. The button carries the
101+
invite token; the callback only completes the invite when the
102+
provider-verified email matches the invited address. */}
103+
<div
104+
className={`grid gap-3 ${buttonProviders.length === 1 ? "grid-cols-1" : "grid-cols-2"}`}
105+
>
106+
{buttonProviders.map((provider) => {
107+
const Btn = provider.LoginButton!;
108+
return (
109+
<div key={provider.id}>
110+
<Btn inviteToken={token} />
111+
</div>
112+
);
113+
})}
114+
</div>
115+
</>
116+
)}
85117
</div>
86118
);
87119
}

packages/admin/src/lib/auth-provider-context.tsx

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,12 @@ import { createContext, useContext } from "react";
1313
export interface AuthProviderModule {
1414
id: string;
1515
label: string;
16-
/** Compact button for the login page (icon + label) */
17-
LoginButton?: React.ComponentType;
16+
/**
17+
* Compact button for the login page (icon + label). When rendered on the
18+
* invite-accept page it receives the invite token so the button can start an
19+
* invite-completing OAuth flow.
20+
*/
21+
LoginButton?: React.ComponentType<{ inviteToken?: string }>;
1822
/** Full form if the provider needs custom input (e.g., handle field) */
1923
LoginForm?: React.ComponentType;
2024
/** Component for the setup wizard admin creation step */

packages/auth/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,7 @@ export {
110110
createAuthorizationUrl,
111111
handleOAuthCallback,
112112
findOrCreateOAuthUser,
113+
acceptInviteViaOAuth,
113114
OAuthError,
114115
github,
115116
google,

packages/auth/src/oauth/consumer.ts

Lines changed: 102 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ import { sha256 } from "@oslojs/crypto/sha2";
66
import { encodeBase64urlNoPadding } from "@oslojs/encoding";
77
import { z } from "zod";
88

9+
import { completeInvite, InviteError, validateInvite } from "../invite.js";
10+
import { hashToken } from "../tokens.js";
911
import type { AuthAdapter, User, RoleLevel } from "../types.js";
1012
import { github, fetchGitHubEmail } from "./providers/github.js";
1113
import { google } from "./providers/google.js";
@@ -32,6 +34,10 @@ export async function createAuthorizationUrl(
3234
config: OAuthConsumerConfig,
3335
providerName: "github" | "google",
3436
stateStore: StateStore,
37+
options?: {
38+
/** When set, this flow accepts an invite and the callback completes it. */
39+
inviteToken?: string;
40+
},
3541
): Promise<{ url: string; state: string }> {
3642
const providerConfig = config.providers[providerName];
3743
if (!providerConfig) {
@@ -54,6 +60,7 @@ export async function createAuthorizationUrl(
5460
provider: providerName,
5561
redirectUri,
5662
codeVerifier,
63+
...(options?.inviteToken ? { inviteToken: options.inviteToken } : {}),
5764
});
5865

5966
// Build authorization URL
@@ -110,10 +117,101 @@ export async function handleOAuthCallback(
110117
// Fetch user profile
111118
const profile = await fetchProfile(provider, tokens.accessToken, providerName);
112119

120+
// When the flow carried an invite token, complete the invite instead of
121+
// applying the self-signup policy.
122+
if (storedState.inviteToken) {
123+
return acceptInviteViaOAuth(adapter, providerName, profile, storedState.inviteToken);
124+
}
125+
113126
// Find or create user
114127
return findOrCreateOAuthUser(adapter, providerName, profile, config.canSelfSignup);
115128
}
116129

130+
/**
131+
* Complete an invite using an OAuth identity.
132+
*
133+
* The invite is only accepted when the provider has verified the email AND it
134+
* matches the invited address (case-insensitive), so a login for a different
135+
* account cannot consume someone else's invite. On success the user is created
136+
* with the invited role and the OAuth account is linked.
137+
*/
138+
export async function acceptInviteViaOAuth(
139+
adapter: AuthAdapter,
140+
providerName: string,
141+
profile: OAuthProfile,
142+
inviteToken: string,
143+
): Promise<User> {
144+
let invite: { email: string; role: RoleLevel };
145+
try {
146+
invite = await validateInvite(adapter, inviteToken);
147+
} catch (error) {
148+
if (error instanceof InviteError) {
149+
throw new OAuthError("invite_invalid", error.message);
150+
}
151+
throw error;
152+
}
153+
154+
if (!profile.emailVerified) {
155+
throw new OAuthError(
156+
"invite_email_unverified",
157+
"Cannot accept invite: email not verified by provider",
158+
);
159+
}
160+
161+
if (profile.email.toLowerCase() !== invite.email.toLowerCase()) {
162+
throw new OAuthError(
163+
"invite_email_mismatch",
164+
"This invite was sent to a different email address than your account.",
165+
);
166+
}
167+
168+
// Already linked (e.g. a retried callback): consume the invite and return.
169+
const existingAccount = await adapter.getOAuthAccount(providerName, profile.id);
170+
if (existingAccount) {
171+
const user = await adapter.getUserById(existingAccount.userId);
172+
if (!user) {
173+
throw new OAuthError("user_not_found", "Linked user not found");
174+
}
175+
// The linked user must actually own the invited email; otherwise a
176+
// provider identity whose EmDash email was changed after linking could
177+
// consume an invite issued to someone else.
178+
if (user.email.toLowerCase() !== invite.email.toLowerCase()) {
179+
throw new OAuthError(
180+
"invite_email_mismatch",
181+
"This invite was sent to a different email address than your account.",
182+
);
183+
}
184+
await adapter.deleteToken(hashToken(inviteToken));
185+
return user;
186+
}
187+
188+
// The invited email already belongs to a user (invite already accepted, or a
189+
// pre-existing account): link the OAuth account and consume the invite rather
190+
// than failing, so the single-use token cannot be replayed.
191+
const existingUser = await adapter.getUserByEmail(profile.email);
192+
if (existingUser) {
193+
await adapter.createOAuthAccount({
194+
provider: providerName,
195+
providerAccountId: profile.id,
196+
userId: existingUser.id,
197+
});
198+
await adapter.deleteToken(hashToken(inviteToken));
199+
return existingUser;
200+
}
201+
202+
// Consume the invite: create the user with the invited role, then link.
203+
const user = await completeInvite(adapter, inviteToken, {
204+
name: profile.name ?? undefined,
205+
avatarUrl: profile.avatarUrl ?? undefined,
206+
});
207+
await adapter.createOAuthAccount({
208+
provider: providerName,
209+
providerAccountId: profile.id,
210+
userId: user.id,
211+
});
212+
return user;
213+
}
214+
117215
/**
118216
* Exchange authorization code for tokens
119217
*/
@@ -331,7 +429,10 @@ export class OAuthError extends Error {
331429
| "token_exchange_failed"
332430
| "profile_fetch_failed"
333431
| "user_not_found"
334-
| "signup_not_allowed",
432+
| "signup_not_allowed"
433+
| "invite_invalid"
434+
| "invite_email_mismatch"
435+
| "invite_email_unverified",
335436
message: string,
336437
) {
337438
super(message);

packages/auth/src/oauth/types.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,4 +33,11 @@ export interface OAuthState {
3333
redirectUri: string;
3434
codeVerifier?: string; // For PKCE
3535
nonce?: string;
36+
/**
37+
* When present, this OAuth flow is accepting an invite. The callback
38+
* completes the invite (creating the user with the invited role and linking
39+
* the OAuth account) instead of falling back to the self-signup policy, but
40+
* only when the provider-verified email matches the invited address.
41+
*/
42+
inviteToken?: string;
3643
}

packages/core/src/astro/routes/api/auth/oauth/[provider].ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,9 @@ type ProviderName = "github" | "google";
1717

1818
const VALID_PROVIDERS = new Set<string>(["github", "google"]);
1919

20+
/** Invite tokens are base64url; clamp shape and length before persisting to state. */
21+
const INVITE_TOKEN_REGEX = /^[A-Za-z0-9_-]{1,256}$/;
22+
2023
function isValidProvider(provider: string): provider is ProviderName {
2124
return VALID_PROVIDERS.has(provider);
2225
}
@@ -113,7 +116,16 @@ export const GET: APIRoute = async ({ params, request, locals, redirect }) => {
113116

114117
const stateStore = createOAuthStateStore(emdash.db);
115118

116-
const { url: authUrl } = await createAuthorizationUrl(config, provider, stateStore);
119+
// When the flow starts from an invite link, carry the invite token so the
120+
// callback can complete the invite for a matching, verified email. Validate
121+
// its shape/length first: this endpoint is unauthenticated, so we avoid
122+
// persisting arbitrary or oversized values into the short-lived state store.
123+
const rawInvite = url.searchParams.get("invite");
124+
const inviteToken = rawInvite && INVITE_TOKEN_REGEX.test(rawInvite) ? rawInvite : undefined;
125+
126+
const { url: authUrl } = await createAuthorizationUrl(config, provider, stateStore, {
127+
inviteToken,
128+
});
117129

118130
return redirect(authUrl);
119131
} catch (error) {

packages/core/src/astro/routes/api/auth/oauth/[provider]/callback.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -216,6 +216,16 @@ export const GET: APIRoute = async ({ params, request, locals, session, redirect
216216
case "signup_not_allowed":
217217
message = "Self-signup is not allowed for your email. Please contact an administrator.";
218218
break;
219+
case "invite_invalid":
220+
message = "This invite link is invalid or has expired. Please ask for a new one.";
221+
break;
222+
case "invite_email_mismatch":
223+
message = "This invite was sent to a different email address than your account.";
224+
break;
225+
case "invite_email_unverified":
226+
message =
227+
"Your account's email is not verified by the provider. Please verify it and try again.";
228+
break;
219229
case "user_not_found":
220230
message = "Your account was not found. It may have been deleted.";
221231
break;

packages/core/src/auth/oauth-state-store.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,9 @@ export function createOAuthStateStore(db: Kysely<Database>): StateStore {
7979
if ("nonce" in parsed && typeof parsed.nonce === "string") {
8080
oauthState.nonce = parsed.nonce;
8181
}
82+
if ("inviteToken" in parsed && typeof parsed.inviteToken === "string") {
83+
oauthState.inviteToken = parsed.inviteToken;
84+
}
8285
return oauthState;
8386
} catch {
8487
return null;

packages/core/src/auth/providers/github-admin.tsx

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -15,13 +15,12 @@ function GitHubIcon({ className }: { className?: string }) {
1515
);
1616
}
1717

18-
export function LoginButton() {
18+
export function LoginButton({ inviteToken }: { inviteToken?: string } = {}) {
19+
const href = inviteToken
20+
? `/_emdash/api/auth/oauth/github?invite=${encodeURIComponent(inviteToken)}`
21+
: "/_emdash/api/auth/oauth/github";
1922
return (
20-
<LinkButton
21-
href="/_emdash/api/auth/oauth/github"
22-
variant="outline"
23-
className="w-full justify-center"
24-
>
23+
<LinkButton href={href} variant="outline" className="w-full justify-center">
2524
<GitHubIcon className="h-5 w-5" />
2625
<span>GitHub</span>
2726
</LinkButton>

0 commit comments

Comments
 (0)