Skip to content

Commit 0228f79

Browse files
authored
Fix CLI login URL origin (#613)
1 parent 5c8b5ce commit 0228f79

6 files changed

Lines changed: 222 additions & 6 deletions

File tree

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import { describe, expect, test } from 'bun:test'
2+
3+
import { getLoginUrlOrigin } from '../_origin'
4+
5+
describe('api/auth/cli/code/_origin', () => {
6+
test('uses the configured public app URL over the request origin', () => {
7+
const req = new Request('https://localhost:10000/api/auth/cli/code')
8+
9+
expect(
10+
getLoginUrlOrigin(
11+
req,
12+
'https://freebuff.com',
13+
'https://freebuff.com',
14+
false,
15+
),
16+
).toBe('https://freebuff.com')
17+
})
18+
19+
test('ignores a localhost configured URL in production', () => {
20+
const req = new Request('https://localhost:10000/api/auth/cli/code')
21+
22+
expect(
23+
getLoginUrlOrigin(
24+
req,
25+
'https://localhost:10000',
26+
'https://freebuff.com',
27+
false,
28+
),
29+
).toBe('https://freebuff.com')
30+
})
31+
32+
test('ignores IPv6 localhost in production', () => {
33+
const req = new Request('http://[::1]:3002/api/auth/cli/code')
34+
35+
expect(
36+
getLoginUrlOrigin(
37+
req,
38+
'http://[::1]:3002',
39+
'https://freebuff.com',
40+
false,
41+
),
42+
).toBe('https://freebuff.com')
43+
})
44+
45+
test('allows a localhost configured URL outside production', () => {
46+
const req = new Request('http://localhost:3002/api/auth/cli/code')
47+
48+
expect(
49+
getLoginUrlOrigin(
50+
req,
51+
'http://localhost:3002',
52+
'https://freebuff.com',
53+
true,
54+
),
55+
).toBe('http://localhost:3002')
56+
})
57+
58+
test('falls back to the request origin when configured URL is invalid', () => {
59+
const req = new Request('http://localhost:3002/api/auth/cli/code')
60+
61+
expect(
62+
getLoginUrlOrigin(req, 'not a url', 'https://freebuff.com', true),
63+
).toBe('http://localhost:3002')
64+
})
65+
})
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
export function getLoginUrlOrigin(
2+
req: Request,
3+
configuredAppUrl: string,
4+
fallbackOrigin: string,
5+
allowLocalhost: boolean,
6+
): string {
7+
const configuredOrigin = getUsableOrigin(configuredAppUrl, allowLocalhost)
8+
if (configuredOrigin) {
9+
return configuredOrigin
10+
}
11+
12+
return getUsableOrigin(req.url, allowLocalhost) ?? fallbackOrigin
13+
}
14+
15+
function getUsableOrigin(url: string, allowLocalhost: boolean) {
16+
try {
17+
const parsedUrl = new URL(url)
18+
if (!allowLocalhost && isLocalhost(parsedUrl.hostname)) {
19+
return null
20+
}
21+
return parsedUrl.origin
22+
} catch {
23+
return null
24+
}
25+
}
26+
27+
function isLocalhost(hostname: string) {
28+
const normalizedHostname = hostname.replace(/^\[|\]$/g, '')
29+
return (
30+
normalizedHostname === 'localhost' ||
31+
normalizedHostname === '127.0.0.1' ||
32+
normalizedHostname === '0.0.0.0' ||
33+
normalizedHostname === '::1'
34+
)
35+
}

freebuff/web/src/app/api/auth/cli/code/route.ts

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ import { z } from 'zod/v4'
88

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

11+
import { getLoginUrlOrigin } from './_origin'
12+
1113
export async function POST(req: Request) {
1214
const reqSchema = z.object({
1315
fingerprintId: z.string(),
@@ -53,9 +55,15 @@ export async function POST(req: Request) {
5355
)
5456
}
5557

56-
// Generate login URL on the same origin that issued the auth code. This
57-
// avoids bouncing between apex/www hosts during the browser OAuth flow.
58-
const loginUrl = new URL('/login', new URL(req.url).origin)
58+
const loginUrl = new URL(
59+
'/login',
60+
getLoginUrlOrigin(
61+
req,
62+
env.NEXT_PUBLIC_CODEBUFF_APP_URL,
63+
'https://freebuff.com',
64+
env.NEXT_PUBLIC_CB_ENVIRONMENT !== 'prod',
65+
),
66+
)
5967
loginUrl.searchParams.set(
6068
'auth_code',
6169
`${fingerprintId}.${expiresAt}.${fingerprintHash}`,
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import { describe, expect, test } from 'bun:test'
2+
3+
import { getLoginUrlOrigin } from '../_origin'
4+
5+
describe('api/auth/cli/code/_origin', () => {
6+
test('uses the configured public app URL over the request origin', () => {
7+
const req = new Request('https://localhost:10000/api/auth/cli/code')
8+
9+
expect(
10+
getLoginUrlOrigin(
11+
req,
12+
'https://www.codebuff.com',
13+
'https://codebuff.com',
14+
false,
15+
),
16+
).toBe('https://www.codebuff.com')
17+
})
18+
19+
test('ignores a localhost configured URL in production', () => {
20+
const req = new Request('https://localhost:10000/api/auth/cli/code')
21+
22+
expect(
23+
getLoginUrlOrigin(
24+
req,
25+
'https://localhost:10000',
26+
'https://codebuff.com',
27+
false,
28+
),
29+
).toBe('https://codebuff.com')
30+
})
31+
32+
test('ignores IPv6 localhost in production', () => {
33+
const req = new Request('http://[::1]:3000/api/auth/cli/code')
34+
35+
expect(
36+
getLoginUrlOrigin(
37+
req,
38+
'http://[::1]:3000',
39+
'https://codebuff.com',
40+
false,
41+
),
42+
).toBe('https://codebuff.com')
43+
})
44+
45+
test('allows a localhost configured URL outside production', () => {
46+
const req = new Request('http://localhost:3000/api/auth/cli/code')
47+
48+
expect(
49+
getLoginUrlOrigin(
50+
req,
51+
'http://localhost:3000',
52+
'https://codebuff.com',
53+
true,
54+
),
55+
).toBe('http://localhost:3000')
56+
})
57+
58+
test('falls back to the request origin when configured URL is invalid', () => {
59+
const req = new Request('http://localhost:3000/api/auth/cli/code')
60+
61+
expect(
62+
getLoginUrlOrigin(req, 'not a url', 'https://codebuff.com', true),
63+
).toBe('http://localhost:3000')
64+
})
65+
})
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
export function getLoginUrlOrigin(
2+
req: Request,
3+
configuredAppUrl: string,
4+
fallbackOrigin: string,
5+
allowLocalhost: boolean,
6+
): string {
7+
const configuredOrigin = getUsableOrigin(configuredAppUrl, allowLocalhost)
8+
if (configuredOrigin) {
9+
return configuredOrigin
10+
}
11+
12+
return getUsableOrigin(req.url, allowLocalhost) ?? fallbackOrigin
13+
}
14+
15+
function getUsableOrigin(url: string, allowLocalhost: boolean) {
16+
try {
17+
const parsedUrl = new URL(url)
18+
if (!allowLocalhost && isLocalhost(parsedUrl.hostname)) {
19+
return null
20+
}
21+
return parsedUrl.origin
22+
} catch {
23+
return null
24+
}
25+
}
26+
27+
function isLocalhost(hostname: string) {
28+
const normalizedHostname = hostname.replace(/^\[|\]$/g, '')
29+
return (
30+
normalizedHostname === 'localhost' ||
31+
normalizedHostname === '127.0.0.1' ||
32+
normalizedHostname === '0.0.0.0' ||
33+
normalizedHostname === '::1'
34+
)
35+
}

web/src/app/api/auth/cli/code/route.ts

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ import { z } from 'zod/v4'
88

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

11+
import { getLoginUrlOrigin } from './_origin'
12+
1113
export async function POST(req: Request) {
1214
const reqSchema = z.object({
1315
fingerprintId: z.string(),
@@ -55,9 +57,15 @@ export async function POST(req: Request) {
5557
)
5658
}
5759

58-
// Generate login URL on the same origin that issued the auth code. This
59-
// avoids bouncing between apex/www hosts during the browser OAuth flow.
60-
const loginUrl = new URL('/login', new URL(req.url).origin)
60+
const loginUrl = new URL(
61+
'/login',
62+
getLoginUrlOrigin(
63+
req,
64+
env.NEXT_PUBLIC_CODEBUFF_APP_URL,
65+
'https://codebuff.com',
66+
env.NEXT_PUBLIC_CB_ENVIRONMENT !== 'prod',
67+
),
68+
)
6169
loginUrl.searchParams.set(
6270
'auth_code',
6371
`${fingerprintId}.${expiresAt}.${fingerprintHash}`,

0 commit comments

Comments
 (0)