Skip to content

Commit

Permalink
feat: presentation during issuance server side
Browse files Browse the repository at this point in the history
Signed-off-by: Timo Glastra <[email protected]>
  • Loading branch information
TimoGlastra committed Nov 9, 2024
1 parent b468f27 commit 6cf1477
Show file tree
Hide file tree
Showing 12 changed files with 213 additions and 27 deletions.
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@
An implementation of the [OAuth 2.0 Authorization Framework](https://datatracker.ietf.org/doc/html/rfc6749), including extension specifications.

- [RFC 9126 - OAuth 2.0 Pushed Authorization Requests](https://datatracker.ietf.org/doc/html/rfc9126)
- [OAuth 2.0 for First-Party Applications](https://www.ietf.org/archive/id/draft-parecki-oauth-first-party-apps-00.html)
- [OAuth 2.0 for First-Party Applications - Draft 0](hhttps://www.ietf.org/archive/id/draft-ietf-oauth-first-party-apps-00.html)
- [RFC 7636 - Proof Key for Code Exchange by OAuth Public Clients](https://datatracker.ietf.org/doc/html/rfc7636)
- [RFC 9449 - OAuth 2.0 Demonstrating Proof of Possession (DPoP)](https://datatracker.ietf.org/doc/html/rfc9449)
- [RFC 7662 - OAuth 2.0 Token Introspection](https://datatracker.ietf.org/doc/html/rfc7662)
Expand Down
46 changes: 46 additions & 0 deletions packages/oauth2/src/Oauth2AuthorizationServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,18 @@ import {
verifyAuthorizationCodeAccessTokenRequest,
verifyPreAuthorizedCodeAccessTokenRequest,
} from './access-token/verify-access-token-request'
import {
type CreateAuthorizationChallengeErrorResponseOptions,
type CreateAuthorizationChallengeResponseOptions,
createAuthorizationChallengeErrorResponse,
createAuthorizationChallengeResponse,
} from './authorization-challenge/create-authorization-challenge-response'
import {
type ParseAuthorizationChallengeRequestOptions,
parseAuthorizationChallengeRequest,
} from './authorization-challenge/parse-authorization-challenge-request'
import type { CallbackContext } from './callbacks'
import { Oauth2ErrorCodes } from './common/v-oauth2-error'
import {
type AuthorizationServerMetadata,
vAuthorizationServerMetadata,
Expand Down Expand Up @@ -113,4 +124,39 @@ export class Oauth2AuthorizationServer {
additionalPayload: options.additionalAccessTokenResponsePayload,
})
}

/**
* Parse an authorization challenge request
*/
public parseAuthorizationChallengeRequest(options: ParseAuthorizationChallengeRequestOptions) {
return parseAuthorizationChallengeRequest(options)
}

public createAuthorizationChallengeResponse(options: CreateAuthorizationChallengeResponseOptions) {
return createAuthorizationChallengeResponse(options)
}

/**
* Create an authorization challenge error response indicating presentation of credenitals
* using OpenID4VP is required before authorization can be granted.
*
* The `presentation` parameter should be an OpenID4VP authorization request url.
* The `authSession` should be used to track the session
*/
public createAuthorizationChallengePresentationErrorResponse(
options: Pick<CreateAuthorizationChallengeErrorResponseOptions, 'errorDescription' | 'additionalPayload'> &
Required<Pick<CreateAuthorizationChallengeErrorResponseOptions, 'authSession' | 'presentation'>>
) {
return createAuthorizationChallengeErrorResponse({
error: Oauth2ErrorCodes.InsufficientAuthorization,
errorDescription: options.errorDescription,
additionalPayload: options.additionalPayload,
authSession: options.authSession,
presentation: options.presentation,
})
}

public createAuthorizationChallengeErrorResponse(options: CreateAuthorizationChallengeErrorResponseOptions) {
return createAuthorizationChallengeErrorResponse(options)
}
}
2 changes: 1 addition & 1 deletion packages/oauth2/src/Oauth2Client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import {
import {
type SendAuthorizationChallengeRequestOptions,
sendAuthorizationChallengeRequest,
} from './authorization-challenge/authorization-challenge'
} from './authorization-challenge/send-authorization-challenge'
import {
type CreateAuthorizationRequestUrlOptions,
createAuthorizationRequestUrl,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import { type StringWithAutoCompletion, parseWithErrorHandling } from '@animo-id/oauth2-utils'
import type { Oauth2ErrorCodes } from '../common/v-oauth2-error'
import {
type AuthorizationChallengeErrorResponse,
type AuthorizationChallengeResponse,
vAuthorizationChallengeErrorResponse,
vAuthorizationChallengeResponse,
} from './v-authorization-challenge'

export interface CreateAuthorizationChallengeResponseOptions {
/**
* The authorization code
*/
authorizationCode: string

/**
* Additional payload to include in the authorization challenge response.
*/
additionalPayload?: Record<string, unknown>
}

/**
* Create an authorization challenge response
*
* @throws {ValidationError} if an error occured during verification of the {@link AuthorizationChallengeResponse}
*/
export function createAuthorizationChallengeResponse(options: CreateAuthorizationChallengeResponseOptions) {
const authorizationChallengeResponse = parseWithErrorHandling(vAuthorizationChallengeResponse, {
...options.additionalPayload,
authorization_code: options.authorizationCode,
} satisfies AuthorizationChallengeResponse)

return { authorizationChallengeResponse }
}

export interface CreateAuthorizationChallengeErrorResponseOptions {
/**
* Auth session identifier for the authorization challenge. The client MUST include this
* in subsequent requests to the authorization challenge endpoint.
*/
authSession?: string

/**
* The presentation during issuance error.
*
* Error codes specific to authorization challenge are:
* - @see Oauth2ErrorCodes.RedirectToWeb
* - @see Oauth2ErrorCodes.InvalidSession
* - @see Oauth2ErrorCodes.InsufficientAuthorization
*
* If you want to require presentation of a
*/
error: Oauth2ErrorCodes | StringWithAutoCompletion

/**
* Optional error description
*/
errorDescription?: string

/**
* OpenID4VP authorization request url that must be completed before authorization
* can be granted
*
* Should be combined with `error` @see Oauth2ErrorCodes.InsufficientAuthorization
*/
presentation?: string

/**
* Optional PAR request uri, allowing the authorization challenge request to be treated
* as a succesfull pushed authorization request.
*
* Should be combined with `error` @see Oauth2ErrorCodes.RedirectToWeb
*/
requestUri?: string

/**
* Duration is seconds after which the `requestUri` parameter will expire. Should only be included
* if the `requestUri` is also included, and has no meaning otherwise
*/
expiresIn?: number

/**
* Additional payload to include in the authorization challenge error response.
*/
additionalPayload?: Record<string, unknown>
}

/**
* Create an authorization challenge error response
*
* @throws {ValidationError} if an error occured during validation of the {@link AuthorizationChallengeErrorResponse}
*/
export function createAuthorizationChallengeErrorResponse(options: CreateAuthorizationChallengeErrorResponseOptions) {
const authorizationChallengeErrorResponse = parseWithErrorHandling(vAuthorizationChallengeErrorResponse, {
...options.additionalPayload,

// General FiPA
error: options.error,
error_description: options.errorDescription,
auth_session: options.authSession,

// Presentation during issuance
presentation: options.presentation,

// PAR
request_uri: options.requestUri,
expires_in: options.expiresIn,
} satisfies AuthorizationChallengeErrorResponse)

return authorizationChallengeErrorResponse
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { parseWithErrorHandling } from '@animo-id/oauth2-utils'
import { vAuthorizationChallengeRequest } from './v-authorization-challenge'

export interface ParseAuthorizationChallengeRequestOptions {
authorizationChallengeRequest: unknown
}

/**
* Parse an authorization challenge request.
*
* @throws {ValidationError} if a successful response was received but an error occured during verification of the {@link AuthorizationChallengeResponse}
*/
export async function parseAuthorizationChallengeRequest(options: ParseAuthorizationChallengeRequestOptions) {
const authorizationChallengeRequest = parseWithErrorHandling(
vAuthorizationChallengeRequest,
options.authorizationChallengeRequest
)

return { authorizationChallengeRequest }
}
Original file line number Diff line number Diff line change
Expand Up @@ -87,13 +87,15 @@ export async function sendAuthorizationChallengeRequest(options: SendAuthorizati
}

// PKCE
const pkce = authorizationServerMetadata.code_challenge_methods_supported
? await createPkce({
allowedCodeChallengeMethods: authorizationServerMetadata.code_challenge_methods_supported,
callbacks: options.callbacks,
codeVerifier: options.pkceCodeVerifier,
})
: undefined
// If auth session is included it's likely not needed to use PKCE
const pkce =
authorizationServerMetadata.code_challenge_methods_supported && !options.authSession
? await createPkce({
allowedCodeChallengeMethods: authorizationServerMetadata.code_challenge_methods_supported,
callbacks: options.callbacks,
codeVerifier: options.pkceCodeVerifier,
})
: undefined

const authorizationChallengeRequest = parseWithErrorHandling(vAuthorizationChallengeRequest, {
...options.additionalRequestPayload,
Expand Down
2 changes: 1 addition & 1 deletion packages/oauth2/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,7 @@ export { type Oauth2AuthorizationServerOptions, Oauth2AuthorizationServer } from
export { type Oauth2ResourceServerOptions, Oauth2ResourceServer } from './Oauth2ResourceServer'
export { type Oauth2ClientOptions, Oauth2Client } from './Oauth2Client'

export { PkceCodeChallengeMethod } from './pkce'
export { PkceCodeChallengeMethod, CreatePkceReturn } from './pkce'
export {
type AuthorizationCodeGrantIdentifier,
vAuthorizationCodeGrantIdentifier,
Expand Down
8 changes: 7 additions & 1 deletion packages/oauth2/src/pkce.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,13 @@ export interface CreatePkceOptions {
callbacks: Pick<CallbackContext, 'hash' | 'generateRandom'>
}

export async function createPkce(options: CreatePkceOptions) {
export interface CreatePkceReturn {
codeVerifier: string
codeChallenge: string
codeChallengeMethod: PkceCodeChallengeMethod
}

export async function createPkce(options: CreatePkceOptions): Promise<CreatePkceReturn> {
const allowedCodeChallengeMethods = options.allowedCodeChallengeMethods ?? [
PkceCodeChallengeMethod.S256,
PkceCodeChallengeMethod.Plain,
Expand Down
28 changes: 15 additions & 13 deletions packages/oid4vci/src/Oid4vciClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import {
preAuthorizedCodeGrantIdentifier,
} from '@animo-id/oauth2'

import type { createPkce } from '../../oauth2/src/pkce'
import type { CreatePkceReturn } from '../../oauth2/src/pkce'
import {
determineAuthorizationServerForCredentialOffer,
resolveCredentialOffer,
Expand Down Expand Up @@ -80,10 +80,11 @@ export class Oid4vciClient {
}

/**
* Retrieve an authorization code using an `presentation_during_issuance_session`.
* Retrieve an authorization code for a presentation during issuance session
*
* This can only be called if an authorization challenge was performed, and an authorization
* response including presentations was exchanged for a `presentation_during_issuance_session`
* This can only be called if an authorization challenge was performed before and returned a
* `presentation` paramater along with an `auth_session`. If the presentation response included
* an `presentation_during_issuance_session` parameter it MUST be included in this request as well.
*/
public async retrieveAuthorizationCodeUsingPresentation(options: {
/**
Expand All @@ -95,7 +96,7 @@ export class Oid4vciClient {
* Presentation during issuance session, obtained from the RP after submitting
* openid4vp authorization response
*/
presentationDuringIssuanceSession: string
presentationDuringIssuanceSession?: string

credentialOffer: CredentialOfferObject
issuerMetadata: IssuerMetadataResult
Expand All @@ -116,14 +117,13 @@ export class Oid4vciClient {
)

const oauth2Client = new Oauth2Client({ callbacks: this.options.callbacks })
// TODO: think what to do about pkce
const authorizationChallengeResponse = await oauth2Client.sendAuthorizationChallengeRequest({
const { authorizationChallengeResponse } = await oauth2Client.sendAuthorizationChallengeRequest({
authorizationServerMetadata,
authSession: options.authSession,
presentationDuringIssuanceSession: options.presentationDuringIssuanceSession,
})

return authorizationChallengeResponse
return { authorizationChallengeResponse }
}

/**
Expand Down Expand Up @@ -157,7 +157,7 @@ export class Oid4vciClient {
authorizationFlow: AuthorizationFlow.Oauth2Redirect
authorizationRequestUrl: string
authorizationServer: string
pkce?: Awaited<ReturnType<typeof createPkce>>
pkce?: CreatePkceReturn
}
> {
if (!options.credentialOffer.grants?.[authorizationCodeGrantIdentifier]) {
Expand Down Expand Up @@ -201,13 +201,15 @@ export class Oid4vciClient {
if (
error instanceof Oauth2ClientAuthorizationChallengeError &&
error.errorResponse.error === Oauth2ErrorCodes.InsufficientAuthorization &&
error.errorResponse.presentation &&
// TODO: we should probably throw an specifc error if presentation is defined but not auth_session?
error.errorResponse.auth_session
error.errorResponse.presentation
) {
if (!error.errorResponse.auth_session) {
throw new Oid4vciError(
`Expected 'auth_session' to be defined with authorization challenge response error '${error.errorResponse.error}' and 'presentation' parameter`
)
}
return {
authorizationFlow: AuthorizationFlow.PresentationDuringIssuance,
// TODO: name? presenationRequestUrl, oid4vpRequestUrl, ??
oid4vpRequestUrl: error.errorResponse.presentation,
authSession: error.errorResponse.auth_session,
authorizationServer: authorizationServerMetadata.issuer,
Expand Down
2 changes: 0 additions & 2 deletions packages/oid4vci/src/__tests__/Oid4vciClient.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -523,8 +523,6 @@ describe('Oid4vciClient', () => {
) {
expect(authorizationChallengeRequest).toEqual({
auth_session: 'auth-session-identifier',
code_challenge: expect.any(String),
code_challenge_method: 'S256',
presentation_during_issuance_session: 'some-session',
})
return HttpResponse.json(presentationDuringIssuance.authorizationChallengeResponse)
Expand Down
2 changes: 1 addition & 1 deletion packages/utils/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ export {
valibotRecursiveFlattenIssues,
} from './parse'
export { joinUriParts } from './path'
export type { Optional, Simplify } from './type'
export type { Optional, Simplify, StringWithAutoCompletion } from './type'
export { getQueryParams, objectToQueryParams } from './url'
export { type ValibotFetcher, createValibotFetcher, defaultFetcher } from './valibot-fetcher'
export { type HttpMethod, vHttpMethod, vHttpsUrl, vInteger } from './validation'
Expand Down
1 change: 1 addition & 0 deletions packages/utils/src/type.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
export type Simplify<T> = { [KeyType in keyof T]: T[KeyType] } & {}
export type Optional<T, K extends keyof T> = Omit<T, K> & Partial<Pick<T, K>>
export type StringWithAutoCompletion = string & {}

0 comments on commit 6cf1477

Please sign in to comment.