Skip to content

Commit

Permalink
feat: add key attestations
Browse files Browse the repository at this point in the history
Signed-off-by: Timo Glastra <[email protected]>
  • Loading branch information
TimoGlastra committed Nov 23, 2024
1 parent 5225d70 commit a9ee6f1
Show file tree
Hide file tree
Showing 32 changed files with 792 additions and 189 deletions.
2 changes: 1 addition & 1 deletion packages/oauth2/src/Oauth2Client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,12 +21,12 @@ import {
createClientAttestationJwt,
} from './client-attestation/clent-attestation'
import { Oauth2ErrorCodes } from './common/v-oauth2-error'
import { extractDpopNonceFromHeaders } from './dpop/dpop'
import { Oauth2ClientAuthorizationChallengeError } from './error/Oauth2ClientAuthorizationChallengeError'
import { fetchAuthorizationServerMetadata } from './metadata/authorization-server/authorization-server-metadata'
import type { AuthorizationServerMetadata } from './metadata/authorization-server/v-authorization-server-metadata'
import { createPkce } from './pkce'
import { type ResourceRequestOptions, resourceRequest } from './resource-request/make-resource-request'
import { extractDpopNonceFromHeaders } from './dpop/dpop'

export interface Oauth2ClientOptions {
/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ describe('Parse Access Token Request', () => {
},
request: {
headers: new Headers({
DPoP: ['hello', 'two'],
DPoP: ['ey.ey.S', 'ey.ey.S'],
}),
method: 'POST',
url: 'https://request.com/token',
Expand Down
39 changes: 22 additions & 17 deletions packages/oauth2/src/access-token/create-access-token.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,16 @@
import { addSecondsToDate, dateToSeconds, encodeToBase64Url } from '@animo-id/oauth2-utils'
import { addSecondsToDate, dateToSeconds, encodeToBase64Url, parseWithErrorHandling } from '@animo-id/oauth2-utils'
import type { CallbackContext } from '../callbacks'
import { HashAlgorithm } from '../callbacks'
import { calculateJwkThumbprint } from '../common/jwk/jwk-thumbprint'
import type { Jwk } from '../common/jwk/v-jwk'
import { jwtHeaderFromJwtSigner } from '../common/jwt/decode-jwt'
import type { JwtSigner } from '../common/jwt/v-jwt'
import type { AccessTokenProfileJwtHeader, AccessTokenProfileJwtPayload } from './v-access-token-jwt'
import {
type AccessTokenProfileJwtHeader,
type AccessTokenProfileJwtPayload,
vAccessTokenProfileJwtHeader,
vAccessTokenProfileJwtPayload,
} from './v-access-token-jwt'

export interface CreateAccessTokenOptions {
callbacks: Pick<CallbackContext, 'signJwt' | 'generateRandom' | 'hash'>
Expand Down Expand Up @@ -70,14 +75,14 @@ export interface CreateAccessTokenOptions {
* @see https://datatracker.ietf.org/doc/html/rfc9068
*/
export async function createAccessTokenJwt(options: CreateAccessTokenOptions) {
const header = {
const header = parseWithErrorHandling(vAccessTokenProfileJwtHeader, {
...jwtHeaderFromJwtSigner(options.signer),
typ: 'at+jwt',
} satisfies AccessTokenProfileJwtHeader
} satisfies AccessTokenProfileJwtHeader)

const now = options.now ?? new Date()

const payload: AccessTokenProfileJwtPayload = {
const payload = parseWithErrorHandling(vAccessTokenProfileJwtPayload, {
iat: dateToSeconds(now),
exp: dateToSeconds(addSecondsToDate(now, options.expiresInSeconds)),
aud: options.audience,
Expand All @@ -86,23 +91,23 @@ export async function createAccessTokenJwt(options: CreateAccessTokenOptions) {
client_id: options.clientId,
sub: options.subject,
scope: options.scope,
cnf: options.dpopJwk
? {
jkt: await calculateJwkThumbprint({
hashAlgorithm: HashAlgorithm.Sha256,
hashCallback: options.callbacks.hash,
jwk: options.dpopJwk,
}),
}
: undefined,
...options.additionalPayload,
}

if (options.dpopJwk) {
payload.cnf = {
jkt: await calculateJwkThumbprint({
hashAlgorithm: HashAlgorithm.Sha256,
hashCallback: options.callbacks.hash,
jwk: options.dpopJwk,
}),
}
}
} satisfies AccessTokenProfileJwtPayload)

const jwt = await options.callbacks.signJwt(options.signer, {
const { jwt } = await options.callbacks.signJwt(options.signer, {
header,
payload,
})

return {
jwt,
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ export interface CreateAuthorizationChallengeErrorResponseOptions {
*
* If you want to require presentation of a
*/
error: Oauth2ErrorCodes | StringWithAutoCompletion
error: StringWithAutoCompletion<Oauth2ErrorCodes>

/**
* Optional error description
Expand Down
23 changes: 18 additions & 5 deletions packages/oauth2/src/callbacks.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { Fetch } from '@animo-id/oauth2-utils'
import type { Fetch, OrPromise } from '@animo-id/oauth2-utils'
import type { ClientAuthenticationCallback } from './client-authentication'
import type { Jwk } from './common/jwk/v-jwk'
import type { JwtHeader, JwtPayload, JwtSigner } from './common/jwt/v-jwt'

/**
Expand All @@ -12,19 +13,31 @@ export enum HashAlgorithm {
/**
* Callback used for operations that require hashing
*/
export type HashCallback = (data: Uint8Array, alg: HashAlgorithm) => Promise<Uint8Array> | Uint8Array
export type HashCallback = (data: Uint8Array, alg: HashAlgorithm) => OrPromise<Uint8Array>

export type GenerateRandomCallback = (byteLength: number) => Promise<Uint8Array> | Uint8Array
export type GenerateRandomCallback = (byteLength: number) => OrPromise<Uint8Array>

export type SignJwtCallback = (
jwtSigner: JwtSigner,
jwt: { header: JwtHeader; payload: JwtPayload }
) => Promise<string> | string
) => OrPromise<{
jwt: string
signerJwk: Jwk
}>

export type VerifyJwtCallback = (
jwtSigner: JwtSigner,
jwt: { header: JwtHeader; payload: JwtPayload; compact: string }
) => Promise<boolean> | boolean
) => OrPromise<
| {
verified: true
signerJwk: Jwk
}
| {
verified: false
signerJwk?: Jwk
}
>

/**
* Callback context provides the callbacks that are required for the oid4vc library
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,7 @@ export async function createClientAttestationJwt(options: CreateClientAttestatio
...options.additionalPayload,
} satisfies ClientAttestationJwtPayload)

const jwt = await options.callbacks.signJwt(options.signer, {
const { jwt } = await options.callbacks.signJwt(options.signer, {
header,
payload,
})
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -220,7 +220,7 @@ export async function createClientAttestationPopJwt(options: CreateClientAttesta
...options.additionalPayload,
} satisfies ClientAttestationPopJwtPayload)

const jwt = await options.callbacks.signJwt(options.signer, {
const { jwt } = await options.callbacks.signJwt(options.signer, {
header,
payload,
})
Expand Down
32 changes: 31 additions & 1 deletion packages/oauth2/src/common/jwk/jwks.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { type CallbackContext, HashAlgorithm } from '../../callbacks'
import { Oauth2Error } from '../../error/Oauth2Error'
import type { JwkSet } from './v-jwk'
import { calculateJwkThumbprint } from './jwk-thumbprint'
import type { Jwk, JwkSet } from './v-jwk'

interface ExtractJwkFromJwksForJwtOptions {
kid?: string
Expand Down Expand Up @@ -32,3 +34,31 @@ export function extractJwkFromJwksForJwt(options: ExtractJwkFromJwksForJwtOption
`Unable to extract jwk from jwks for use '${options.use}'${options.kid ? `with kid '${options.kid}'.` : '. No kid provided and more than jwk.'}`
)
}

export async function isJwkInSet({
jwk,
jwks,
callbacks,
}: {
jwk: Jwk
jwks: Jwk[]
callbacks: Pick<CallbackContext, 'hash'>
}) {
const jwkThumbprint = await calculateJwkThumbprint({
hashAlgorithm: HashAlgorithm.Sha256,
hashCallback: callbacks.hash,
jwk,
})

for (const jwkFromSet of jwks) {
const jwkFromSetThumbprint = await calculateJwkThumbprint({
hashAlgorithm: HashAlgorithm.Sha256,
hashCallback: callbacks.hash,
jwk: jwkFromSet,
})

if (jwkFromSetThumbprint === jwkThumbprint) return true
}

return false
}
22 changes: 22 additions & 0 deletions packages/oauth2/src/common/jwt/decode-jwt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,14 @@ export function jwtHeaderFromJwtSigner(signer: JwtSigner) {
} as const
}

if (signer.method === 'trustChain') {
return {
alg: signer.alg,
kid: signer.kid,
trust_chain: signer.trustChain,
} as const
}

if (signer.method === 'jwk') {
return {
alg: signer.alg,
Expand Down Expand Up @@ -111,6 +119,20 @@ export function jwtSignerFromJwt({ header, payload }: Pick<DecodeJwtResult, 'hea
x5c: header.x5c,
}
}

if (header.trust_chain) {
if (!header.kid) {
throw new Error(`When 'trust_chain' is used in jwt header, the 'kid' parameter is required.`)
}

return {
method: 'trustChain',
alg: header.alg,
trustChain: header.trust_chain,
kid: header.kid,
}
}

if (header.kid) {
if (header.kid.startsWith('did:')) {
if (payload.iss && header.kid.startsWith(payload.iss)) {
Expand Down
20 changes: 17 additions & 3 deletions packages/oauth2/src/common/jwt/v-jwt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,16 +21,27 @@ export type JwtSignerX5c = {
alg: string
}

export type JwtSignerTrustChain = {
method: 'trustChain'
trustChain: string[]
alg: string
kid: string
}

// In case of custom nothing will be added to the header
export type JwtSignerCustom = {
method: 'custom'
alg: string
}

export type JwtSigner = JwtSignerDid | JwtSignerJwk | JwtSignerX5c | JwtSignerCustom
export type JwtSigner = JwtSignerDid | JwtSignerJwk | JwtSignerX5c | JwtSignerTrustChain | JwtSignerCustom

// TODO: make more strict
export const vCompactJwt = v.string()
export type JwtSignerWithJwk = JwtSigner & { publicJwk: Jwk }

export const vCompactJwt = v.pipe(
v.string(),
v.regex(/^([a-zA-Z0-9_=]+)\.([a-zA-Z0-9_=]+)\.([a-zA-Z0-9_\-\+\/=]*)$/, 'Not a valid compact jwt')
)

export const vJwtConfirmationPayload = v.looseObject({
jwk: v.optional(vJwk),
Expand All @@ -49,6 +60,9 @@ export const vJwtPayload = v.looseObject({
jti: v.optional(v.string()),

cnf: v.optional(vJwtConfirmationPayload),

// Reserved for status parameters
status: v.optional(v.looseObject({})),
})
export type JwtPayload = v.InferOutput<typeof vJwtPayload>

Expand Down
23 changes: 19 additions & 4 deletions packages/oauth2/src/common/jwt/verify-jwt.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { dateToSeconds } from '@animo-id/oauth2-utils'
import type { VerifyJwtCallback } from '../../callbacks'
import { Oauth2JwtVerificationError } from '../../error/Oauth2JwtVerificationError'
import type { JwtHeader, JwtPayload, JwtSigner } from './v-jwt'
import type { Jwk } from '../jwk/v-jwk'
import type { JwtHeader, JwtPayload, JwtSigner, JwtSignerWithJwk } from './v-jwt'

export interface VerifyJwtOptions {
/**
Expand Down Expand Up @@ -76,16 +77,23 @@ export interface VerifyJwtOptions {
expectedSubject?: string
}

export async function verifyJwt(options: VerifyJwtOptions) {
export interface VerifyJwtReturn {
signer: JwtSignerWithJwk
}

export async function verifyJwt(options: VerifyJwtOptions): Promise<VerifyJwtReturn> {
const errorMessage = options.errorMessage ?? 'Error during verification of jwt.'

let signerJwk: Jwk
try {
const isValid = await options.verifyJwtCallback(options.signer, {
const result = await options.verifyJwtCallback(options.signer, {
header: options.header,
payload: options.payload,
compact: options.compact,
})

if (!isValid) throw new Oauth2JwtVerificationError(errorMessage)
if (!result.verified) throw new Oauth2JwtVerificationError(errorMessage)
signerJwk = result.signerJwk
} catch (error) {
if (error instanceof Oauth2JwtVerificationError) throw error
throw new Oauth2JwtVerificationError(errorMessage, { cause: error })
Expand Down Expand Up @@ -118,4 +126,11 @@ export async function verifyJwt(options: VerifyJwtOptions) {
if (options.expectedSubject && options.expectedSubject !== options.payload.sub) {
throw new Oauth2JwtVerificationError(`${errorMessage} jwt 'sub' does not match expected value.`)
}

return {
signer: {
...options.signer,
publicJwk: signerJwk,
},
}
}
2 changes: 1 addition & 1 deletion packages/oauth2/src/dpop/dpop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@ export async function createDpopJwt(options: CreateDpopJwtOptions) {
...options.additionalPayload,
} satisfies DpopJwtPayload)

const jwt = await options.callbacks.signJwt(options.signer, {
const { jwt } = await options.callbacks.signJwt(options.signer, {
header,
payload,
})
Expand Down
5 changes: 5 additions & 0 deletions packages/oauth2/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@ export { type HttpMethod, getGlobalConfig, setGlobalConfig, type Oid4vcTsConfig

export { Oauth2ErrorCodes, type Oauth2ErrorResponse, vOauth2ErrorResponse } from './common/v-oauth2-error'
export { calculateJwkThumbprint, type CalculateJwkThumbprintOptions } from './common/jwk/jwk-thumbprint'

// TODO: should we move this to oauth2-utils?
export { isJwkInSet } from './common/jwk/jwks'

export { type Jwk, type JwkSet, vJwk } from './common/jwk/v-jwk'
export type { AccessTokenProfileJwtPayload } from './access-token/v-access-token-jwt'

Expand All @@ -28,6 +32,7 @@ export {
JwtSignerDid,
JwtSignerJwk,
JwtSignerX5c,
JwtSignerWithJwk,
vJwtHeader,
vJwtPayload,
vCompactJwt,
Expand Down
14 changes: 11 additions & 3 deletions packages/oauth2/tests/util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,14 @@ export const callbacks = {
await jose.jwtVerify(compact, josePublicKey, {
currentDate: payload.exp ? new Date((payload.exp - 300) * 1000) : undefined,
})
return true
return {
verified: true,
signerJwk: jwk,
}
} catch (error) {
return false
return {
verified: false,
}
}
},
} as const satisfies Partial<CallbackContext>
Expand Down Expand Up @@ -71,6 +76,9 @@ export const getSignJwtCallback = (privateJwks: Jwk[]): SignJwtCallback => {
const josePrivateKey = await jose.importJWK(privateJwk as jose.JWK, signer.alg)
const jwt = await new jose.SignJWT(payload).setProtectedHeader(header).sign(josePrivateKey)

return jwt
return {
jwt: jwt,
signerJwk: jwk,
}
}
}
Loading

0 comments on commit a9ee6f1

Please sign in to comment.