Skip to content

Commit 4b320ff

Browse files
feat(auth): invite-only Google sign-in for @dashverse.ai / @dashtoon.com (#507)
Invite-only, Google-only sign-in limited to GOOGLE_ALLOWED_DOMAIN. Invites are redeemed by verified email (14 days, resend), atomically. Nobody can create organizations. Only org admins create or delete projects and manage invites; members see only granted projects. Password and GitHub sign-in are off while the allow-list is set (ALLOW_PASSWORD_LOGIN=true restores password sign-in only). Migration 20261020000000_member_org_user_unique must be applied by hand on prod.
1 parent dfb9eb6 commit 4b320ff

33 files changed

Lines changed: 1655 additions & 245 deletions

‎apps/api/src/controllers/oauth-callback.controller.tsx‎

Lines changed: 216 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,27 @@
11
import { LogError } from '@/utils/errors';
22
import {
33
Arctic,
4+
DomainPolicyError,
5+
type GoogleIdentity,
46
type OAuth2Tokens,
57
createSession,
8+
describeAllowedDomains,
69
generateSessionToken,
710
github,
811
google,
12+
isEmailDomainAllowed,
13+
isGithubLoginEnabled,
14+
parseGoogleIdentity,
915
setSessionTokenCookie,
1016
} from '@openpanel/auth';
11-
import { type Account, connectUserToOrganization, db } from '@openpanel/db';
17+
import {
18+
type Account,
19+
type User,
20+
claimPendingInvitesForUser,
21+
connectUserToOrganization,
22+
db,
23+
getIsRegistrationAllowed,
24+
} from '@openpanel/db';
1225
import type { FastifyReply, FastifyRequest } from 'fastify';
1326
import { z } from 'zod';
1427

@@ -48,15 +61,58 @@ interface OAuthUser {
4861
}
4962

5063
// Shared utility functions
64+
const NO_INVITE_MESSAGE =
65+
'You need an invite to access OpenPanel. Ask an admin to invite you.';
66+
67+
/**
68+
* Best-effort: join the organizations this user was invited to. A broken or
69+
* expired invite must never block an otherwise valid sign-in.
70+
*/
71+
async function redeemInvites({
72+
user,
73+
inviteId,
74+
claimByEmail,
75+
reply,
76+
}: {
77+
user: User;
78+
inviteId: string | undefined | null;
79+
claimByEmail: boolean;
80+
reply: FastifyReply;
81+
}) {
82+
if (inviteId) {
83+
try {
84+
await connectUserToOrganization({ user, inviteId });
85+
} catch (error) {
86+
reply.log.warn(
87+
{ error, inviteId, userId: user.id },
88+
'could not redeem invite from link',
89+
);
90+
}
91+
reply.clearCookie('inviteId');
92+
}
93+
94+
if (claimByEmail) {
95+
const { failed } = await claimPendingInvitesForUser(user);
96+
for (const { inviteId, error } of failed) {
97+
reply.log.warn(
98+
{ error, inviteId, userId: user.id },
99+
'could not redeem pending invite',
100+
);
101+
}
102+
}
103+
}
104+
51105
async function handleExistingUser({
52106
account,
53107
oauthUser,
54108
providerName,
109+
inviteId,
55110
reply,
56111
}: {
57112
account: Account;
58113
oauthUser: OAuthUser;
59114
providerName: Provider;
115+
inviteId: string | undefined | null;
60116
reply: FastifyReply;
61117
}) {
62118
const sessionToken = generateSessionToken();
@@ -71,6 +127,13 @@ async function handleExistingUser({
71127
},
72128
});
73129

130+
if (inviteId) {
131+
const user = await db.user.findUniqueOrThrow({
132+
where: { id: account.userId },
133+
});
134+
await redeemInvites({ user, inviteId, claimByEmail: false, reply });
135+
}
136+
74137
setSessionTokenCookie(
75138
(...args) => reply.setCookie(...args),
76139
sessionToken,
@@ -107,6 +170,19 @@ async function handleNewUser({
107170
);
108171
}
109172

173+
// Enforced here rather than before the IdP redirect: this is the first point
174+
// where we know the person is new, so returning users are never caught by
175+
// it. No profile data in the log payload, the person is not a user.
176+
// GitHub only hands us a verified primary email (getGithubEmail)
177+
if (
178+
!(await getIsRegistrationAllowed({
179+
inviteId,
180+
verifiedEmail: oauthUser.email,
181+
}))
182+
) {
183+
throw new LogError(NO_INVITE_MESSAGE, { providerName, inviteId });
184+
}
185+
110186
const user = await db.user.create({
111187
data: {
112188
email: oauthUser.email,
@@ -121,17 +197,7 @@ async function handleNewUser({
121197
},
122198
});
123199

124-
if (inviteId) {
125-
try {
126-
await connectUserToOrganization({ user, inviteId });
127-
} catch (error) {
128-
reply.log.error('error connecting user to organization', {
129-
error,
130-
inviteId,
131-
user,
132-
});
133-
}
134-
}
200+
await redeemInvites({ user, inviteId, claimByEmail: false, reply });
135201

136202
const sessionToken = generateSessionToken();
137203
const session = await createSession(sessionToken, user.id);
@@ -181,35 +247,127 @@ async function fetchGithubUser(accessToken: string): Promise<OAuthUser> {
181247
};
182248
}
183249

184-
async function fetchGoogleUser(tokens: OAuth2Tokens): Promise<OAuthUser> {
185-
const claims = Arctic.decodeIdToken(tokens.idToken());
250+
function getGoogleIdentity(tokens: OAuth2Tokens): GoogleIdentity {
251+
try {
252+
return parseGoogleIdentity(Arctic.decodeIdToken(tokens.idToken()));
253+
} catch (error) {
254+
if (error instanceof DomainPolicyError) {
255+
throw new LogError(error.message);
256+
}
257+
throw error;
258+
}
259+
}
186260

187-
const claimsSchema = z.object({
188-
sub: z.string(),
189-
email: z.string(),
190-
email_verified: z.boolean(),
191-
given_name: z.string().optional(),
192-
family_name: z.string().optional(),
261+
function isUniqueConstraintError(error: unknown) {
262+
return (
263+
typeof error === 'object' &&
264+
error !== null &&
265+
'code' in error &&
266+
(error as { code?: unknown }).code === 'P2002'
267+
);
268+
}
269+
270+
/**
271+
* Find or create the user behind a verified Google identity.
272+
*
273+
* 1. An account already bound to this Google id.
274+
* 2. A legacy Google/OAuth account matched by email, which gets bound now.
275+
* 3. An existing user with this email (e.g. an email/password account): link
276+
* Google to it. Safe because Google verified the address and, with an
277+
* allow-list configured, that it belongs to our Workspace.
278+
* 4. A brand-new user, only when registration is allowed for them.
279+
*/
280+
async function resolveGoogleUser(
281+
identity: GoogleIdentity,
282+
inviteId: string | undefined | null,
283+
): Promise<User> {
284+
const account = await db.account.findFirst({
285+
where: {
286+
OR: [
287+
{ provider: 'google', providerId: identity.id },
288+
{
289+
provider: 'google',
290+
providerId: null,
291+
user: { email: { equals: identity.email, mode: 'insensitive' } },
292+
},
293+
{
294+
provider: 'oauth',
295+
user: { email: { equals: identity.email, mode: 'insensitive' } },
296+
},
297+
],
298+
},
299+
// Prefer the account already bound to this exact Google id
300+
orderBy: { providerId: { sort: 'asc', nulls: 'last' } },
301+
include: { user: true },
193302
});
194303

195-
const claimsResult = claimsSchema.safeParse(claims);
196-
if (!claimsResult.success) {
197-
throw new LogError('Error fetching Google user', {
198-
error: claimsResult.error,
199-
claims,
304+
if (account) {
305+
await db.account.update({
306+
where: { id: account.id },
307+
data: {
308+
provider: 'google',
309+
providerId: identity.id,
310+
email: identity.email,
311+
},
200312
});
313+
return account.user;
201314
}
202315

203-
if (!claimsResult.data.email_verified) {
204-
throw new LogError('Email not verified with Google');
316+
const existingUser = await db.user.findFirst({
317+
where: { email: { equals: identity.email, mode: 'insensitive' } },
318+
});
319+
320+
if (existingUser) {
321+
try {
322+
await db.account.create({
323+
data: {
324+
userId: existingUser.id,
325+
provider: 'google',
326+
providerId: identity.id,
327+
email: identity.email,
328+
},
329+
});
330+
} catch (error) {
331+
// A concurrent sign-in linked it first
332+
if (!isUniqueConstraintError(error)) {
333+
throw error;
334+
}
335+
}
336+
return existingUser;
205337
}
206338

207-
return {
208-
id: claimsResult.data.sub,
209-
email: claimsResult.data.email,
210-
firstName: claimsResult.data.given_name || '',
211-
lastName: claimsResult.data.family_name || '',
212-
};
339+
const isRegistrationAllowed = await getIsRegistrationAllowed({
340+
inviteId,
341+
verifiedEmail: identity.email,
342+
});
343+
if (!isRegistrationAllowed) {
344+
throw new LogError(NO_INVITE_MESSAGE, { providerName: 'google', inviteId });
345+
}
346+
347+
try {
348+
return await db.user.create({
349+
data: {
350+
email: identity.email,
351+
firstName: identity.firstName,
352+
lastName: identity.lastName,
353+
accounts: {
354+
create: {
355+
provider: 'google',
356+
providerId: identity.id,
357+
email: identity.email,
358+
},
359+
},
360+
},
361+
});
362+
} catch (error) {
363+
// A concurrent first sign-in created the user first
364+
if (!isUniqueConstraintError(error)) {
365+
throw error;
366+
}
367+
return db.user.findFirstOrThrow({
368+
where: { email: { equals: identity.email, mode: 'insensitive' } },
369+
});
370+
}
213371
}
214372

215373
interface ValidatedOAuthQuery {
@@ -273,6 +431,12 @@ export async function githubCallback(req: FastifyRequest, reply: FastifyReply) {
273431
const inviteId = req.cookies.inviteId;
274432
const tokens = await github.validateAuthorizationCode(code);
275433
const githubUser = await fetchGithubUser(tokens.accessToken());
434+
// Belt and braces: signInOAuth already refuses GitHub with an allow-list
435+
if (!isGithubLoginEnabled() || !isEmailDomainAllowed(githubUser.email)) {
436+
throw new LogError(
437+
`Sign in with your ${describeAllowedDomains()} Google account`,
438+
);
439+
}
276440
const account = await db.account.findFirst({
277441
where: {
278442
OR: [
@@ -292,6 +456,7 @@ export async function githubCallback(req: FastifyRequest, reply: FastifyReply) {
292456
account,
293457
oauthUser: githubUser,
294458
providerName: 'github',
459+
inviteId,
295460
reply,
296461
});
297462
}
@@ -313,38 +478,27 @@ export async function googleCallback(req: FastifyRequest, reply: FastifyReply) {
313478
const { code } = await validateOAuthCallback(req, 'google');
314479
const inviteId = req.cookies.inviteId;
315480
const codeVerifier = req.cookies.google_code_verifier!;
316-
const tokens = await google.validateAuthorizationCode(code, codeVerifier);
317-
const googleUser = await fetchGoogleUser(tokens);
318-
const existingUser = await db.account.findFirst({
319-
where: {
320-
OR: [
321-
// To keep
322-
{ provider: 'google', providerId: googleUser.id },
323-
// During migration
324-
{ provider: 'google', providerId: null, email: googleUser.email },
325-
{ provider: 'oauth', user: { email: googleUser.email } },
326-
],
327-
},
328-
});
329-
330481
reply.clearCookie('google_code_verifier');
331482
reply.clearCookie('google_oauth_state');
332483

333-
if (existingUser) {
334-
return await handleExistingUser({
335-
account: existingUser,
336-
oauthUser: googleUser,
337-
providerName: 'google',
338-
reply,
339-
});
340-
}
341-
342-
return await handleNewUser({
343-
oauthUser: googleUser,
344-
providerName: 'google',
345-
inviteId,
346-
reply,
347-
});
484+
const tokens = await google.validateAuthorizationCode(code, codeVerifier);
485+
const identity = getGoogleIdentity(tokens);
486+
const user = await resolveGoogleUser(identity, inviteId);
487+
488+
// Google verified this email, so pending invites addressed to it can be
489+
// redeemed without the person ever clicking the invite link.
490+
await redeemInvites({ user, inviteId, claimByEmail: true, reply });
491+
492+
const sessionToken = generateSessionToken();
493+
const session = await createSession(sessionToken, user.id);
494+
setSessionTokenCookie(
495+
(...args) => reply.setCookie(...args),
496+
sessionToken,
497+
session.expiresAt,
498+
);
499+
return reply.redirect(
500+
process.env.DASHBOARD_URL || process.env.NEXT_PUBLIC_DASHBOARD_URL!,
501+
);
348502
} catch (error) {
349503
req.log.error(error);
350504
return redirectWithError(reply, error);

0 commit comments

Comments
 (0)