mirror of
https://github.com/saltbo/zpan.git
synced 2026-08-28 15:51:29 +08:00
Merge pull request #547 from saltbo/fix/dpop-verification-challenge
fix(auth): challenge invalid DPoP tokens
This commit is contained in:
@@ -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 }}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user