bc5da12372
* 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>
146 lines
4.5 KiB
TypeScript
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)
|
|
})
|
|
})
|