Skip to content

Commit 43de45d

Browse files
feat(cli-auth): device-authorization poll flow, drop the loopback listener
The CLI no longer binds a local port. It generates a request id + poll secret, opens /cli/auth, and polls /api/cli/auth/poll over TLS while the user approves in the browser — so the flow works over SSH and inside containers, where the browser and terminal don't share a machine. - approve stores the approval keyed by request id (session-authed, userId from the session only); poll verifies the secret before an atomic claim, so an observer of the semi-public request id can neither mint nor cancel it - pairing code stays as the anti-phishing compare; no key ever crosses the browser; done page just confirms - removes the loopback listener, /token exchange, buildCliHandoffUrl, and validateCliCallbackUrl (+ its tests) — nothing hands a key to a URL anymore
1 parent 209e6ec commit 43de45d

21 files changed

Lines changed: 451 additions & 658 deletions

apps/sim/app/api/cli/auth/approve/route.test.ts

Lines changed: 25 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,9 @@ import { createHash } from 'node:crypto'
55
import { createMockRequest } from '@sim/testing'
66
import { beforeEach, describe, expect, it, vi } from 'vitest'
77

8-
const { mockGetSession, mockCreateAuthCode, mockEnforceUserRateLimit } = vi.hoisted(() => ({
8+
const { mockGetSession, mockCreateApproval, mockEnforceUserRateLimit } = vi.hoisted(() => ({
99
mockGetSession: vi.fn(),
10-
mockCreateAuthCode: vi.fn(),
10+
mockCreateApproval: vi.fn(),
1111
mockEnforceUserRateLimit: vi.fn(),
1212
}))
1313

@@ -16,8 +16,8 @@ vi.mock('@/lib/auth', () => ({
1616
getSession: mockGetSession,
1717
}))
1818

19-
vi.mock('@/lib/cli-auth/code-store', () => ({
20-
createAuthCode: mockCreateAuthCode,
19+
vi.mock('@/lib/cli-auth/approval-store', () => ({
20+
createApproval: mockCreateApproval,
2121
}))
2222

2323
vi.mock('@/lib/core/rate-limiter', () => ({
@@ -26,43 +26,47 @@ vi.mock('@/lib/core/rate-limiter', () => ({
2626

2727
import { POST } from '@/app/api/cli/auth/approve/route'
2828

29-
const CHALLENGE = createHash('sha256').update('a'.repeat(43)).digest('base64url')
29+
const REQUEST = 'a'.repeat(43)
30+
const CHALLENGE = createHash('sha256').update('b'.repeat(43)).digest('base64url')
3031

3132
describe('POST /api/cli/auth/approve', () => {
3233
beforeEach(() => {
3334
vi.clearAllMocks()
3435
mockGetSession.mockResolvedValue({ user: { id: 'user-1' } })
3536
mockEnforceUserRateLimit.mockResolvedValue(null)
36-
mockCreateAuthCode.mockResolvedValue('generated-code')
37+
mockCreateApproval.mockResolvedValue(undefined)
3738
})
3839

39-
it('issues a code for the signed-in user', async () => {
40-
const response = await POST(createMockRequest('POST', { challenge: CHALLENGE }))
41-
40+
it('records the approval for the signed-in user', async () => {
41+
const response = await POST(
42+
createMockRequest('POST', { request: REQUEST, challenge: CHALLENGE })
43+
)
4244
expect(response.status).toBe(200)
43-
await expect(response.json()).resolves.toEqual({ code: 'generated-code' })
44-
expect(mockCreateAuthCode).toHaveBeenCalledWith('user-1', CHALLENGE)
45+
await expect(response.json()).resolves.toEqual({ ok: true })
46+
expect(mockCreateApproval).toHaveBeenCalledWith('user-1', REQUEST, CHALLENGE)
4547
})
4648

4749
it('rejects an unauthenticated caller', async () => {
4850
mockGetSession.mockResolvedValue(null)
49-
50-
const response = await POST(createMockRequest('POST', { challenge: CHALLENGE }))
51-
51+
const response = await POST(
52+
createMockRequest('POST', { request: REQUEST, challenge: CHALLENGE })
53+
)
5254
expect(response.status).toBe(401)
53-
expect(mockCreateAuthCode).not.toHaveBeenCalled()
55+
expect(mockCreateApproval).not.toHaveBeenCalled()
5456
})
5557

5658
it('ignores a user id supplied in the body', async () => {
57-
await POST(createMockRequest('POST', { challenge: CHALLENGE, userId: 'attacker' }))
58-
59-
expect(mockCreateAuthCode).toHaveBeenCalledWith('user-1', CHALLENGE)
59+
await POST(
60+
createMockRequest('POST', { request: REQUEST, challenge: CHALLENGE, userId: 'attacker' })
61+
)
62+
expect(mockCreateApproval).toHaveBeenCalledWith('user-1', REQUEST, CHALLENGE)
6063
})
6164

6265
it('rejects a malformed challenge', async () => {
63-
const response = await POST(createMockRequest('POST', { challenge: 'not-a-digest' }))
64-
66+
const response = await POST(
67+
createMockRequest('POST', { request: REQUEST, challenge: 'not-a-digest' })
68+
)
6569
expect(response.status).toBe(400)
66-
expect(mockCreateAuthCode).not.toHaveBeenCalled()
70+
expect(mockCreateApproval).not.toHaveBeenCalled()
6771
})
6872
})

apps/sim/app/api/cli/auth/approve/route.ts

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,16 +3,19 @@ import { type NextRequest, NextResponse } from 'next/server'
33
import { approveCliAuthContract } from '@/lib/api/contracts'
44
import { parseRequest } from '@/lib/api/server'
55
import { getSession } from '@/lib/auth'
6-
import { createAuthCode } from '@/lib/cli-auth/code-store'
6+
import { createApproval } from '@/lib/cli-auth/approval-store'
77
import { enforceUserRateLimit } from '@/lib/core/rate-limiter'
88
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
99

1010
const logger = createLogger('CliAuthApproveAPI')
1111

1212
/**
13+
* Records a signed-in user's approval of a CLI handoff so the waiting terminal's
14+
* poll can complete.
15+
*
1316
* The approving user comes from the session and nothing else — a client-supplied
14-
* user id here would let any caller mint a code redeemable for someone else's
15-
* key. No key is generated until redemption.
17+
* user id here would let any caller approve a request redeemable for someone
18+
* else's key. No key is generated until the CLI polls.
1619
*/
1720
export const POST = withRouteHandler(async (request: NextRequest) => {
1821
const session = await getSession()
@@ -26,8 +29,8 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
2629
const parsed = await parseRequest(approveCliAuthContract, request, {})
2730
if (!parsed.success) return parsed.response
2831

29-
const code = await createAuthCode(session.user.id, parsed.data.body.challenge)
30-
logger.info('Issued CLI authorization code', { userId: session.user.id })
32+
await createApproval(session.user.id, parsed.data.body.request, parsed.data.body.challenge)
33+
logger.info('Recorded CLI authorization approval', { userId: session.user.id })
3134

32-
return NextResponse.json({ code })
35+
return NextResponse.json({ ok: true })
3336
})
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { createMockRequest } from '@sim/testing'
5+
import { beforeEach, describe, expect, it, vi } from 'vitest'
6+
7+
const { mockPollApproval, mockGenerateCopilotApiKey, mockEnforceIpRateLimit } = vi.hoisted(() => ({
8+
mockPollApproval: vi.fn(),
9+
mockGenerateCopilotApiKey: vi.fn(),
10+
mockEnforceIpRateLimit: vi.fn(),
11+
}))
12+
13+
vi.mock('@/lib/cli-auth/approval-store', () => ({
14+
pollApproval: mockPollApproval,
15+
}))
16+
17+
vi.mock('@/lib/copilot/server/api-keys', () => ({
18+
generateCopilotApiKey: mockGenerateCopilotApiKey,
19+
CopilotApiKeyError: class extends Error {},
20+
}))
21+
22+
vi.mock('@/lib/core/rate-limiter', () => ({
23+
enforceIpRateLimit: mockEnforceIpRateLimit,
24+
}))
25+
26+
import { POST } from '@/app/api/cli/auth/poll/route'
27+
28+
const REQUEST = 'a'.repeat(43)
29+
const VERIFIER = 'b'.repeat(43)
30+
31+
function pollRequest(body: Record<string, unknown>) {
32+
return createMockRequest('POST', body)
33+
}
34+
35+
describe('POST /api/cli/auth/poll', () => {
36+
beforeEach(() => {
37+
vi.clearAllMocks()
38+
mockEnforceIpRateLimit.mockResolvedValue(null)
39+
mockGenerateCopilotApiKey.mockResolvedValue({ id: 'key-1', apiKey: 'sk-test' })
40+
})
41+
42+
it('returns pending without minting while unapproved', async () => {
43+
mockPollApproval.mockResolvedValue({ status: 'pending' })
44+
const response = await POST(pollRequest({ request: REQUEST, verifier: VERIFIER }))
45+
expect(response.status).toBe(200)
46+
await expect(response.json()).resolves.toEqual({ status: 'pending' })
47+
expect(mockGenerateCopilotApiKey).not.toHaveBeenCalled()
48+
})
49+
50+
it('mints for the approving user once approved', async () => {
51+
mockPollApproval.mockResolvedValue({ status: 'approved', userId: 'user-1' })
52+
const response = await POST(pollRequest({ request: REQUEST, verifier: VERIFIER }))
53+
expect(response.status).toBe(200)
54+
await expect(response.json()).resolves.toEqual({
55+
status: 'complete',
56+
key: { id: 'key-1', apiKey: 'sk-test' },
57+
})
58+
expect(mockGenerateCopilotApiKey).toHaveBeenCalledWith('user-1', expect.stringMatching(/^CLI /))
59+
})
60+
61+
it('rejects a malformed verifier before touching the store', async () => {
62+
const response = await POST(pollRequest({ request: REQUEST, verifier: 'too-short' }))
63+
expect(response.status).toBe(400)
64+
expect(mockPollApproval).not.toHaveBeenCalled()
65+
})
66+
67+
it('honors the IP rate limiter', async () => {
68+
mockEnforceIpRateLimit.mockResolvedValue(
69+
new Response(null, { status: 429 }) as unknown as never
70+
)
71+
const response = await POST(pollRequest({ request: REQUEST, verifier: VERIFIER }))
72+
expect(response.status).toBe(429)
73+
expect(mockPollApproval).not.toHaveBeenCalled()
74+
})
75+
})
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import { createLogger } from '@sim/logger'
2+
import { type NextRequest, NextResponse } from 'next/server'
3+
import { pollCliAuthContract } from '@/lib/api/contracts'
4+
import { parseRequest } from '@/lib/api/server'
5+
import { pollApproval } from '@/lib/cli-auth/approval-store'
6+
import { CopilotApiKeyError, generateCopilotApiKey } from '@/lib/copilot/server/api-keys'
7+
import { enforceIpRateLimit } from '@/lib/core/rate-limiter'
8+
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
9+
10+
const logger = createLogger('CliAuthPollAPI')
11+
12+
/** Keys are named for the day they were issued, matching what the CLI prints. */
13+
function cliKeyName(): string {
14+
return `CLI (${new Date().toISOString().slice(0, 10)})`
15+
}
16+
17+
/**
18+
* The CLI's poll endpoint. Unauthenticated by necessity — the CLI has no
19+
* session — but the request id is only a rendezvous handle and minting requires
20+
* the poll secret, which never leaves the CLI. Returns `pending` until the user
21+
* approves; on the first authorized poll it consumes the approval and mints.
22+
*/
23+
export const POST = withRouteHandler(async (request: NextRequest) => {
24+
const rateLimited = await enforceIpRateLimit('cli-auth-poll', request)
25+
if (rateLimited) return rateLimited
26+
27+
const parsed = await parseRequest(pollCliAuthContract, request, {})
28+
if (!parsed.success) return parsed.response
29+
30+
const { request: requestId, verifier } = parsed.data.body
31+
32+
const result = await pollApproval(requestId, verifier)
33+
if (result.status === 'pending') {
34+
return NextResponse.json({ status: 'pending' })
35+
}
36+
37+
try {
38+
const key = await generateCopilotApiKey(result.userId, cliKeyName())
39+
logger.info('Minted CLI key on approved poll', { userId: result.userId })
40+
return NextResponse.json({ status: 'complete', key })
41+
} catch (error) {
42+
const status = error instanceof CopilotApiKeyError ? error.upstreamStatus : undefined
43+
return NextResponse.json(
44+
{ error: 'Failed to generate copilot API key' },
45+
{ status: status ?? 500 }
46+
)
47+
}
48+
})

apps/sim/app/api/cli/auth/token/route.test.ts

Lines changed: 0 additions & 90 deletions
This file was deleted.

apps/sim/app/api/cli/auth/token/route.ts

Lines changed: 0 additions & 49 deletions
This file was deleted.

0 commit comments

Comments
 (0)