Skip to content

Commit

Permalink
feat: authorization challenge
Browse files Browse the repository at this point in the history
Signed-off-by: Timo Glastra <[email protected]>
  • Loading branch information
TimoGlastra committed Nov 4, 2024
1 parent 2e81cf1 commit d1bbf81
Show file tree
Hide file tree
Showing 21 changed files with 707 additions and 26 deletions.
2 changes: 1 addition & 1 deletion biome.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@
"noRestrictedGlobals": {
"level": "error",
"options": {
"deniedGlobals": ["URL", "URLSearchParams", "fetch"]
"deniedGlobals": ["URL", "URLSearchParams", "fetch", "Response", "Headers"]
}
}
},
Expand Down
81 changes: 81 additions & 0 deletions packages/oauth2/src/Oauth2Client.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,24 @@
import { objectToQueryParams } from '@animo-id/oid4vc-utils'
import {
type RetrieveAuthorizationCodeAccessTokenOptions,
type RetrievePreAuthorizedCodeAccessTokenOptions,
retrieveAuthorizationCodeAccessToken,
retrievePreAuthorizedCodeAccessToken,
} from './access-token/retrieve-access-token'
import {
type SendAuthorizationChallengeRequestOptions,
sendAuthorizationChallengeRequest,
} from './authorization-challenge/authorization-challenge'
import {
type CreateAuthorizationRequestUrlOptions,
createAuthorizationRequestUrl,
} from './authorization-request/create-authorization-request'
import type { CallbackContext } from './callbacks'
import { Oauth2ErrorCodes } from './common/v-oauth2-error'
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'

