Deprecate query & fragment in DPoP proof htu claim (#3879)

* Properly validate JWK `htu` claim by enforcing URL without query or fragment

* type fix

* Return DPoP validation result from `authenticateRequest`

* Log clients using invalid "htu" claim in DPoP proof

* review comments

* fix lint

* tidy

* rename dpop result to dpop proof
This commit is contained in:
Matthieu Sieben 2025-06-05 14:46:51 +02:00 committed by GitHub
parent a3b24ca77c
commit 3fa2ee3b6a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
18 changed files with 693 additions and 320 deletions

View File

@ -0,0 +1,5 @@
---
"@atproto/oauth-provider": minor
---
Improve validation of DPoP proofs

View File

@ -0,0 +1,5 @@
---
"@atproto/jwk": minor
---
Properly validate JWK `htu` claim by enforcing URL without query or fragment

View File

@ -0,0 +1,5 @@
---
"@atproto/pds": patch
---
Log clients using invalid "htu" claim in DPoP proof

View File

@ -0,0 +1,5 @@
---
"@atproto/oauth-provider": patch
---
Return DPoP validation result from `authenticateRequest`

View File

@ -54,7 +54,7 @@
"prettier": "^3.2.5", "prettier": "^3.2.5",
"prettier-config-standard": "^7.0.0", "prettier-config-standard": "^7.0.0",
"prettier-plugin-tailwindcss": "^0.6.11", "prettier-plugin-tailwindcss": "^0.6.11",
"typescript": "^5.8.2" "typescript": "^5.8.3"
}, },
"workspaces": { "workspaces": {
"packages": [ "packages": [

View File

@ -61,6 +61,50 @@ export const jwtHeaderSchema = z
export type JwtHeader = z.infer<typeof jwtHeaderSchema> export type JwtHeader = z.infer<typeof jwtHeaderSchema>
/**
* @see {@link https://www.rfc-editor.org/rfc/rfc9449.html#section-4.2-4.6}
* @see {@link https://www.rfc-editor.org/rfc/rfc9110#section-7.1}
*/
export const htuSchema = z.string().superRefine((value, ctx) => {
try {
const url = new URL(value)
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Only http: and https: protocols are allowed',
})
}
if (url.username || url.password) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Credentials not allowed',
})
}
if (url.search) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Query string not allowed',
})
}
if (url.hash) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Fragment not allowed',
})
}
} catch (err) {
ctx.addIssue({
code: z.ZodIssueCode.invalid_string,
validation: 'url',
})
}
return value
})
// https://www.iana.org/assignments/jwt/jwt.xhtml // https://www.iana.org/assignments/jwt/jwt.xhtml
export const jwtPayloadSchema = z export const jwtPayloadSchema = z
.object({ .object({
@ -72,7 +116,7 @@ export const jwtPayloadSchema = z
iat: z.number().int().optional(), iat: z.number().int().optional(),
jti: z.string().optional(), jti: z.string().optional(),
htm: z.string().optional(), htm: z.string().optional(),
htu: z.string().optional(), htu: htuSchema.optional(),
ath: z.string().optional(), ath: z.string().optional(),
acr: z.string().optional(), acr: z.string().optional(),
azp: z.string().optional(), azp: z.string().optional(),

View File

@ -1,15 +1,18 @@
import { createHash } from 'node:crypto' import { createHash } from 'node:crypto'
import { EmbeddedJWK, calculateJwkThumbprint, errors, jwtVerify } from 'jose' import { EmbeddedJWK, calculateJwkThumbprint, errors, jwtVerify } from 'jose'
import { z } from 'zod' import { z } from 'zod'
import { ValidationError } from '@atproto/jwk'
import { DPOP_NONCE_MAX_AGE } from '../constants.js' import { DPOP_NONCE_MAX_AGE } from '../constants.js'
import { InvalidDpopProofError } from '../errors/invalid-dpop-proof-error.js' import { InvalidDpopProofError } from '../errors/invalid-dpop-proof-error.js'
import { UseDpopNonceError } from '../errors/use-dpop-nonce-error.js' import { UseDpopNonceError } from '../errors/use-dpop-nonce-error.js'
import { ifURL } from '../lib/util/cast.js'
import { import {
DpopNonce, DpopNonce,
DpopSecret, DpopSecret,
dpopSecretSchema, dpopSecretSchema,
rotationIntervalSchema, rotationIntervalSchema,
} from './dpop-nonce.js' } from './dpop-nonce.js'
import { DpopProof } from './dpop-proof.js'
const { JOSEError } = errors const { JOSEError } = errors
@ -47,111 +50,163 @@ export class DpopManager {
* @see {@link https://datatracker.ietf.org/doc/html/rfc9449#section-4.3} * @see {@link https://datatracker.ietf.org/doc/html/rfc9449#section-4.3}
*/ */
async checkProof( async checkProof(
proof: unknown, httpMethod: string,
htm: string, // HTTP Method httpUrl: Readonly<URL>,
htu: string | URL, // HTTP URL httpHeaders: Record<string, undefined | string | string[]>,
accessToken?: string, // Access Token accessToken?: string,
) { ): Promise<null | DpopProof> {
if (Array.isArray(proof) && proof.length === 1) { // Fool proofing against use of empty string
proof = proof[0] if (!httpMethod) {
throw new TypeError('HTTP method is required')
} }
if (!proof || typeof proof !== 'string') { const proof = extractProof(httpHeaders)
throw new InvalidDpopProofError('DPoP proof required') if (!proof) return null
}
const { protectedHeader, payload } = await jwtVerify<{ const { protectedHeader, payload } = await jwtVerify(proof, EmbeddedJWK, {
iat: number
jti: string
}>(proof, EmbeddedJWK, {
typ: 'dpop+jwt', typ: 'dpop+jwt',
maxTokenAge: 10, maxTokenAge: 10, // Will ensure presence & validity of "iat" claim
clockTolerance: DPOP_NONCE_MAX_AGE / 1e3, clockTolerance: DPOP_NONCE_MAX_AGE / 1e3,
requiredClaims: ['iat', 'jti'],
}).catch((err) => { }).catch((err) => {
const message = throw newInvalidDpopProofError('Failed to verify DPoP proof', err)
err instanceof JOSEError
? `Invalid DPoP proof (${err.message})`
: 'Invalid DPoP proof'
throw new InvalidDpopProofError(message, err)
}) })
if (!payload.jti || typeof payload.jti !== 'string') { // @NOTE For legacy & backwards compatibility reason, we cannot use
throw new InvalidDpopProofError('Invalid or missing jti property') // `jwtPayloadSchema` here as it will reject DPoP proofs containing a query
// or fragment component in the "htu" claim.
// const { ath, htm, htu, jti, nonce } = await jwtPayloadSchema
// .parseAsync(payload)
// .catch((err) => {
// throw buildInvalidDpopProofError('Invalid DPoP proof', err)
// })
// @TODO Uncomment previous lines (and remove redundant checks bellow) once
// we decide to drop legacy support.
const { ath, htm, htu, jti, nonce } = payload
if (nonce !== undefined && typeof nonce !== 'string') {
throw newInvalidDpopProofError('Invalid DPoP "nonce" type')
}
if (!jti || typeof jti !== 'string') {
throw newInvalidDpopProofError('DPoP "jti" missing')
} }
// Note rfc9110#section-9.1 states that the method name is case-sensitive // Note rfc9110#section-9.1 states that the method name is case-sensitive
if (!htm || htm !== payload['htm']) { if (!htm || htm !== httpMethod) {
throw new InvalidDpopProofError('DPoP htm mismatch') throw newInvalidDpopProofError('DPoP "htm" mismatch')
} }
if ( if (!htu || typeof htu !== 'string') {
payload['nonce'] !== undefined && throw newInvalidDpopProofError('Invalid DPoP "htu" type')
typeof payload['nonce'] !== 'string'
) {
throw new InvalidDpopProofError('DPoP nonce must be a string')
} }
if (!payload['nonce'] && this.dpopNonce) { // > To reduce the likelihood of false negatives, servers SHOULD employ
// > syntax-based normalization (Section 6.2.2 of [RFC3986]) and
// > scheme-based normalization (Section 6.2.3 of [RFC3986]) before
// > comparing the htu claim.
//
// RFC9449 section 4.3. Checking DPoP Proofs - https://datatracker.ietf.org/doc/html/rfc9449#section-4.3
if (!htu || parseHtu(htu) !== normalizeHtuUrl(httpUrl)) {
throw newInvalidDpopProofError('DPoP "htu" mismatch')
}
if (!nonce && this.dpopNonce) {
throw new UseDpopNonceError() throw new UseDpopNonceError()
} }
if (payload['nonce'] && !this.dpopNonce?.check(payload['nonce'])) { if (nonce && !this.dpopNonce?.check(nonce)) {
throw new UseDpopNonceError('DPoP nonce mismatch') throw new UseDpopNonceError('DPoP "nonce" mismatch')
}
const htuNorm = normalizeHtu(htu)
if (!htuNorm) {
throw new TypeError('Invalid "htu" argument')
}
if (htuNorm !== normalizeHtu(payload['htu'])) {
throw new InvalidDpopProofError('DPoP htu mismatch')
} }
if (accessToken) { if (accessToken) {
const athBuffer = createHash('sha256').update(accessToken).digest() const accessTokenHash = createHash('sha256').update(accessToken).digest()
if (payload['ath'] !== athBuffer.toString('base64url')) { if (ath !== accessTokenHash.toString('base64url')) {
throw new InvalidDpopProofError('DPoP ath mismatch') throw newInvalidDpopProofError('DPoP "ath" mismatch')
} }
} else if (payload['ath']) { } else if (ath !== undefined) {
throw new InvalidDpopProofError('DPoP ath not allowed') throw newInvalidDpopProofError('DPoP "ath" claim not allowed')
} }
try { // @NOTE we can assert there is a jwk because the jwtVerify used the
return { // EmbeddedJWK key getter mechanism.
protectedHeader, const jwk = protectedHeader.jwk!
payload, const jkt = await calculateJwkThumbprint(jwk, 'sha256').catch((err) => {
jkt: await calculateJwkThumbprint(protectedHeader['jwk']!, 'sha256'), // EmbeddedJWK throw newInvalidDpopProofError('Failed to calculate jkt', err)
} })
} catch (err) {
const message = return { jti, jkt, htm, htu }
err instanceof JOSEError ? err.message : 'Failed to calculate jkt' }
throw new InvalidDpopProofError(message, err) }
}
function extractProof(
httpHeaders: Record<string, undefined | string | string[]>,
): string | null {
const dpopHeader = httpHeaders['dpop']
switch (typeof dpopHeader) {
case 'string':
if (dpopHeader) return dpopHeader
throw newInvalidDpopProofError('DPoP header cannot be empty')
case 'object':
// @NOTE the "0" case should never happen a node.js HTTP server will only
// return an array if the header is set multiple times.
if (dpopHeader.length === 1 && dpopHeader[0]) return dpopHeader[0]!
throw newInvalidDpopProofError('DPoP header must contain a single proof')
default:
return null
} }
} }
/** /**
* @note * Constructs the HTTP URI (htu) claim as defined in RFC9449.
* > The htu claim matches the HTTP URI value for the HTTP request in which the
* > JWT was received, ignoring any query and fragment parts.
* *
* > To reduce the likelihood of false negatives, servers SHOULD employ * The htu claim is the normalized URL of the HTTP request, excluding the query
* > syntax-based normalization (Section 6.2.2 of [RFC3986]) and scheme-based * string and fragment. This function ensures that the URL is normalized by
* > normalization (Section 6.2.3 of [RFC3986]) before comparing the htu claim. * removing the search and hash components, as well as by using an URL object to
* @see {@link https://datatracker.ietf.org/doc/html/rfc9449#section-4.3 | RFC9449 section 4.3. Checking DPoP Proofs} * simplify the pathname (e.g. removing dot segments).
*
* @returns The normalized URL as a string.
* @see {@link https://datatracker.ietf.org/doc/html/rfc9449#section-4.3}
*/ */
function normalizeHtu(htu: unknown): string | null { function normalizeHtuUrl(url: Readonly<URL>): string {
// Optimization // NodeJS's `URL` normalizes the pathname, so we can just use that.
if (!htu) return null return url.origin + url.pathname
}
try {
const url = new URL(String(htu)) function parseHtu(htu: string): string {
url.hash = '' const url = ifURL(htu)
url.search = '' if (!url) {
return url.href throw newInvalidDpopProofError('DPoP "htu" is not a valid URL')
} catch { }
return null
} // @NOTE the checks bellow can be removed once once jwtPayloadSchema is used
// to validate the DPoP proof payload as it already performs these checks
// (though the htuSchema).
if (url.password || url.username) {
throw newInvalidDpopProofError('DPoP "htu" must not contain credentials')
}
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
throw newInvalidDpopProofError('DPoP "htu" must be http or https')
}
// @NOTE For legacy & backwards compatibility reason, we allow a query and
// fragment in the DPoP proof's htu. This is not a standard behavior as the
// htu is not supposed to contain query or fragment.
// NodeJS's `URL` normalizes the pathname.
return normalizeHtuUrl(url)
}
function newInvalidDpopProofError(
title: string,
err?: unknown,
): InvalidDpopProofError {
const msg =
err instanceof JOSEError || err instanceof ValidationError
? `${title}: ${err.message}`
: title
return new InvalidDpopProofError(msg, err)
} }

View File

@ -0,0 +1,6 @@
export type DpopProof = {
jti: string
jkt: string
htm: string
htu: string
}

View File

@ -11,8 +11,8 @@ export const authorizationHeaderSchema = z.tuple([
oauthAccessTokenSchema, oauthAccessTokenSchema,
]) ])
export const parseAuthorizationHeader = (header?: string) => { export const parseAuthorizationHeader = (header: unknown) => {
if (header == null) { if (typeof header !== 'string') {
throw new WWWAuthenticateError( throw new WWWAuthenticateError(
'invalid_request', 'invalid_request',
'Authorization header required', 'Authorization header required',

View File

@ -2,3 +2,17 @@ export function asArray<T>(value: T | T[]): T[] {
if (value == null) return [] if (value == null) return []
return Array.isArray(value) ? value : [value] return Array.isArray(value) ? value : [value]
} }
export function asURL(value: string | { toString: () => string }): URL {
return new URL(value)
}
export function ifURL(
value: string | { toString: () => string },
): URL | undefined {
try {
return asURL(value)
} catch {
return undefined
}
}

View File

@ -69,7 +69,11 @@ import { LocalizedString, MultiLangString } from './lib/util/locale.js'
import { extractZodErrorMessage } from './lib/util/zod-error.js' import { extractZodErrorMessage } from './lib/util/zod-error.js'
import { CustomMetadata, buildMetadata } from './metadata/build-metadata.js' import { CustomMetadata, buildMetadata } from './metadata/build-metadata.js'
import { OAuthHooks } from './oauth-hooks.js' import { OAuthHooks } from './oauth-hooks.js'
import { OAuthVerifier, OAuthVerifierOptions } from './oauth-verifier.js' import {
DpopProof,
OAuthVerifier,
OAuthVerifierOptions,
} from './oauth-verifier.js'
import { ReplayStore, ifReplayStore } from './replay/replay-store.js' import { ReplayStore, ifReplayStore } from './replay/replay-store.js'
import { codeSchema } from './request/code.js' import { codeSchema } from './request/code.js'
import { RequestInfo } from './request/request-info.js' import { RequestInfo } from './request/request-info.js'
@ -458,7 +462,7 @@ export class OAuthProvider extends OAuthVerifier {
public async pushedAuthorizationRequest( public async pushedAuthorizationRequest(
credentials: OAuthClientCredentials, credentials: OAuthClientCredentials,
authorizationRequest: OAuthAuthorizationRequestPar, authorizationRequest: OAuthAuthorizationRequestPar,
dpopJkt: null | string, dpopProof: null | DpopProof,
): Promise<OAuthParResponse> { ): Promise<OAuthParResponse> {
try { try {
const [client, clientAuth] = await this.authenticateClient(credentials) const [client, clientAuth] = await this.authenticateClient(credentials)
@ -474,7 +478,7 @@ export class OAuthProvider extends OAuthVerifier {
clientAuth, clientAuth,
parameters, parameters,
null, null,
dpopJkt, dpopProof,
) )
return { return {
@ -717,7 +721,7 @@ export class OAuthProvider extends OAuthVerifier {
clientCredentials: OAuthClientCredentials, clientCredentials: OAuthClientCredentials,
clientMetadata: RequestMetadata, clientMetadata: RequestMetadata,
request: OAuthTokenRequest, request: OAuthTokenRequest,
dpopJkt: null | string, dpopProof: null | DpopProof,
): Promise<OAuthTokenResponse> { ): Promise<OAuthTokenResponse> {
const [client, clientAuth] = const [client, clientAuth] =
await this.authenticateClient(clientCredentials) await this.authenticateClient(clientCredentials)
@ -740,7 +744,7 @@ export class OAuthProvider extends OAuthVerifier {
clientAuth, clientAuth,
clientMetadata, clientMetadata,
request, request,
dpopJkt, dpopProof,
) )
} }
@ -750,7 +754,7 @@ export class OAuthProvider extends OAuthVerifier {
clientAuth, clientAuth,
clientMetadata, clientMetadata,
request, request,
dpopJkt, dpopProof,
) )
} }
@ -764,7 +768,7 @@ export class OAuthProvider extends OAuthVerifier {
clientAuth: ClientAuth, clientAuth: ClientAuth,
clientMetadata: RequestMetadata, clientMetadata: RequestMetadata,
input: OAuthAuthorizationCodeGrantTokenRequest, input: OAuthAuthorizationCodeGrantTokenRequest,
dpopJkt: null | string, dpopProof: null | DpopProof,
): Promise<OAuthTokenResponse> { ): Promise<OAuthTokenResponse> {
const code = codeSchema.parse(input.code) const code = codeSchema.parse(input.code)
try { try {
@ -807,7 +811,7 @@ export class OAuthProvider extends OAuthVerifier {
deviceId, deviceId,
parameters, parameters,
input, input,
dpopJkt, dpopProof,
) )
} catch (err) { } catch (err) {
// If a token is replayed, requestManager.findCode will throw. In that // If a token is replayed, requestManager.findCode will throw. In that
@ -835,14 +839,14 @@ export class OAuthProvider extends OAuthVerifier {
clientAuth: ClientAuth, clientAuth: ClientAuth,
clientMetadata: RequestMetadata, clientMetadata: RequestMetadata,
input: OAuthRefreshTokenGrantTokenRequest, input: OAuthRefreshTokenGrantTokenRequest,
dpopJkt: null | string, dpopProof: null | DpopProof,
): Promise<OAuthTokenResponse> { ): Promise<OAuthTokenResponse> {
return this.tokenManager.refresh( return this.tokenManager.refresh(
client, client,
clientAuth, clientAuth,
clientMetadata, clientMetadata,
input, input,
dpopJkt, dpopProof,
) )
} }
@ -874,24 +878,24 @@ export class OAuthProvider extends OAuthVerifier {
protected override async verifyToken( protected override async verifyToken(
tokenType: OAuthTokenType, tokenType: OAuthTokenType,
token: OAuthAccessToken, token: OAuthAccessToken,
dpopJkt: string | null, dpopProof: null | DpopProof,
verifyOptions?: VerifyTokenClaimsOptions, verifyOptions?: VerifyTokenClaimsOptions,
): Promise<VerifyTokenClaimsResult> { ): Promise<VerifyTokenClaimsResult> {
if (this.accessTokenMode === AccessTokenMode.stateless) { if (this.accessTokenMode === AccessTokenMode.stateless) {
return super.verifyToken(tokenType, token, dpopJkt, verifyOptions) return super.verifyToken(tokenType, token, dpopProof, verifyOptions)
} }
if (this.accessTokenMode === AccessTokenMode.light) { if (this.accessTokenMode === AccessTokenMode.light) {
const { claims } = await super.verifyToken( const { tokenClaims } = await super.verifyToken(
tokenType, tokenType,
token, token,
dpopJkt, dpopProof,
// Do not verify the scope and audience in case of "light" tokens. // Do not verify the scope and audience in case of "light" tokens.
// these will be checked through the tokenManager hereafter. // these will be checked through the tokenManager hereafter.
undefined, undefined,
) )
const tokenId = claims.jti const tokenId = tokenClaims.jti
// In addition to verifying the signature (through the verifier above), we // In addition to verifying the signature (through the verifier above), we
// also verify the tokenId is still valid using a database to fetch // also verify the tokenId is still valid using a database to fetch
@ -900,7 +904,7 @@ export class OAuthProvider extends OAuthVerifier {
token, token,
tokenType, tokenType,
tokenId, tokenId,
dpopJkt, dpopProof,
verifyOptions, verifyOptions,
) )
} }

View File

@ -8,6 +8,7 @@ import {
} from '@atproto/oauth-types' } from '@atproto/oauth-types'
import { DpopManager, DpopManagerOptions } from './dpop/dpop-manager.js' import { DpopManager, DpopManagerOptions } from './dpop/dpop-manager.js'
import { DpopNonce } from './dpop/dpop-nonce.js' import { DpopNonce } from './dpop/dpop-nonce.js'
import { DpopProof } from './dpop/dpop-proof.js'
import { InvalidDpopProofError } from './errors/invalid-dpop-proof-error.js' import { InvalidDpopProofError } from './errors/invalid-dpop-proof-error.js'
import { InvalidTokenError } from './errors/invalid-token-error.js' import { InvalidTokenError } from './errors/invalid-token-error.js'
import { UseDpopNonceError } from './errors/use-dpop-nonce-error.js' import { UseDpopNonceError } from './errors/use-dpop-nonce-error.js'
@ -50,7 +51,7 @@ export type OAuthVerifierOptions = Override<
> >
export { DpopNonce, Key, Keyset } export { DpopNonce, Key, Keyset }
export type { RedisOptions, ReplayStore, VerifyTokenClaimsOptions } export type { DpopProof, RedisOptions, ReplayStore, VerifyTokenClaimsOptions }
export class OAuthVerifier { export class OAuthVerifier {
public readonly issuer: OAuthIssuerIdentifier public readonly issuer: OAuthIssuerIdentifier
@ -95,30 +96,30 @@ export class OAuthVerifier {
} }
public async checkDpopProof( public async checkDpopProof(
proof: unknown, httpMethod: string,
htm: string, httpUrl: Readonly<URL>,
htu: string | URL, httpHeaders: Record<string, undefined | string | string[]>,
accessToken?: string, accessToken?: string,
): Promise<string | null> { ): Promise<null | DpopProof> {
if (proof === undefined) return null const dpopProof = await this.dpopManager.checkProof(
httpMethod,
const { payload, jkt } = await this.dpopManager.checkProof( httpUrl,
proof, httpHeaders,
htm,
htu,
accessToken, accessToken,
) )
const unique = await this.replayManager.uniqueDpop(payload.jti) if (dpopProof) {
if (!unique) throw new InvalidDpopProofError('DPoP proof jti is not unique') const unique = await this.replayManager.uniqueDpop(dpopProof.jti)
if (!unique) throw new InvalidDpopProofError('DPoP proof replayed')
}
return jkt return dpopProof
} }
protected async verifyToken( protected async verifyToken(
tokenType: OAuthTokenType, tokenType: OAuthTokenType,
token: OAuthAccessToken, token: OAuthAccessToken,
dpopJkt: string | null, dpopProof: null | DpopProof,
verifyOptions?: VerifyTokenClaimsOptions, verifyOptions?: VerifyTokenClaimsOptions,
): Promise<VerifyTokenClaimsResult> { ): Promise<VerifyTokenClaimsResult> {
if (!isSignedJwt(token)) { if (!isSignedJwt(token)) {
@ -135,35 +136,37 @@ export class OAuthVerifier {
token, token,
payload.jti, payload.jti,
tokenType, tokenType,
dpopJkt,
payload, payload,
dpopProof,
verifyOptions, verifyOptions,
) )
} }
public async authenticateRequest( public async authenticateRequest(
method: string, httpMethod: string,
url: URL, httpUrl: Readonly<URL>,
headers: { httpHeaders: Record<string, undefined | string | string[]>,
authorization?: string
dpop?: unknown
},
verifyOptions?: VerifyTokenClaimsOptions, verifyOptions?: VerifyTokenClaimsOptions,
) { ): Promise<VerifyTokenClaimsResult> {
const [tokenType, token] = parseAuthorizationHeader(headers.authorization) const [tokenType, token] = parseAuthorizationHeader(
httpHeaders['authorization'],
)
try { try {
const dpopJkt = await this.checkDpopProof( const dpopProof = await this.checkDpopProof(
headers.dpop, httpMethod,
method, httpUrl,
url, httpHeaders,
token, token,
) )
if (tokenType === 'DPoP' && !dpopJkt) { const tokenResult = await this.verifyToken(
throw new InvalidDpopProofError(`DPoP proof required`) tokenType,
} token,
dpopProof,
verifyOptions,
)
return await this.verifyToken(tokenType, token, dpopJkt, verifyOptions) return tokenResult
} catch (err) { } catch (err) {
if (err instanceof UseDpopNonceError) throw err.toWwwAuthenticateError() if (err instanceof UseDpopNonceError) throw err.toWwwAuthenticateError()
if (err instanceof WWWAuthenticateError) throw err if (err instanceof WWWAuthenticateError) throw err

View File

@ -16,6 +16,8 @@ import { DeviceId } from '../device/device-id.js'
import { AccessDeniedError } from '../errors/access-denied-error.js' import { AccessDeniedError } from '../errors/access-denied-error.js'
import { ConsentRequiredError } from '../errors/consent-required-error.js' import { ConsentRequiredError } from '../errors/consent-required-error.js'
import { InvalidAuthorizationDetailsError } from '../errors/invalid-authorization-details-error.js' import { InvalidAuthorizationDetailsError } from '../errors/invalid-authorization-details-error.js'
import { InvalidDpopKeyBindingError } from '../errors/invalid-dpop-key-binding-error.js'
import { InvalidDpopProofError } from '../errors/invalid-dpop-proof-error.js'
import { InvalidGrantError } from '../errors/invalid-grant-error.js' import { InvalidGrantError } from '../errors/invalid-grant-error.js'
import { InvalidParametersError } from '../errors/invalid-parameters-error.js' import { InvalidParametersError } from '../errors/invalid-parameters-error.js'
import { InvalidRequestError } from '../errors/invalid-request-error.js' import { InvalidRequestError } from '../errors/invalid-request-error.js'
@ -23,6 +25,7 @@ import { InvalidScopeError } from '../errors/invalid-scope-error.js'
import { RequestMetadata } from '../lib/http/request.js' import { RequestMetadata } from '../lib/http/request.js'
import { callAsync } from '../lib/util/function.js' import { callAsync } from '../lib/util/function.js'
import { OAuthHooks } from '../oauth-hooks.js' import { OAuthHooks } from '../oauth-hooks.js'
import { DpopProof } from '../oauth-verifier.js'
import { Signer } from '../signer/signer.js' import { Signer } from '../signer/signer.js'
import { Code, generateCode } from './code.js' import { Code, generateCode } from './code.js'
import { import {
@ -56,9 +59,9 @@ export class RequestManager {
clientAuth: ClientAuth, clientAuth: ClientAuth,
input: Readonly<OAuthAuthorizationRequestParameters>, input: Readonly<OAuthAuthorizationRequestParameters>,
deviceId: null | DeviceId, deviceId: null | DeviceId,
dpopJkt: null | string, dpopProof: null | DpopProof,
): Promise<RequestInfo> { ): Promise<RequestInfo> {
const parameters = await this.validate(client, clientAuth, input, dpopJkt) const parameters = await this.validate(client, clientAuth, input, dpopProof)
return this.create(client, clientAuth, parameters, deviceId) return this.create(client, clientAuth, parameters, deviceId)
} }
@ -89,7 +92,7 @@ export class RequestManager {
client: Client, client: Client,
clientAuth: ClientAuth, clientAuth: ClientAuth,
parameters: Readonly<OAuthAuthorizationRequestParameters>, parameters: Readonly<OAuthAuthorizationRequestParameters>,
dpop_jkt: null | string, dpopProof: null | DpopProof,
): Promise<Readonly<OAuthAuthorizationRequestParameters>> { ): Promise<Readonly<OAuthAuthorizationRequestParameters>> {
// ------------------------------- // -------------------------------
// Validate unsupported parameters // Validate unsupported parameters
@ -196,12 +199,11 @@ export class RequestManager {
// https://datatracker.ietf.org/doc/html/rfc9449#section-10 // https://datatracker.ietf.org/doc/html/rfc9449#section-10
if (!parameters.dpop_jkt) { if (!parameters.dpop_jkt) {
if (dpop_jkt) parameters = { ...parameters, dpop_jkt } if (dpopProof) parameters = { ...parameters, dpop_jkt: dpopProof.jkt }
} else if (parameters.dpop_jkt !== dpop_jkt) { } else if (!dpopProof) {
throw new InvalidParametersError( throw new InvalidDpopProofError('DPoP proof required')
parameters, } else if (parameters.dpop_jkt !== dpopProof.jkt) {
'"dpop_jkt" parameters does not match the DPoP proof', throw new InvalidDpopKeyBindingError()
)
} }
if (clientAuth.method === CLIENT_ASSERTION_TYPE_JWT_BEARER) { if (clientAuth.method === CLIENT_ASSERTION_TYPE_JWT_BEARER) {

View File

@ -101,16 +101,16 @@ export function createOAuthMiddleware<
.parseAsync(payload, { path: ['body'] }) .parseAsync(payload, { path: ['body'] })
.catch(throwInvalidRequest) .catch(throwInvalidRequest)
const dpopJkt = await server.checkDpopProof( const dpopProof = await server.checkDpopProof(
req.headers['dpop'],
req.method!, req.method!,
this.url, this.url,
req.headers,
) )
return server.pushedAuthorizationRequest( return server.pushedAuthorizationRequest(
credentials, credentials,
authorizationRequest, authorizationRequest,
dpopJkt, dpopProof,
) )
}, 201), }, 201),
) )
@ -138,17 +138,17 @@ export function createOAuthMiddleware<
.parseAsync(payload, { path: ['body'] }) .parseAsync(payload, { path: ['body'] })
.catch(throwInvalidGrant) .catch(throwInvalidGrant)
const dpopJkt = await server.checkDpopProof( const dpopProof = await server.checkDpopProof(
req.headers['dpop'],
req.method!, req.method!,
this.url, this.url,
req.headers,
) )
return server.token( return server.token(
clientCredentials, clientCredentials,
clientMetadata, clientMetadata,
tokenRequest, tokenRequest,
dpopJkt, dpopProof,
) )
}), }),
) )

View File

@ -32,6 +32,7 @@ import { RequestMetadata } from '../lib/http/request.js'
import { dateToEpoch, dateToRelativeSeconds } from '../lib/util/date.js' import { dateToEpoch, dateToRelativeSeconds } from '../lib/util/date.js'
import { callAsync } from '../lib/util/function.js' import { callAsync } from '../lib/util/function.js'
import { OAuthHooks } from '../oauth-hooks.js' import { OAuthHooks } from '../oauth-hooks.js'
import { DpopProof } from '../oauth-verifier.js'
import { Sub } from '../oidc/sub.js' import { Sub } from '../oidc/sub.js'
import { Code, isCode } from '../request/code.js' import { Code, isCode } from '../request/code.js'
import { SignedTokenPayload } from '../signer/signed-token-payload.js' import { SignedTokenPayload } from '../signer/signed-token-payload.js'
@ -104,12 +105,12 @@ export class TokenManager {
| OAuthAuthorizationCodeGrantTokenRequest | OAuthAuthorizationCodeGrantTokenRequest
| OAuthClientCredentialsGrantTokenRequest | OAuthClientCredentialsGrantTokenRequest
| OAuthPasswordGrantTokenRequest, | OAuthPasswordGrantTokenRequest,
dpopJkt: null | string, dpopProof: null | DpopProof,
): Promise<OAuthTokenResponse> { ): Promise<OAuthTokenResponse> {
// @NOTE the atproto specific DPoP requirement is enforced though the // @NOTE the atproto specific DPoP requirement is enforced though the
// "dpop_bound_access_tokens" metadata, which is enforced by the // "dpop_bound_access_tokens" metadata, which is enforced by the
// ClientManager class. // ClientManager class.
if (client.metadata.dpop_bound_access_tokens && !dpopJkt) { if (client.metadata.dpop_bound_access_tokens && !dpopProof) {
throw new InvalidDpopProofError('DPoP proof required') throw new InvalidDpopProofError('DPoP proof required')
} }
@ -117,8 +118,10 @@ export class TokenManager {
// Allow clients to bind their access tokens to a DPoP key during // Allow clients to bind their access tokens to a DPoP key during
// token request if they didn't provide a "dpop_jkt" during the // token request if they didn't provide a "dpop_jkt" during the
// authorization request. // authorization request.
if (dpopJkt) parameters = { ...parameters, dpop_jkt: dpopJkt } if (dpopProof) parameters = { ...parameters, dpop_jkt: dpopProof.jkt }
} else if (parameters.dpop_jkt !== dpopJkt) { } else if (!dpopProof) {
throw new InvalidDpopProofError('DPoP proof required')
} else if (parameters.dpop_jkt !== dpopProof.jkt) {
throw new InvalidDpopKeyBindingError() throw new InvalidDpopKeyBindingError()
} }
@ -347,7 +350,7 @@ export class TokenManager {
clientAuth: ClientAuth, clientAuth: ClientAuth,
clientMetadata: RequestMetadata, clientMetadata: RequestMetadata,
input: OAuthRefreshTokenGrantTokenRequest, input: OAuthRefreshTokenGrantTokenRequest,
dpopJkt: null | string, dpopProof: null | DpopProof,
): Promise<OAuthTokenResponse> { ): Promise<OAuthTokenResponse> {
const refreshTokenParsed = refreshTokenSchema.safeParse(input.refresh_token) const refreshTokenParsed = refreshTokenSchema.safeParse(input.refresh_token)
if (!refreshTokenParsed.success) { if (!refreshTokenParsed.success) {
@ -381,9 +384,9 @@ export class TokenManager {
} }
if (parameters.dpop_jkt) { if (parameters.dpop_jkt) {
if (!dpopJkt) { if (!dpopProof) {
throw new InvalidDpopProofError('DPoP proof required') throw new InvalidDpopProofError('DPoP proof required')
} else if (parameters.dpop_jkt !== dpopJkt) { } else if (parameters.dpop_jkt !== dpopProof.jkt) {
throw new InvalidDpopKeyBindingError() throw new InvalidDpopKeyBindingError()
} }
} }
@ -531,7 +534,7 @@ export class TokenManager {
token: OAuthAccessToken, token: OAuthAccessToken,
tokenType: OAuthTokenType, tokenType: OAuthTokenType,
tokenId: TokenId, tokenId: TokenId,
dpopJkt: string | null, dpopProof: null | DpopProof,
verifyOptions?: VerifyTokenClaimsOptions, verifyOptions?: VerifyTokenClaimsOptions,
): Promise<VerifyTokenClaimsResult> { ): Promise<VerifyTokenClaimsResult> {
const tokenInfo = await this.getTokenInfo(tokenId).catch((err) => { const tokenInfo = await this.getTokenInfo(tokenId).catch((err) => {
@ -547,7 +550,7 @@ export class TokenManager {
const { parameters } = data const { parameters } = data
// Construct a list of claim, as if the token was a JWT. // Construct a list of claim, as if the token was a JWT.
const claims: SignedTokenPayload = { const tokenClaims: SignedTokenPayload = {
iss: this.signer.issuer, iss: this.signer.issuer,
jti: tokenId, jti: tokenId,
sub: account.sub, sub: account.sub,
@ -566,8 +569,8 @@ export class TokenManager {
token, token,
tokenId, tokenId,
tokenType, tokenType,
dpopJkt, tokenClaims,
claims, dpopProof,
verifyOptions, verifyOptions,
) )
} }

View File

@ -3,9 +3,13 @@ import { InvalidDpopKeyBindingError } from '../errors/invalid-dpop-key-binding-e
import { InvalidDpopProofError } from '../errors/invalid-dpop-proof-error.js' import { InvalidDpopProofError } from '../errors/invalid-dpop-proof-error.js'
import { asArray } from '../lib/util/cast.js' import { asArray } from '../lib/util/cast.js'
import { InvalidTokenError } from '../oauth-errors.js' import { InvalidTokenError } from '../oauth-errors.js'
import { DpopProof } from '../oauth-verifier.js'
import { SignedTokenPayload } from '../signer/signed-token-payload.js' import { SignedTokenPayload } from '../signer/signed-token-payload.js'
import { TokenId } from './token-id.js' import { TokenId } from './token-id.js'
const BEARER = 'Bearer' satisfies OAuthTokenType
const DPOP = 'DPoP' satisfies OAuthTokenType
export type VerifyTokenClaimsOptions = { export type VerifyTokenClaimsOptions = {
/** One of these audience must be included in the token audience(s) */ /** One of these audience must be included in the token audience(s) */
audience?: [string, ...string[]] audience?: [string, ...string[]]
@ -17,48 +21,73 @@ export type VerifyTokenClaimsResult = {
token: OAuthAccessToken token: OAuthAccessToken
tokenId: TokenId tokenId: TokenId
tokenType: OAuthTokenType tokenType: OAuthTokenType
claims: SignedTokenPayload tokenClaims: SignedTokenPayload
dpopProof: null | DpopProof
} }
export function verifyTokenClaims( export function verifyTokenClaims(
token: OAuthAccessToken, token: OAuthAccessToken,
tokenId: TokenId, tokenId: TokenId,
tokenType: OAuthTokenType, tokenType: OAuthTokenType,
dpopJkt: string | null, tokenClaims: SignedTokenPayload,
claims: SignedTokenPayload, dpopProof: null | DpopProof,
options?: VerifyTokenClaimsOptions, options?: VerifyTokenClaimsOptions,
): VerifyTokenClaimsResult { ): VerifyTokenClaimsResult {
const dateReference = Date.now() const dateReference = Date.now()
const claimsJkt = claims.cnf?.jkt ?? null
const expectedTokenType: OAuthTokenType = claimsJkt ? 'DPoP' : 'Bearer' if (tokenClaims.cnf?.jkt) {
if (expectedTokenType !== tokenType) { // An access token with a cnf.jkt claim must be a DPoP token
throw new InvalidTokenError(expectedTokenType, `Invalid token type`) if (tokenType !== DPOP) {
} throw new InvalidTokenError(
if (tokenType === 'DPoP' && !dpopJkt) { DPOP,
throw new InvalidDpopProofError(`jkt is required for DPoP tokens`) `Access token is bound to a DPoP proof, but token type is ${tokenType}`,
} )
if (claimsJkt !== dpopJkt) { }
throw new InvalidDpopKeyBindingError()
// DPoP token type must be used with a DPoP proof
if (!dpopProof) {
throw new InvalidDpopProofError(`DPoP proof required`)
}
// DPoP proof must be signed with the key that matches the "cnf" claim
if (tokenClaims.cnf.jkt !== dpopProof.jkt) {
throw new InvalidDpopKeyBindingError()
}
} else {
// An access token without a cnf.jkt claim must be a Bearer token
if (tokenType !== BEARER) {
throw new InvalidTokenError(
BEARER,
`Bearer token type must be used without a DPoP proof`,
)
}
// Unexpected DPoP proof received for a Bearer token
if (dpopProof) {
throw new InvalidTokenError(
BEARER,
`DPoP proof not expected for Bearer token type`,
)
}
} }
if (options?.audience) { if (options?.audience) {
const aud = asArray(claims.aud) const aud = asArray(tokenClaims.aud)
if (!options.audience.some((v) => aud.includes(v))) { if (!options.audience.some((v) => aud.includes(v))) {
throw new InvalidTokenError(tokenType, `Invalid audience`) throw new InvalidTokenError(tokenType, `Invalid audience`)
} }
} }
if (options?.scope) { if (options?.scope) {
const scopes = claims.scope?.split(' ') const scopes = tokenClaims.scope?.split(' ')
if (!scopes || !options.scope.some((v) => scopes.includes(v))) { if (!scopes || !options.scope.some((v) => scopes.includes(v))) {
throw new InvalidTokenError(tokenType, `Invalid scope`) throw new InvalidTokenError(tokenType, `Invalid scope`)
} }
} }
if (claims.exp != null && claims.exp * 1000 <= dateReference) { if (tokenClaims.exp != null && tokenClaims.exp * 1000 <= dateReference) {
throw new InvalidTokenError(tokenType, `Token expired`) throw new InvalidTokenError(tokenType, `Token expired`)
} }
return { token, tokenId, tokenType, claims } return { token, tokenId, tokenType, tokenClaims, dpopProof }
} }

View File

@ -490,19 +490,19 @@ export class AuthVerifier {
const originalUrl = const originalUrl =
('originalUrl' in req && req.originalUrl) || req.url || '/' ('originalUrl' in req && req.originalUrl) || req.url || '/'
const url = new URL(originalUrl, this._publicUrl) const url = new URL(originalUrl, this._publicUrl)
const result = await this.oauthVerifier.authenticateRequest( const { tokenClaims } = await this.oauthVerifier.authenticateRequest(
req.method || 'GET', req.method || 'GET',
url, url,
req.headers, req.headers,
{ audience: [this.dids.pds] }, { audience: [this.dids.pds] },
) )
const { sub } = result.claims const { sub } = tokenClaims
if (typeof sub !== 'string' || !sub.startsWith('did:')) { if (typeof sub !== 'string' || !sub.startsWith('did:')) {
throw new InvalidRequestError('Malformed token', 'InvalidToken') throw new InvalidRequestError('Malformed token', 'InvalidToken')
} }
const oauthScopes = new Set(result.claims.scope?.split(' ')) const oauthScopes = new Set(tokenClaims.scope?.split(' '))
if (!oauthScopes.has('transition:generic')) { if (!oauthScopes.has('transition:generic')) {
throw new AuthRequiredError( throw new AuthRequiredError(
@ -535,7 +535,7 @@ export class AuthVerifier {
return { return {
credentials: { credentials: {
type: 'oauth', type: 'oauth',
did: result.claims.sub, did: tokenClaims.sub,
scope: scopeEquivalent, scope: scopeEquivalent,
oauthScopes, oauthScopes,
isPrivileged: scopeEquivalent === AuthScope.AppPassPrivileged, isPrivileged: scopeEquivalent === AuthScope.AppPassPrivileged,

481
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff