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
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { describe, it, expect } from 'vitest';
import { buildHandoffBridgeHtml } from '../buildHandoffBridgeHtml';

describe('buildHandoffBridgeHtml', () => {
const baseParams = {
deepLink: 'pagespace://auth-exchange?code=abc123&provider=google',
title: "You're signed in",
body: 'Return to the desktop app — you can close this window.',
};

it('embeds the deep link in a meta refresh tag', () => {
const html = buildHandoffBridgeHtml(baseParams);
expect(html).toContain('http-equiv="refresh"');
// Ampersands inside attributes must be entity-encoded
expect(html).toContain('content="0; url=pagespace://auth-exchange?code=abc123&provider=google"');
});

it('embeds the deep link in the noscript anchor fallback', () => {
const html = buildHandoffBridgeHtml(baseParams);
expect(html).toContain('<noscript>');
expect(html).toContain('href="pagespace://auth-exchange?code=abc123&amp;provider=google"');
});

it('renders the title and body text', () => {
const html = buildHandoffBridgeHtml(baseParams);
expect(html).toContain('You&#39;re signed in');
expect(html).toContain('Return to the desktop app');
});

it('escapes HTML-significant characters in user-provided strings', () => {
const html = buildHandoffBridgeHtml({
deepLink: 'pagespace://auth?code=<script>alert(1)</script>',
title: '<img src=x onerror=alert(1)>',
body: 'A "quoted" & < > value',
});
expect(html).not.toContain('<script>alert(1)</script>');
expect(html).not.toContain('<img src=x');
expect(html).toContain('&lt;script&gt;');
expect(html).toContain('&lt;img');
expect(html).toContain('&quot;quoted&quot;');
});

it('contains no inline <script> tags', () => {
const html = buildHandoffBridgeHtml(baseParams);
expect(html).not.toMatch(/<script[\s>]/i);
});

it('declares a doctype and html lang', () => {
const html = buildHandoffBridgeHtml(baseParams);
expect(html).toMatch(/^<!DOCTYPE html>/);
expect(html).toContain('<html lang="en">');
});
});
87 changes: 87 additions & 0 deletions apps/web/src/app/api/auth/_shared/buildHandoffBridgeHtml.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
interface BuildHandoffBridgeHtmlParams {
deepLink: string;
title: string;
body: string;
}

const escapeHtml = (input: string): string =>
input
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');