export interface Oauth2ClientOptions {
/**
Expand Down Expand Up @@ -42,6 +50,79 @@ export class Oauth2Client {
return fetchAuthorizationServerMetadata(issuer, this.options.callbacks.fetch)
}

/**
* Initiate authorization.
*
* It will take the followings steps:
* - if `authorization_challenge_endpoint` is defined, send an authorization challenge request
* - if authorization challenge request returns a `redirect_to_web` error code with `request_uri`
* then construct the authorization request url based on the `request_uri`
* - if the `authorization_challenge_endpoint` is not defined, or authorization challenge request reuturns a `redirect_to_web` error code without `request_uri`
* then the authorization request url will be constructed as usual (optionally using PAR).
*
* @throws {Oauth2ClientAuthorizationChallengeError} in case of an error response. If `error` is
* `insufficient_authorization` possible extra steps can be taken.
*/
public async initiateAuthorization(options: Omit<CreateAuthorizationRequestUrlOptions, 'callbacks'>) {
const pkce = options.authorizationServerMetadata.code_challenge_methods_supported
? await createPkce({
allowedCodeChallengeMethods: options.authorizationServerMetadata.code_challenge_methods_supported,
callbacks: this.options.callbacks,
codeVerifier: options.pkceCodeVerifier,
})
: undefined

if (options.authorizationServerMetadata.authorization_challenge_endpoint) {
try {
await this.sendAuthorizationChallengeRequest({
authorizationServerMetadata: options.authorizationServerMetadata,
additionalRequestPayload: options.additionalRequestPayload,
clientId: options.clientId,
pkceCodeVerifier: pkce?.codeVerifier,
scope: options.scope,
})
} catch (error) {
// In this case we resume with the normal auth flow
const isRecoverableError =
error instanceof Oauth2ClientAuthorizationChallengeError &&
error.errorResponse.error === Oauth2ErrorCodes.RedirectToWeb

if (!isRecoverableError) throw error

// If a request_uri was returned we can treat the response as if PAR was used
if (error.errorResponse.request_uri) {
const authorizationRequestUrl = `${options.authorizationServerMetadata.authorization_endpoint}?${objectToQueryParams(
{
request_uri: error.errorResponse.request_uri,
client_id: options.clientId,
}
)}`

return {
authorizationRequestUrl,
pkce,
}
}
}
}

return this.createAuthorizationRequestUrl({
authorizationServerMetadata: options.authorizationServerMetadata,
clientId: options.clientId,
additionalRequestPayload: options.additionalRequestPayload,
redirectUri: options.redirectUri,
scope: options.scope,
pkceCodeVerifier: pkce?.codeVerifier,
})
}

public sendAuthorizationChallengeRequest(options: Omit<SendAuthorizationChallengeRequestOptions, 'callbacks'>) {
return sendAuthorizationChallengeRequest({
...options,
callbacks: this.options.callbacks,
})
}

public async createAuthorizationRequestUrl(options: Omit<CreateAuthorizationRequestUrlOptions, 'callbacks'>) {
return createAuthorizationRequestUrl({
authorizationServerMetadata: options.authorizationServerMetadata,
Expand Down
1 change: 1 addition & 0 deletions packages/oauth2/src/access-token/introspect-token.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { Oauth2Error } from '../error/Oauth2Error'
import { Oauth2InvalidFetchResponseError } from '../error/Oauth2InvalidFetchResponseError'
import type { AuthorizationServerMetadata } from '../metadata/authorization-server/v-authorization-server-metadata'

import { Headers } from '@animo-id/oid4vc-utils'
import type { CallbackContext } from '../callbacks'
import {
type TokenIntrospectionRequest,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@ export function parseAccessTokenRequest(options: ParseAccessTokenRequestOptions)
} else {
// Unsupported grant type
throw new Oauth2ServerErrorResponseError({
error: Oauth2ErrorCodes.InvalidGrant,
error: Oauth2ErrorCodes.UnsupportedGrantType,
error_description: `The grant type '${accessTokenRequest.grant_type}' is not supported`,
})
}
Expand Down
142 changes: 142 additions & 0 deletions packages/oauth2/src/authorization-challenge/authorization-challenge.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
import {
ValidationError,
createValibotFetcher,
objectToQueryParams,
parseWithErrorHandling,
} from '@animo-id/oid4vc-utils'
import * as v from 'valibot'
import type { CallbackContext } from '../callbacks'
import { ContentType } from '../common/content-type'
import { Oauth2ClientAuthorizationChallengeError } from '../error/Oauth2ClientAuthorizationChallengeError'
import { Oauth2Error } from '../error/Oauth2Error'
import { Oauth2InvalidFetchResponseError } from '../error/Oauth2InvalidFetchResponseError'
import type { AuthorizationServerMetadata } from '../metadata/authorization-server/v-authorization-server-metadata'
import { createPkce } from '../pkce'
import {
type AuthorizationChallengeRequest,
vAuthorizationChallengeErrorResponse,
vAuthorizationChallengeRequest,
vAuthorizationChallengeResponse,
} from './v-authorization-challenge'

export interface SendAuthorizationChallengeRequestOptions {
/**
* Callback context
*/
callbacks: Pick<CallbackContext, 'fetch' | 'hash' | 'generateRandom'>

/**
* Metadata of the authorization server where to perform the authorization challenge
*/
authorizationServerMetadata: AuthorizationServerMetadata

/**
* Previously established auth session
*/
authSession?: string

/**
* The client id to use for the authorization challenge request
*/
clientId?: string

/**
* Scope to request for the authorization challenge request
*/
scope?: string

/**
* Presentation during issuance sessios if credentials were presented
* as part of an issuance session
*/
presentationDuringIssuanceSession?: string

/**
* Additional payload to include in the authorization challenge request. Items will be encoded and sent
* using x-www-form-urlencoded format. Nested items (JSON) will be stringified and url encoded.
*/
additionalRequestPayload?: Record<string, unknown>

/**
* Code verifier to use for pkce. If not provided a value will generated when pkce is supported
*/
pkceCodeVerifier?: string
}

/**
* Send an authorization challenge request.
*
* @throws {Oauth2ClientAuthorizationChallengeError} if the request failed and a {@link AuthorizationChallengeErrorResponse} is returned
* @throws {Oauth2InvalidFetchResponseError} if the request failed but no error response could be parsed
* @throws {ValidationError} if a successful response was received but an error occured during verification of the {@link AuthorizationChallengeResponse}
*/
export async function sendAuthorizationChallengeRequest(options: SendAuthorizationChallengeRequestOptions) {
const fetchWithValibot = createValibotFetcher(options.callbacks.fetch)

const authorizationServerMetadata = options.authorizationServerMetadata
if (!authorizationServerMetadata.authorization_challenge_endpoint) {
throw new Oauth2Error(
`Unable to send authorization challange. Authorization server '${authorizationServerMetadata.issuer}' has no 'authorization_challenge_endpoint'`
)
}

// PKCE
const pkce = authorizationServerMetadata.code_challenge_methods_supported
? await createPkce({
allowedCodeChallengeMethods: authorizationServerMetadata.code_challenge_methods_supported,
callbacks: options.callbacks,
codeVerifier: options.pkceCodeVerifier,
})
: undefined

const authorizationChallengeRequest = parseWithErrorHandling(vAuthorizationChallengeRequest, {
...options.additionalRequestPayload,
auth_session: options.authSession,
client_id: options.clientId,
scope: options.scope,
code_challenge: pkce?.codeChallenge,
code_challenge_method: pkce?.codeChallengeMethod,
presentation_during_issuance_session: options.presentationDuringIssuanceSession,
} satisfies AuthorizationChallengeRequest)

const { response, result } = await fetchWithValibot(
vAuthorizationChallengeResponse,
authorizationServerMetadata.authorization_challenge_endpoint,
{
method: 'POST',
body: objectToQueryParams(authorizationChallengeRequest),
headers: {
'Content-Type': ContentType.XWwwFormUrlencoded,
},
}
)

if (!response.ok || !result) {
const authorizationChallengeErrorResponse = v.safeParse(
vAuthorizationChallengeErrorResponse,
await response
.clone()
.json()
.catch(() => null)
)
if (authorizationChallengeErrorResponse.success) {
throw new Oauth2ClientAuthorizationChallengeError(
`Error requesting authorization code from authorization challenge endpoint '${authorizationServerMetadata.authorization_challenge_endpoint}'. Received response with status ${response.status}`,
authorizationChallengeErrorResponse.output,
response
)
}

throw new Oauth2InvalidFetchResponseError(
`Error requesting authorization code from authorization challenge endpoint '${authorizationServerMetadata.authorization_challenge_endpoint}'. Received response with status ${response.status}`,
await response.clone().text(),
response
)
}

if (!result.success) {
throw new ValidationError('Error validating authorization challenge response', result.issues)
}

return { pkce, authorizationChallengeResponse: result.output }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { vInteger } from '@animo-id/oid4vc-utils'
import * as v from 'valibot'
import { vOauth2ErrorResponse } from '../common/v-oauth2-error'

export const vAuthorizationChallengeRequest = v.looseObject({
client_id: v.optional(v.string()),
scope: v.optional(v.string()),
auth_session: v.optional(v.string()),

// PKCE
code_challenge: v.optional(v.string()),
code_challenge_method: v.optional(v.string()),

// DRAFT presentation during issuance
presentation_during_issuance_session: v.optional(v.string()),
})
export type AuthorizationChallengeRequest = v.InferOutput<typeof vAuthorizationChallengeRequest>

export const vAuthorizationChallengeResponse = v.looseObject({
authorization_code: v.string(),
})
export type AuthorizationChallengeResponse = v.InferOutput<typeof vAuthorizationChallengeResponse>

export const vAuthorizationChallengeErrorResponse = v.looseObject({
...vOauth2ErrorResponse.entries,
auth_session: v.optional(v.string()),
request_uri: v.optional(v.string()),
expires_in: v.optional(vInteger),

// DRAFT: presentation during issuance
presentation: v.optional(v.string()),
})
export type AuthorizationChallengeErrorResponse = v.InferOutput<typeof vAuthorizationChallengeErrorResponse>
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ export interface CreateAuthorizationRequestUrlOptions {
redirectUri?: string

/**
* Additional payload to include in the authorizatino request. Items will be encoded and sent
* Additional payload to include in the authorization request. Items will be encoded and sent
* using x-www-form-urlencoded format. Nested items (JSON) will be stringified and url encoded.
*/
additionalRequestPayload?: Record<string, unknown>
Expand Down Expand Up @@ -117,7 +117,6 @@ export async function createAuthorizationRequestUrl(options: CreateAuthorization
return {
authorizationRequestUrl,
pkce,
authorizationServer: authorizationServerMetadata.issuer,
}
}

Expand Down
15 changes: 14 additions & 1 deletion packages/oauth2/src/common/v-oauth2-error.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,30 @@
import * as v from 'valibot'

export enum Oauth2ErrorCodes {
// Oauth2
InvalidRequest = 'invalid_request',
InvalidToken = 'invalid_token',
InsufficientScope = 'insufficient_scope',
InvalidGrant = 'invalid_grant',
InvalidClient = 'invalid_client',
UnauthorizedClient = 'unauthorized_client',
UnsupportedGrantType = 'unsupported_grant_type',
InvalidScope = 'invalid_scope',

// DPoP
InvalidDpopProof = 'invalid_dpop_proof',
UseDpopNonce = 'use_dpop_nonce',
InvalidGrant = 'invalid_grant',

// FaPI
RedirectToWeb = 'redirect_to_web',
InvalidSession = 'invalid_session',
InsufficientAuthorization = 'insufficient_authorization',
}

export const vOauth2ErrorResponse = v.looseObject({
error: v.union([v.enum(Oauth2ErrorCodes), v.string()]),
error_description: v.optional(v.string()),
error_uri: v.optional(v.string()),
})

export type Oauth2ErrorResponse = v.InferOutput<typeof vOauth2ErrorResponse>
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import type { FetchResponse } from '@animo-id/oid4vc-utils'
import type { AuthorizationChallengeErrorResponse } from '../authorization-challenge/v-authorization-challenge'
import { Oauth2ClientErrorResponseError } from './Oauth2ClientErrorResponseError'

export class Oauth2ClientAuthorizationChallengeError extends Oauth2ClientErrorResponseError {
public constructor(
message: string,
public readonly errorResponse: AuthorizationChallengeErrorResponse,
response: FetchResponse
) {
super(message, errorResponse, response)
}
}
7 changes: 6 additions & 1 deletion packages/oauth2/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ export {
} from './error/Oauth2ResourceUnauthorizedError'
export { Oauth2InvalidFetchResponseError } from './error/Oauth2InvalidFetchResponseError'
export { Oauth2ClientErrorResponseError } from './error/Oauth2ClientErrorResponseError'
export { Oauth2ClientAuthorizationChallengeError } from './error/Oauth2ClientAuthorizationChallengeError'
export { Oauth2ServerErrorResponseError } from './error/Oauth2ServerErrorResponseError'

export {
Expand All @@ -57,13 +58,17 @@ export {
export { fetchJwks } from './metadata/fetch-jwks-uri'
export { fetchWellKnownMetadata } from './metadata/fetch-well-known-metadata'

export { SupportedAuthenticationScheme } from './access-token/verify-access-token'
export type {
RetrieveAuthorizationCodeAccessTokenOptions,
RetrievePreAuthorizedCodeAccessTokenOptions,
} from './access-token/retrieve-access-token'
export type { CreateAuthorizationRequestUrlOptions } from './authorization-request/create-authorization-request'
export { resourceRequestWithDpopRetry, type ResourceRequestOptions } from './resource-request/make-resource-request'
export { type VerifyResourceRequestOptions, verifyResourceRequest } from './resource-request/verify-resource-request'
export {
type VerifyResourceRequestOptions,
verifyResourceRequest,
} from './resource-request/verify-resource-request'

export type {
CallbackContext,
Expand Down
Loading

0 comments on commit d1bbf81

Please sign in to comment.