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
2 changes: 1 addition & 1 deletion apps/web/src/app/api/auth/__tests__/me.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@ describe('GET /api/auth/me', () => {
expect(body.id).toBe(mockUser.id);
expect(body.name).toBe(mockUser.name);
expect(body.email).toBe(mockUser.email);
expect(body.image).toBe(mockUser.image);
expect(body.image).toBeNull();
expect(body.role).toBe(mockUser.role);
// Date is serialized to ISO string in JSON response
expect(body.emailVerified).toBe(mockVerifiedDate.toISOString());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,18 @@ vi.mock('@pagespace/lib/security', () => ({
},
}));

vi.mock('@pagespace/db', () => ({
users: { id: 'id', image: 'image' },
db: {
update: vi.fn().mockReturnValue({
set: vi.fn().mockReturnValue({
where: vi.fn().mockResolvedValue(undefined),
}),
}),
},
eq: vi.fn((field: unknown, value: unknown) => ({ field, value })),
}));

vi.mock('@/lib/auth', () => ({
getClientIP: vi.fn().mockReturnValue('192.168.1.1'),
}));
Expand Down Expand Up @@ -191,7 +203,10 @@ describe('/api/auth/mobile/oauth/google/exchange', () => {

await POST(request);

expect(createOrLinkOAuthUser).toHaveBeenCalledWith(mockUserInfo);
expect(createOrLinkOAuthUser).toHaveBeenCalledWith({
...mockUserInfo,
picture: undefined,
});
});

it('creates device token for mobile platform', async () => {
Expand Down Expand Up @@ -737,7 +752,7 @@ describe('/api/auth/mobile/oauth/google/exchange', () => {
const response = await POST(request);
const body = await response.json();

expect(body.user.picture).toBe(mockUser.image);
expect(body.user.picture).toBeNull();
});

it('returns user role', async () => {
Expand Down
32 changes: 29 additions & 3 deletions apps/web/src/app/api/auth/google/callback/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import crypto from 'crypto';
import { provisionGettingStartedDriveIfNeeded } from '@/lib/onboarding/getting-started-drive';
import { getClientIP, isSafeReturnUrl } from '@/lib/auth';
import { appendSessionCookie } from '@/lib/auth/cookie-config';
import { resolveGoogleAvatarImage } from '@/lib/auth/google-avatar';

const googleCallbackSchema = z.object({
code: z.string().min(1, 'Authorization code is required'),
Expand Down Expand Up @@ -146,14 +147,25 @@ export async function GET(req: Request) {
});

if (user) {
if (!user.googleId || !user.name || user.image !== picture) {
const resolvedImage = await resolveGoogleAvatarImage({
userId: user.id,
pictureUrl: picture,
existingImage: user.image,
});

if (
!user.googleId ||
!user.name ||
user.image !== resolvedImage ||
(email_verified && !user.emailVerified)
) {
loggers.auth.info('Updating existing user via Google OAuth', { email });
await db.update(users)
.set({
googleId: googleId || user.googleId,
provider: user.password ? 'both' : 'google',
name: user.name || userName,
image: picture || user.image,
image: resolvedImage,
emailVerified: email_verified ? new Date() : user.emailVerified,
})
.where(eq(users.id, user.id));
Expand All @@ -170,7 +182,7 @@ export async function GET(req: Request) {
name: userName,
email,
emailVerified: email_verified ? new Date() : null,
image: picture || null,
image: null,
googleId,
provider: 'google',
tokenVersion: 0,
Expand All @@ -180,6 +192,20 @@ export async function GET(req: Request) {
}).returning();

user = newUser;

const resolvedImage = await resolveGoogleAvatarImage({
userId: user.id,
pictureUrl: picture,
existingImage: user.image,
});

if (resolvedImage !== (user.image ?? null)) {
await db.update(users)
.set({ image: resolvedImage })
.where(eq(users.id, user.id));
user = { ...user, image: resolvedImage };
}

loggers.auth.info('New user created via Google OAuth', { userId: user.id, name: user.name });
}

Expand Down
32 changes: 29 additions & 3 deletions apps/web/src/app/api/auth/google/native/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { z } from 'zod/v4';
import { provisionGettingStartedDriveIfNeeded } from '@/lib/onboarding/getting-started-drive';
import { getClientIP } from '@/lib/auth';
import { appendSessionCookie } from '@/lib/auth/cookie-config';
import { resolveGoogleAvatarImage } from '@/lib/auth/google-avatar';
import {
checkDistributedRateLimit,
resetDistributedRateLimit,
Expand Down Expand Up @@ -97,15 +98,26 @@ export async function POST(req: Request) {

let isNewUser = false;
if (user) {
const resolvedImage = await resolveGoogleAvatarImage({
userId: user.id,
pictureUrl: picture,
existingImage: user.image,
});

// Update existing user if needed
if (!user.googleId || !user.name || user.image !== picture) {
if (
!user.googleId ||
!user.name ||
user.image !== resolvedImage ||
(email_verified && !user.emailVerified)
) {
loggers.auth.info('Updating existing user via native Google OAuth', { email, platform });
await db.update(users)
.set({
googleId: googleId || user.googleId,
provider: user.password ? 'both' : 'google',
name: user.name || name || email.split('@')[0],
image: picture || user.image,
image: resolvedImage,
emailVerified: email_verified ? new Date() : user.emailVerified,
})
.where(eq(users.id, user.id));
Expand All @@ -122,7 +134,7 @@ export async function POST(req: Request) {
name: name || email.split('@')[0],
email,
emailVerified: email_verified ? new Date() : null,
image: picture || null,
image: null,
googleId,
provider: 'google',
tokenVersion: 0,
Expand All @@ -131,6 +143,20 @@ export async function POST(req: Request) {
subscriptionTier: 'free',
}).returning();
user = newUser;

const resolvedImage = await resolveGoogleAvatarImage({
userId: user.id,
pictureUrl: picture,
existingImage: user.image,
});

if (resolvedImage !== (user.image ?? null)) {
await db.update(users)
.set({ image: resolvedImage })
.where(eq(users.id, user.id));
user = { ...user, image: resolvedImage };
}

loggers.auth.info('New user created via native Google OAuth', { userId: user.id, platform });
}

Expand Down
33 changes: 30 additions & 3 deletions apps/web/src/app/api/auth/google/one-tap/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { NextResponse } from 'next/server';
import { provisionGettingStartedDriveIfNeeded } from '@/lib/onboarding/getting-started-drive';
import { getClientIP } from '@/lib/auth';
import { appendSessionCookie } from '@/lib/auth/cookie-config';
import { resolveGoogleAvatarImage } from '@/lib/auth/google-avatar';

const oneTapSchema = z.object({
credential: z.string().min(1, 'Credential is required'),
Expand Down Expand Up @@ -120,16 +121,27 @@ export async function POST(req: Request) {
let isNewUser = false;

if (user) {
const resolvedImage = await resolveGoogleAvatarImage({
userId: user.id,
pictureUrl: picture,
existingImage: user.image,
});

// Update existing user with Google ID if not set, or update other profile info
if (!user.googleId || !user.name || user.image !== picture) {
if (
!user.googleId ||
!user.name ||
user.image !== resolvedImage ||
(email_verified && !user.emailVerified)
) {
loggers.auth.info('Updating existing user via Google One Tap', { email });
await db
.update(users)
.set({
googleId: googleId || user.googleId,
provider: user.password ? 'both' : 'google',
name: user.name || userName,
image: picture || user.image,
image: resolvedImage,
emailVerified: email_verified ? new Date() : user.emailVerified,
})
.where(eq(users.id, user.id));
Expand All @@ -155,7 +167,7 @@ export async function POST(req: Request) {
name: userName,
email,
emailVerified: email_verified ? new Date() : null,
image: picture || null,
image: null,
googleId,
provider: 'google',
tokenVersion: 0,
Expand All @@ -166,6 +178,21 @@ export async function POST(req: Request) {
.returning();

user = newUser;

const resolvedImage = await resolveGoogleAvatarImage({
userId: user.id,
pictureUrl: picture,
existingImage: user.image,
});

if (resolvedImage !== (user.image ?? null)) {
await db
.update(users)
.set({ image: resolvedImage })
.where(eq(users.id, user.id));
user = { ...user, image: resolvedImage };
}

loggers.auth.info('New user created via Google One Tap', {
userId: user.id,
name: user.name,
Expand Down
7 changes: 5 additions & 2 deletions apps/web/src/app/api/auth/me/route.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { requireAuth, isAuthError } from '@/lib/auth/auth-helpers';
import { authRepository } from '@/lib/repositories/auth-repository';
import { isExternalHttpUrl } from '@/lib/auth/google-avatar';

export async function GET(req: Request) {
const auth = await requireAuth(req);
Expand All @@ -17,12 +18,14 @@ export async function GET(req: Request) {
console.log(`[AUTH] User profile loaded: ${user.email} (provider: ${user.provider}, id: ${user.id})`);
}

const safeImage = isExternalHttpUrl(user.image) ? null : user.image;

return Response.json({
id: user.id,
name: user.name,
email: user.email,
image: user.image,
image: safeImage,
role: user.role,
emailVerified: user.emailVerified,
});
}
}
21 changes: 20 additions & 1 deletion apps/web/src/app/api/auth/mobile/oauth/google/exchange/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ import {
generateCSRFToken,
validateOrCreateDeviceToken,
} from '@pagespace/lib/server';
import { db, users, eq } from '@pagespace/db';
import {
checkDistributedRateLimit,
resetDistributedRateLimit,
Expand All @@ -63,6 +64,7 @@ import { verifyOAuthIdToken, createOrLinkOAuthUser, OAuthProvider } from '@pages
import type { MobileOAuthResponse } from '@pagespace/lib/server';
import { getClientIP } from '@/lib/auth';
import { createSessionCookie } from '@/lib/auth/cookie-config';
import { resolveGoogleAvatarImage } from '@/lib/auth/google-avatar';

const oauthExchangeSchema = z.object({
idToken: z.string().min(1, 'ID token is required'),
Expand Down Expand Up @@ -214,7 +216,24 @@ export async function POST(req: Request) {
provider: userInfo.provider,
});

const user = await createOrLinkOAuthUser(userInfo);
const googlePictureUrl = userInfo.picture;
let user = await createOrLinkOAuthUser({
...userInfo,
picture: undefined,
});

const resolvedImage = await resolveGoogleAvatarImage({
userId: user.id,
pictureUrl: googlePictureUrl,
existingImage: user.image,
});

if (resolvedImage !== (user.image ?? null)) {
await db.update(users)
.set({ image: resolvedImage })
.where(eq(users.id, user.id));
user = { ...user, image: resolvedImage };
}

loggers.auth.info('OAuth user created/linked', {
userId: user.id,
Expand Down
24 changes: 24 additions & 0 deletions apps/web/src/components/ui/avatar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,27 @@ import * as AvatarPrimitive from "@radix-ui/react-avatar"

import { cn } from "@/lib/utils"

function sanitizeAvatarSrc(src: string | undefined): string | undefined {
if (!src) {
return src
}

if (!/^https?:\/\//i.test(src)) {
return src
}

if (typeof window === "undefined") {
return undefined
}

try {
const parsed = new URL(src, window.location.origin)
return parsed.origin === window.location.origin ? src : undefined
} catch {
return undefined
}
}

function Avatar({
className,
...props
Expand All @@ -26,12 +47,15 @@ function AvatarImage({
crossOrigin = "anonymous",
...props
}: React.ComponentProps<typeof AvatarPrimitive.Image>) {
const safeSrc = sanitizeAvatarSrc(typeof props.src === "string" ? props.src : undefined)

return (
<AvatarPrimitive.Image
data-slot="avatar-image"
className={cn("aspect-square size-full object-cover", className)}
crossOrigin={crossOrigin}
{...props}
src={safeSrc}
/>
)
}
Expand Down
Loading
Loading