11import { LogError } from '@/utils/errors' ;
22import {
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' ;
1225import type { FastifyReply , FastifyRequest } from 'fastify' ;
1326import { 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+
51105async 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
215373interface 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