export function buildHandoffBridgeHtml({
deepLink,
title,
body,
}: BuildHandoffBridgeHtmlParams): string {
const safeDeepLink = escapeHtml(deepLink);
const safeTitle = escapeHtml(title);
const safeBody = escapeHtml(body);

return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta http-equiv="refresh" content="0; url=${safeDeepLink}">
<title>${safeTitle} — PageSpace</title>
<style>
html, body { margin: 0; padding: 0; height: 100%; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, sans-serif;
background: linear-gradient(135deg, #eff6ff 0%, #dbeafe 50%, #bfdbfe 100%);
color: #0f172a;
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
}
@media (prefers-color-scheme: dark) {
body {
background: linear-gradient(135deg, #020617 0%, #0c1f3d 50%, #0f172a 100%);
color: #e2e8f0;
}
.card { background: rgba(15, 23, 42, 0.7); border-color: rgba(148, 163, 184, 0.18); }
.body-text { color: #94a3b8; }
}
.card {
max-width: 420px;
width: calc(100% - 48px);
padding: 32px 28px;
border-radius: 16px;
background: rgba(255, 255, 255, 0.85);
border: 1px solid rgba(148, 163, 184, 0.25);
box-shadow: 0 24px 60px -20px rgba(15, 23, 42, 0.25);
text-align: center;
}
.check {
width: 48px;
height: 48px;
margin: 0 auto 16px;
color: #10b981;
}
.title { font-size: 18px; font-weight: 600; margin: 0 0 8px; }
.body-text { font-size: 14px; color: #475569; margin: 0 0 20px; line-height: 1.5; }
.fallback { font-size: 12px; margin-top: 16px; }
.fallback a { color: #2563eb; text-decoration: none; }
.fallback a:hover { text-decoration: underline; }
</style>
</head>
<body>
<main class="card">
<svg class="check" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"></path>
<polyline points="22 4 12 14.01 9 11.01"></polyline>
</svg>
<p class="title">${safeTitle}</p>
<p class="body-text">${safeBody}</p>
<noscript>
<p class="fallback">If you are not redirected automatically, <a href="${safeDeepLink}">click here to return to PageSpace</a>.</p>
</noscript>
</main>
</body>
</html>`;
}
30 changes: 30 additions & 0 deletions apps/web/src/app/api/auth/_shared/handoffBridgeResponse.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { NextResponse } from 'next/server';
import { buildHandoffBridgeHtml } from './buildHandoffBridgeHtml';

const HANDOFF_BRIDGE_CSP =
"default-src 'none'; style-src 'unsafe-inline'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'";

const HANDOFF_BRIDGE_BODY =
'Return to the PageSpace desktop app — you can safely close this window.';

export const buildHandoffBridgeResponse = (
deepLink: string,
title: string,
): NextResponse => {
const html = buildHandoffBridgeHtml({
deepLink,
title,
body: HANDOFF_BRIDGE_BODY,
});
return new NextResponse(html, {
status: 200,
headers: {
'Content-Type': 'text/html; charset=utf-8',
'Cache-Control': 'no-store',
'Referrer-Policy': 'no-referrer',
'Content-Security-Policy': HANDOFF_BRIDGE_CSP,
'X-Frame-Options': 'DENY',
'X-Content-Type-Options': 'nosniff',
},
});
};
42 changes: 34 additions & 8 deletions apps/web/src/app/api/auth/apple/callback/__tests__/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -782,7 +782,7 @@ describe('POST /api/auth/apple/callback', () => {
});

describe('desktop platform redirect', () => {
it('redirects to deep link with exchange code', async () => {
it('returns handoff bridge HTML containing deep link with exchange code', async () => {
const state = createSignedState({
returnUrl: '/dashboard',
platform: 'desktop',
Expand All @@ -797,11 +797,37 @@ describe('POST /api/auth/apple/callback', () => {

const response = await POST(request);

expect(response.status).toBe(307);
const location = response.headers.get('Location')!;
expect(location).toContain('pagespace://auth-exchange');
expect(location).toContain('code=mock-exchange-code');
expect(location).toContain('provider=apple');
expect(response.status).toBe(200);
expect(response.headers.get('Content-Type')).toContain('text/html');
const body = await response.text();
expect(body).toContain('pagespace://auth-exchange');
expect(body).toContain('code=mock-exchange-code');
expect(body).toContain('provider=apple');
expect(body).toContain('http-equiv="refresh"');
});

it('emits hardened security headers on the handoff bridge response', async () => {
const state = createSignedState({
returnUrl: '/dashboard',
platform: 'desktop',
deviceId: 'desktop-dev-123',
deviceName: 'My Mac',
});

const request = createCallbackRequest({ id_token: 'valid-token', state });
const response = await POST(request);

const csp = response.headers.get('Content-Security-Policy') ?? '';
expect(csp).toContain("default-src 'none'");
expect(csp).toContain("style-src 'unsafe-inline'");
expect(csp).toContain("base-uri 'none'");
expect(csp).toContain("form-action 'none'");
expect(csp).toContain("frame-ancestors 'none'");
expect(csp).not.toContain('img-src');
expect(response.headers.get('Cache-Control')).toBe('no-store');
expect(response.headers.get('X-Frame-Options')).toBe('DENY');
expect(response.headers.get('X-Content-Type-Options')).toBe('nosniff');
expect(response.headers.get('Referrer-Policy')).toBe('no-referrer');
});

it('includes isNewUser flag in deep link for new users', async () => {
Expand All @@ -822,9 +848,9 @@ describe('POST /api/auth/apple/callback', () => {
});

const response = await POST(request);
const location = response.headers.get('Location')!;
const body = await response.text();

expect(location).toContain('isNewUser=true');
expect(body).toContain('isNewUser=true');
});

it('redirects with error when desktop platform has no deviceId', async () => {
Expand Down
5 changes: 3 additions & 2 deletions apps/web/src/app/api/auth/apple/callback/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { getClientIP, isSafeReturnUrl } from '@/lib/auth';
import { verifyOAuthState } from '@/lib/auth/oauth-state';
import { appendSessionCookie, createDeviceTokenHandoffCookie } from '@/lib/auth/cookie-config';
import { authRepository } from '@/lib/repositories/auth-repository';
import { buildHandoffBridgeResponse } from '@/app/api/auth/_shared/handoffBridgeResponse';

// Apple sends name info as JSON in the 'user' field (only on first authorization)
const appleUserSchema = z.object({
Expand Down Expand Up @@ -257,13 +258,13 @@ export async function POST(req: Request) {
deepLinkUrl.searchParams.set('isNewUser', 'true');
}

loggers.auth.info('Desktop Apple OAuth deep link redirect', {
loggers.auth.info('Desktop Apple OAuth deep link bridge', {
userId: user.id,
provider: 'apple',
hasNewUserFlag: isNewlyProvisioned,
});

return NextResponse.redirect(deepLinkUrl.toString());
return buildHandoffBridgeResponse(deepLinkUrl.toString(), "You're signed in");
}

// iOS PLATFORM: Same as desktop - use secure exchange code flow
Expand Down
42 changes: 34 additions & 8 deletions apps/web/src/app/api/auth/google/callback/__tests__/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -796,7 +796,7 @@ describe('GET /api/auth/google/callback', () => {
});

describe('desktop platform redirect', () => {
it('redirects to deep link with exchange code', async () => {
it('returns handoff bridge HTML containing deep link with exchange code', async () => {
const state = createSignedState({
returnUrl: '/dashboard',
platform: 'desktop',
Expand All @@ -807,11 +807,37 @@ describe('GET /api/auth/google/callback', () => {
const request = createCallbackRequest({ code: 'valid-code', state });
const response = await GET(request);

expect(response.status).toBe(307);
const location = response.headers.get('Location')!;
expect(location).toContain('pagespace://auth-exchange');
expect(location).toContain('code=mock-exchange-code');
expect(location).toContain('provider=google');
expect(response.status).toBe(200);
expect(response.headers.get('Content-Type')).toContain('text/html');
const body = await response.text();
expect(body).toContain('pagespace://auth-exchange');
expect(body).toContain('code=mock-exchange-code');
expect(body).toContain('provider=google');
expect(body).toContain('http-equiv="refresh"');
});

it('emits hardened security headers on the handoff bridge response', async () => {
const state = createSignedState({
returnUrl: '/dashboard',
platform: 'desktop',
deviceId: 'desktop-dev-123',
deviceName: 'My Mac',
});

const request = createCallbackRequest({ code: 'valid-code', state });
const response = await GET(request);

const csp = response.headers.get('Content-Security-Policy') ?? '';
expect(csp).toContain("default-src 'none'");
expect(csp).toContain("style-src 'unsafe-inline'");
expect(csp).toContain("base-uri 'none'");
expect(csp).toContain("form-action 'none'");
expect(csp).toContain("frame-ancestors 'none'");
expect(csp).not.toContain('img-src');
expect(response.headers.get('Cache-Control')).toBe('no-store');
expect(response.headers.get('X-Frame-Options')).toBe('DENY');
expect(response.headers.get('X-Content-Type-Options')).toBe('nosniff');
expect(response.headers.get('Referrer-Policy')).toBe('no-referrer');
});

it('includes isNewUser flag in deep link for newly provisioned drives', async () => {
Expand All @@ -828,9 +854,9 @@ describe('GET /api/auth/google/callback', () => {

const request = createCallbackRequest({ code: 'valid-code', state });
const response = await GET(request);
const location = response.headers.get('Location')!;
const body = await response.text();

expect(location).toContain('isNewUser=true');
expect(body).toContain('isNewUser=true');
});

it('redirects with error when desktop platform has no deviceId', async () => {
Expand Down
5 changes: 3 additions & 2 deletions apps/web/src/app/api/auth/google/callback/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { appendSessionCookie, createDeviceTokenHandoffCookie } from '@/lib/auth/
import { resolveGoogleAvatarImage } from '@/lib/auth/google-avatar';
import { consumePKCEVerifier } from '@pagespace/lib/auth';
import { authRepository } from '@/lib/repositories/auth-repository';
import { buildHandoffBridgeResponse } from '@/app/api/auth/_shared/handoffBridgeResponse';

const client = new OAuth2Client(
process.env.GOOGLE_OAUTH_CLIENT_ID,
Expand Down Expand Up @@ -295,13 +296,13 @@ export async function GET(req: Request) {
deepLinkUrl.searchParams.set('isNewUser', 'true');
}

loggers.auth.info('Desktop OAuth deep link redirect', {
loggers.auth.info('Desktop OAuth deep link bridge', {
userId: user.id,
provider: 'google',
hasNewUserFlag: isNewlyProvisioned,
});

return NextResponse.redirect(deepLinkUrl.toString());
return buildHandoffBridgeResponse(deepLinkUrl.toString(), "You're signed in");
}

// iOS PLATFORM: Same as desktop - use secure exchange code flow
Expand Down
Loading
Loading