Merge pull request #547 from saltbo/fix/dpop-verification-challenge

fix(auth): challenge invalid DPoP tokens
This commit is contained in:
Jasper Van
2026-07-31 12:34:05 -04:00
committed by GitHub
3 changed files with 59 additions and 3 deletions
+1 -1
View File
@@ -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 }}
+51
View File
@@ -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) => {
+7 -2
View File
@@ -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<Env>(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
}