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:
parent
a3b24ca77c
commit
3fa2ee3b6a
5
.changeset/metal-oranges-sing.md
Normal file
5
.changeset/metal-oranges-sing.md
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"@atproto/oauth-provider": minor
|
||||||
|
---
|
||||||
|
|
||||||
|
Improve validation of DPoP proofs
|
5
.changeset/nasty-knives-kick.md
Normal file
5
.changeset/nasty-knives-kick.md
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"@atproto/jwk": minor
|
||||||
|
---
|
||||||
|
|
||||||
|
Properly validate JWK `htu` claim by enforcing URL without query or fragment
|
5
.changeset/sweet-ways-allow.md
Normal file
5
.changeset/sweet-ways-allow.md
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"@atproto/pds": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Log clients using invalid "htu" claim in DPoP proof
|
5
.changeset/weak-cycles-speak.md
Normal file
5
.changeset/weak-cycles-speak.md
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"@atproto/oauth-provider": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Return DPoP validation result from `authenticateRequest`
|
@ -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": [
|
||||||
|
@ -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(),
|
||||||
|
@ -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)
|
||||||
}
|
}
|
||||||
|
6
packages/oauth/oauth-provider/src/dpop/dpop-proof.ts
Normal file
6
packages/oauth/oauth-provider/src/dpop/dpop-proof.ts
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
export type DpopProof = {
|
||||||
|
jti: string
|
||||||
|
jkt: string
|
||||||
|
htm: string
|
||||||
|
htu: string
|
||||||
|
}
|
@ -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',
|
||||||
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@ -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,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -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
|
||||||
|
@ -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) {
|
||||||
|
@ -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,
|
||||||
)
|
)
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
@ -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,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -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 }
|
||||||
}
|
}
|
||||||
|
@ -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
481
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Loading…
x
Reference in New Issue
Block a user