Skip to content

Commit

Permalink
feat: add client attestations (#7)
Browse files Browse the repository at this point in the history
* feat: add client attestations
* feat: add key attestations

Signed-off-by: Timo Glastra <[email protected]>
  • Loading branch information
TimoGlastra authored Nov 23, 2024
1 parent 7ca0beb commit 0f60387
Show file tree
Hide file tree
Showing 42 changed files with 1,652 additions and 364 deletions.
5 changes: 5 additions & 0 deletions .changeset/brave-apricots-provide.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@animo-id/oauth2": minor
---

feat: add client attestations
5 changes: 5 additions & 0 deletions .changeset/old-feet-begin.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@animo-id/oid4vci": minor
---

feat: add key attestations
31 changes: 30 additions & 1 deletion packages/oauth2/src/Oauth2AuthorizationServer.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { parseWithErrorHandling } from '@animo-id/oauth2-utils'
import { type FetchHeaders, parseWithErrorHandling } from '@animo-id/oauth2-utils'
import { type CreateAccessTokenOptions, createAccessTokenJwt } from './access-token/create-access-token'
import {
type CreateAccessTokenResponseOptions,
Expand All @@ -22,6 +22,11 @@ import {
parseAuthorizationChallengeRequest,
} from './authorization-challenge/parse-authorization-challenge-request'
import type { CallbackContext } from './callbacks'
import {
extractClientAttestationJwtsFromHeaders,
verifyClientAttestationJwt,
} from './client-attestation/clent-attestation'
import { verifyClientAttestationPopJwt } from './client-attestation/client-attestation-pop'
import { Oauth2ErrorCodes } from './common/v-oauth2-error'
import {
type AuthorizationServerMetadata,
Expand Down Expand Up @@ -159,4 +164,28 @@ export class Oauth2AuthorizationServer {
public createAuthorizationChallengeErrorResponse(options: CreateAuthorizationChallengeErrorResponseOptions) {
return createAuthorizationChallengeErrorResponse(options)
}

public async verifyClientAttestation({
authorizationServer,
headers,
}: { authorizationServer: string; headers: FetchHeaders }) {
const { clientAttestationHeader, clientAttestationPopHeader } = extractClientAttestationJwtsFromHeaders(headers)

const clientAttestation = await verifyClientAttestationJwt({
callbacks: this.options.callbacks,
clientAttestationJwt: clientAttestationHeader,
})

const clientAttestationPop = await verifyClientAttestationPopJwt({
callbacks: this.options.callbacks,
authorizationServer,
clientAttestation,
clientAttestationPopJwt: clientAttestationPopHeader,
})

return {
clientAttestation,
clientAttestationPop,
}
}
}
34 changes: 34 additions & 0 deletions packages/oauth2/src/Oauth2Client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,12 @@ import {
createAuthorizationRequestUrl,
} from './authorization-request/create-authorization-request'
import type { CallbackContext } from './callbacks'
import {
type CreateClientAttestationJwtOptions,
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'
Expand Down Expand Up @@ -84,6 +89,8 @@ export class Oauth2Client {
pkceCodeVerifier: pkce?.codeVerifier,
scope: options.scope,
resource: options.resource,
clientAttestation: options.clientAttestation,
dpop: options.dpop,
})
} catch (error) {
// In this case we resume with the normal auth flow
Expand All @@ -102,7 +109,14 @@ export class Oauth2Client {
}
).toString()}`

const dpopNonce = extractDpopNonceFromHeaders(error.response.headers)
return {
dpop: options.dpop
? {
...options.dpop,
nonce: dpopNonce,
}
: undefined,
authorizationRequestUrl,
pkce,
}
Expand All @@ -118,6 +132,8 @@ export class Oauth2Client {
scope: options.scope,
pkceCodeVerifier: pkce?.codeVerifier,
resource: options.resource,
clientAttestation: options.clientAttestation,
dpop: options.dpop,
})
}

Expand All @@ -138,6 +154,8 @@ export class Oauth2Client {
scope: options.scope,
callbacks: this.options.callbacks,
pkceCodeVerifier: options.pkceCodeVerifier,
clientAttestation: options.clientAttestation,
dpop: options.dpop,
})
}

Expand All @@ -148,6 +166,7 @@ export class Oauth2Client {
txCode,
dpop,
resource,
clientAttestation,
}: Omit<RetrievePreAuthorizedCodeAccessTokenOptions, 'callbacks'>) {
const result = await retrievePreAuthorizedCodeAccessToken({
authorizationServerMetadata,
Expand All @@ -160,6 +179,7 @@ export class Oauth2Client {
},
callbacks: this.options.callbacks,
dpop,
clientAttestation,
})

return result
Expand All @@ -173,6 +193,7 @@ export class Oauth2Client {
redirectUri,
resource,
dpop,
clientAttestation,
}: Omit<RetrieveAuthorizationCodeAccessTokenOptions, 'callbacks'>) {
const result = await retrieveAuthorizationCodeAccessToken({
authorizationServerMetadata,
Expand All @@ -183,6 +204,7 @@ export class Oauth2Client {
callbacks: this.options.callbacks,
dpop,
redirectUri,
clientAttestation,
})

return result
Expand All @@ -194,6 +216,7 @@ export class Oauth2Client {
refreshToken,
resource,
dpop,
clientAttestation,
}: Omit<RetrieveRefreshTokenAccessTokenOptions, 'callbacks'>) {
const result = await retrieveRefreshTokenAccessToken({
authorizationServerMetadata,
Expand All @@ -202,6 +225,7 @@ export class Oauth2Client {
resource,
callbacks: this.options.callbacks,
dpop,
clientAttestation,
})

return result
Expand All @@ -210,4 +234,14 @@ export class Oauth2Client {
public async resourceRequest(options: ResourceRequestOptions) {
return resourceRequest(options)
}

/**
* @todo move this to another class?
*/
public async createClientAttestationJwt(options: Omit<CreateClientAttestationJwtOptions, 'callbacks'>) {
return await createClientAttestationJwt({
callbacks: this.options.callbacks,
...options,
})
}
}
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
Loading

0 comments on commit 0f60387

Please sign in to comment.