diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6564d452..dd11b713 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -231,7 +231,7 @@ jobs: e2e-cf: name: E2E (CF Workers) runs-on: ubuntu-latest - needs: check + needs: [check, e2e-node] env: BETTER_AUTH_SECRET: ci-test-secret-that-is-at-least-32-chars E2E_CLOUD_BUSINESS_EMAIL_CF: ${{ secrets.E2E_CLOUD_BUSINESS_EMAIL_CF }} diff --git a/server/auth.integration.test.ts b/server/auth.integration.test.ts index b00a2156..87df61b7 100644 --- a/server/auth.integration.test.ts +++ b/server/auth.integration.test.ts @@ -872,6 +872,57 @@ describe('Agent OAuth consent guards', () => { ) }) + it('returns a DPoP challenge for a foreign access token instead of an internal error', async () => { + const ctx = await createTestApp() + const apiUrl = 'http://localhost:3000/api/objects' + const { privateKey: foreignPrivateKey } = await generateKeyPair('ES256') + const { privateKey: dpopPrivateKey, publicKey: dpopPublicKey } = await generateKeyPair('ES256') + const dpopPublicJwk = await exportJWK(dpopPublicKey) + const accessToken = await new SignJWT({ + sub: 'foreign-user', + client_id: 'foreign-client', + zpan_org_id: 'foreign-workspace', + act: { sub: 'foreign-agent', iss: 'https://identity.example.com/api/auth' }, + scope: 'objects:create', + cnf: { jkt: 'foreign-thumbprint' }, + }) + .setProtectedHeader({ typ: 'JWT', alg: 'ES256', kid: 'foreign-key' }) + .setIssuer('http://localhost:3000/api/auth') + .setAudience('http://localhost:3000/api') + .setIssuedAt() + .setExpirationTime('5m') + .setJti(crypto.randomUUID()) + .sign(foreignPrivateKey) + const proof = await new SignJWT({ + htm: 'POST', + htu: apiUrl, + ath: await deriveDpopAth(accessToken), + }) + .setProtectedHeader({ typ: 'dpop+jwt', alg: 'ES256', jwk: dpopPublicJwk }) + .setIssuedAt() + .setJti(crypto.randomUUID()) + .sign(dpopPrivateKey) + const getJwks = ctx.auth.api.getJwks + vi.stubGlobal( + 'fetch', + vi.fn(async (input: string | URL | Request) => { + const url = input instanceof Request ? input.url : String(input) + if (url === 'http://localhost:3000/api/auth/jwks') return Response.json(await getJwks()) + throw new Error(`unexpected fetch: ${url}`) + }), + ) + + const response = await ctx.app.request(apiUrl, { + method: 'POST', + headers: { Authorization: `DPoP ${accessToken}`, DPoP: proof, 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: 'foreign.txt', size: 1, type: 'text/plain', dirtype: 0, parent: '' }), + }) + + expect(response.status).toBe(401) + expect(response.headers.get('www-authenticate')).toContain('DPoP') + expect(response.headers.get('www-authenticate')).toContain('/.well-known/oauth-protected-resource/api') + }) + it('issues a DPoP API token through JWT bearer and token exchange grants', async () => { const ctx = await createTestApp() ctx.app.get('/api/test-agent-audit', async (c) => { diff --git a/server/middleware/auth.ts b/server/middleware/auth.ts index e89d99d4..78755424 100644 --- a/server/middleware/auth.ts +++ b/server/middleware/auth.ts @@ -1,6 +1,5 @@ import { oauthProviderResourceClient } from '@better-auth/oauth-provider/resource-client' import { AuthorizationScope, isAuthorizationScope, permissionScopes } from '@shared/authorization' -import { APIError } from 'better-auth' import { createDpopReplayStore } from 'better-auth/oauth2' import { createMiddleware } from 'hono/factory' import { @@ -34,7 +33,7 @@ export const authMiddleware = createMiddleware(async (c, next) => { dpop: { replayStore: createDpopReplayStore(authContext.internalAdapter) }, }) } catch (error) { - if (error instanceof APIError) throw dpopUnauthorized(audience) + if (isUnauthorizedApiError(error)) throw dpopUnauthorized(audience) throw error } const userId = typeof payload.sub === 'string' ? payload.sub : null @@ -231,3 +230,9 @@ function dpopUnauthorized(resource: string): AppError { }, }) } + +function isUnauthorizedApiError(error: unknown): boolean { + if (!(error instanceof Error) || error.name !== 'APIError') return false + const candidate = error as Error & { status?: unknown; statusCode?: unknown } + return candidate.status === 'UNAUTHORIZED' || candidate.status === 401 || candidate.statusCode === 401 +}