Files
accounted/lib/auth/__tests__/oauth-codes.test.ts
T
Mattsson bc5da12372 fix(mcp-oauth): allowlist Cursor's OAuth callbacks so its dynamic registration succeeds (#2225)
* fix(mcp-oauth): allowlist Cursor's OAuth callbacks so its dynamic registration succeeds

Cursor (IDE, CLI, and Grok Bot on top of it) registers three redirect URIs
in one /register request: cursor://anysphere.cursor-mcp/oauth/callback,
https://www.cursor.com/agents/mcp/oauth/callback and
http://localhost:8787/callback. Only the loopback matched a built-in
pattern and /register fails the whole set on any unknown URI, so every
Cursor connection to the URL we hand out in Settings died with
"Redirect URI not allowed". Users cannot self-register the cursor://
form either (the settings panel requires https).

Add a built-in `cursor` provider with the two non-loopback callbacks as
exact matches (no cursor.com prefix), name it "Cursor (Anysphere)" on
the consent page, list the pre-approved clients in the OAuth clients
settings text (sv + en) and the mcp-server rules, and cover the
register, allowlist and consent paths with tests.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DBFeTvQXgMCNXR6fG9drff

* fix(mcp-oauth): show the cursor:// deeplink unverified and let CSP pass its post-consent redirect

Review findings on #2225, one pass:

- Skeptic (correctness), REFUTED: new URL('cursor://...').origin is the
  string "null", so the consent page emitted form-action 'self' null and
  Chromium would block the 303 to the deeplink after Allow. The header
  now uses the scheme-source (cursor:) when the origin is opaque; a test
  pins the header on the cursor:// URI.
- Skeptic (security), CodeRabbit (Major) and Superagent (P2): a custom
  scheme can be claimed by any local app (RFC 8252 section 8.4), so it
  must not be presented as a vendor-verified callback. The deeplink is
  its own provider, cursor_deeplink, rendered "Cursor (Anysphere)" with
  the localhost tag "Din egen dator" and verified: false. The https
  cursor.com callback keeps the verified label. A test pins that a code
  minted without a code_challenge can never be exchanged, which is what
  keeps a scheme hijack from turning into a token.
- CodeRabbit (Minor): the rules doc now says the Grok callback matches
  with or without the trailing slash.
- Regression skeptic: docs/WHITELABEL.md listed only Claude and
  localhost and pointed at the wrong file; now lists the built-ins and
  points at lib/auth/oauth-allowlist.ts.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DBFeTvQXgMCNXR6fG9drff

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-03 15:33:58 +02:00

146 lines
4.5 KiB
TypeScript

import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import crypto from 'crypto'
import { createAuthCode, decryptAuthCode, verifyPkce, hashAuthCode } from '../oauth-codes'
beforeEach(() => {
vi.stubEnv('SUPABASE_SERVICE_ROLE_KEY', 'test-secret-for-oauth-tests')
})
afterEach(() => {
vi.unstubAllEnvs()
vi.useRealTimers()
})
// ============================================================
// createAuthCode + decryptAuthCode round-trip
// ============================================================
describe('createAuthCode + decryptAuthCode round-trip', () => {
it('encrypts and decrypts preserving userId, codeChallenge, redirectUri', () => {
const payload = {
userId: 'user-123',
codeChallenge: 'challenge-abc',
redirectUri: 'https://claude.ai/api/callback',
}
const code = createAuthCode(payload)
const decrypted = decryptAuthCode(code)
expect(decrypted).not.toBeNull()
expect(decrypted!.userId).toBe('user-123')
expect(decrypted!.codeChallenge).toBe('challenge-abc')
expect(decrypted!.redirectUri).toBe('https://claude.ai/api/callback')
})
it('sets exp approximately 5 minutes in future', () => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-04-14T12:00:00Z'))
const code = createAuthCode({
userId: 'user-1',
codeChallenge: 'ch',
redirectUri: 'http://localhost',
})
const decrypted = decryptAuthCode(code)
expect(decrypted).not.toBeNull()
// exp should be Date.now() + 5 * 60 * 1000
const expectedExp = new Date('2026-04-14T12:00:00Z').getTime() + 5 * 60 * 1000
expect(decrypted!.exp).toBe(expectedExp)
})
it('returns null for expired code', () => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-04-14T12:00:00Z'))
const code = createAuthCode({
userId: 'user-1',
codeChallenge: 'ch',
redirectUri: 'http://localhost',
})
// Advance past 5 minute TTL
vi.advanceTimersByTime(5 * 60 * 1000 + 1)
const decrypted = decryptAuthCode(code)
expect(decrypted).toBeNull()
})
it('returns null for tampered ciphertext', () => {
const code = createAuthCode({
userId: 'user-1',
codeChallenge: 'ch',
redirectUri: 'http://localhost',
})
// Flip a character in the middle of the encrypted string
const chars = code.split('')
const mid = Math.floor(chars.length / 2)
chars[mid] = chars[mid] === 'A' ? 'B' : 'A'
const tampered = chars.join('')
expect(decryptAuthCode(tampered)).toBeNull()
})
it('returns null for completely invalid base64url', () => {
expect(decryptAuthCode('not-a-valid-code!!!')).toBeNull()
})
it('throws when SUPABASE_SERVICE_ROLE_KEY is not set', () => {
vi.stubEnv('SUPABASE_SERVICE_ROLE_KEY', '')
expect(() =>
createAuthCode({
userId: 'user-1',
codeChallenge: 'ch',
redirectUri: 'http://localhost',
})
).toThrow('SUPABASE_SERVICE_ROLE_KEY is required')
})
})
// ============================================================
// verifyPkce
// ============================================================
describe('verifyPkce', () => {
it('returns true when SHA256(verifier) matches challenge', () => {
const verifier = 'dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk'
// Compute expected challenge using base64url(SHA-256(verifier))
const challenge = crypto.createHash('sha256').update(verifier).digest('base64url')
expect(verifyPkce(verifier, challenge)).toBe(true)
})
it('returns false when verifier does not match challenge', () => {
expect(verifyPkce('correct-verifier', 'wrong-challenge')).toBe(false)
})
})
// ============================================================
// hashAuthCode
// ============================================================
describe('hashAuthCode', () => {
it('returns 64-char hex string', () => {
const hash = hashAuthCode('some-auth-code')
expect(hash).toMatch(/^[0-9a-f]{64}$/)
})
it('is deterministic for same input', () => {
const hash1 = hashAuthCode('deterministic-code')
const hash2 = hashAuthCode('deterministic-code')
expect(hash1).toBe(hash2)
})
})
describe('verifyPkce with a missing challenge', () => {
it('never verifies when the code was minted without a code_challenge', () => {
// /authorize does not reject a missing code_challenge; it mints the code
// with an empty one. That must stay unexchangeable, otherwise a
// custom-scheme (cursor://) hijacker could skip PKCE entirely.
expect(verifyPkce('any-verifier', '')).toBe(false)
expect(verifyPkce('', '')).toBe(false)
})
})