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
65 changes: 65 additions & 0 deletions freebuff/web/src/app/api/auth/cli/code/__tests__/origin.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { describe, expect, test } from 'bun:test'

import { getLoginUrlOrigin } from '../_origin'

describe('api/auth/cli/code/_origin', () => {
test('uses the configured public app URL over the request origin', () => {
const req = new Request('https://localhost:10000/api/auth/cli/code')

expect(
getLoginUrlOrigin(
req,
'https://freebuff.com',
'https://freebuff.com',
false,
),
).toBe('https://freebuff.com')
})

test('ignores a localhost configured URL in production', () => {
const req = new Request('https://localhost:10000/api/auth/cli/code')

expect(
getLoginUrlOrigin(
req,
'https://localhost:10000',
'https://freebuff.com',
false,
),
).toBe('https://freebuff.com')
})

test('ignores IPv6 localhost in production', () => {
const req = new Request('http://[::1]:3002/api/auth/cli/code')

expect(
getLoginUrlOrigin(
req,
'http://[::1]:3002',
'https://freebuff.com',
false,
),
).toBe('https://freebuff.com')
})

test('allows a localhost configured URL outside production', () => {
const req = new Request('http://localhost:3002/api/auth/cli/code')

expect(
getLoginUrlOrigin(
req,
'http://localhost:3002',
'https://freebuff.com',
true,
),
).toBe('http://localhost:3002')
})

test('falls back to the request origin when configured URL is invalid', () => {
const req = new Request('http://localhost:3002/api/auth/cli/code')

expect(
getLoginUrlOrigin(req, 'not a url', 'https://freebuff.com', true),
).toBe('http://localhost:3002')
})
})
35 changes: 35 additions & 0 deletions freebuff/web/src/app/api/auth/cli/code/_origin.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
export function getLoginUrlOrigin(
req: Request,
configuredAppUrl: string,
fallbackOrigin: string,
allowLocalhost: boolean,
): string {
const configuredOrigin = getUsableOrigin(configuredAppUrl, allowLocalhost)
if (configuredOrigin) {
return configuredOrigin
}

return getUsableOrigin(req.url, allowLocalhost) ?? fallbackOrigin
}

function getUsableOrigin(url: string, allowLocalhost: boolean) {
try {
const parsedUrl = new URL(url)
if (!allowLocalhost && isLocalhost(parsedUrl.hostname)) {
return null
}
return parsedUrl.origin
} catch {
return null
}
}

function isLocalhost(hostname: string) {
const normalizedHostname = hostname.replace(/^\[|\]$/g, '')
return (
normalizedHostname === 'localhost' ||
normalizedHostname === '127.0.0.1' ||
normalizedHostname === '0.0.0.0' ||
normalizedHostname === '::1'
)
}
14 changes: 11 additions & 3 deletions freebuff/web/src/app/api/auth/cli/code/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import { z } from 'zod/v4'

import { logger } from '@/util/logger'

import { getLoginUrlOrigin } from './_origin'

export async function POST(req: Request) {
const reqSchema = z.object({
fingerprintId: z.string(),
Expand Down Expand Up @@ -53,9 +55,15 @@ export async function POST(req: Request) {
)
}

// Generate login URL on the same origin that issued the auth code. This
// avoids bouncing between apex/www hosts during the browser OAuth flow.
const loginUrl = new URL('/login', new URL(req.url).origin)
const loginUrl = new URL(
'/login',
getLoginUrlOrigin(
req,
env.NEXT_PUBLIC_CODEBUFF_APP_URL,
'https://freebuff.com',
env.NEXT_PUBLIC_CB_ENVIRONMENT !== 'prod',
),
)
loginUrl.searchParams.set(
'auth_code',
`${fingerprintId}.${expiresAt}.${fingerprintHash}`,
Expand Down
65 changes: 65 additions & 0 deletions web/src/app/api/auth/cli/code/__tests__/origin.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { describe, expect, test } from 'bun:test'

import { getLoginUrlOrigin } from '../_origin'

describe('api/auth/cli/code/_origin', () => {
test('uses the configured public app URL over the request origin', () => {
const req = new Request('https://localhost:10000/api/auth/cli/code')

expect(
getLoginUrlOrigin(
req,
'https://www.codebuff.com',
'https://codebuff.com',
false,
),
).toBe('https://www.codebuff.com')
})

test('ignores a localhost configured URL in production', () => {
const req = new Request('https://localhost:10000/api/auth/cli/code')

expect(
getLoginUrlOrigin(
req,
'https://localhost:10000',
'https://codebuff.com',
false,
),
).toBe('https://codebuff.com')
})

test('ignores IPv6 localhost in production', () => {
const req = new Request('http://[::1]:3000/api/auth/cli/code')

expect(
getLoginUrlOrigin(
req,
'http://[::1]:3000',
'https://codebuff.com',
false,
),
).toBe('https://codebuff.com')
})

test('allows a localhost configured URL outside production', () => {
const req = new Request('http://localhost:3000/api/auth/cli/code')

expect(
getLoginUrlOrigin(
req,
'http://localhost:3000',
'https://codebuff.com',
true,
),
).toBe('http://localhost:3000')
})

test('falls back to the request origin when configured URL is invalid', () => {
const req = new Request('http://localhost:3000/api/auth/cli/code')

expect(
getLoginUrlOrigin(req, 'not a url', 'https://codebuff.com', true),
).toBe('http://localhost:3000')
})
})
35 changes: 35 additions & 0 deletions web/src/app/api/auth/cli/code/_origin.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
export function getLoginUrlOrigin(
req: Request,
configuredAppUrl: string,
fallbackOrigin: string,
allowLocalhost: boolean,
): string {
const configuredOrigin = getUsableOrigin(configuredAppUrl, allowLocalhost)
if (configuredOrigin) {
return configuredOrigin
}

return getUsableOrigin(req.url, allowLocalhost) ?? fallbackOrigin
}

function getUsableOrigin(url: string, allowLocalhost: boolean) {
try {
const parsedUrl = new URL(url)
if (!allowLocalhost && isLocalhost(parsedUrl.hostname)) {
return null
}
return parsedUrl.origin
} catch {
return null
}
}

function isLocalhost(hostname: string) {
const normalizedHostname = hostname.replace(/^\[|\]$/g, '')
return (
normalizedHostname === 'localhost' ||
normalizedHostname === '127.0.0.1' ||
normalizedHostname === '0.0.0.0' ||
normalizedHostname === '::1'
)
}
14 changes: 11 additions & 3 deletions web/src/app/api/auth/cli/code/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import { z } from 'zod/v4'

import { logger } from '@/util/logger'

import { getLoginUrlOrigin } from './_origin'

export async function POST(req: Request) {
const reqSchema = z.object({
fingerprintId: z.string(),
Expand Down Expand Up @@ -55,9 +57,15 @@ export async function POST(req: Request) {
)
}

// Generate login URL on the same origin that issued the auth code. This
// avoids bouncing between apex/www hosts during the browser OAuth flow.
const loginUrl = new URL('/login', new URL(req.url).origin)
const loginUrl = new URL(
'/login',
getLoginUrlOrigin(
req,
env.NEXT_PUBLIC_CODEBUFF_APP_URL,
'https://codebuff.com',
env.NEXT_PUBLIC_CB_ENVIRONMENT !== 'prod',
),
)
loginUrl.searchParams.set(
'auth_code',
`${fingerprintId}.${expiresAt}.${fingerprintHash}`,
Expand Down
Loading