@sphereon/oid4vci-issuer
Version:
OpenID 4 Verifiable Credential Issuance issuer REST endpoints
269 lines (253 loc) • 10 kB
text/typescript
import { calculateJwkThumbprint, JWK, uuidv4 } from '@sphereon/oid4vc-common'
import {
AccessTokenRequest,
AccessTokenResponse,
Alg,
CNonceState,
CredentialOfferSession,
EXPIRED_PRE_AUTHORIZED_CODE,
GrantTypes,
INVALID_PRE_AUTHORIZED_CODE,
IssueStatus,
IStateManager,
Jwt,
JWTSignerCallback,
JWTVerifyCallback,
PIN_NOT_MATCH_ERROR,
PIN_VALIDATION_ERROR,
PRE_AUTH_CODE_LITERAL,
PRE_AUTHORIZED_CODE_REQUIRED_ERROR,
TokenError,
TokenErrorResponse,
UNSUPPORTED_GRANT_TYPE_ERROR,
USER_PIN_NOT_REQUIRED_ERROR,
USER_PIN_REQUIRED_ERROR,
USER_PIN_TX_CODE_SPEC_ERROR,
} from '@sphereon/oid4vci-common'
import { isPreAuthorizedCodeExpired } from '../functions'
export interface ITokenEndpointOpts {
tokenEndpointDisabled?: boolean // Disable if used in an existing OAuth2/OIDC environment and have the AS handle tokens
tokenPath?: string // token path can either be defined here, or will be deduced from issuer metadata
interval?: number
cNonceExpiresIn?: number
tokenExpiresIn?: number
preAuthorizedCodeExpirationDuration?: number
accessTokenSignerCallback?: JWTSignerCallback
accessTokenVerificationCallback?: JWTVerifyCallback
accessTokenIssuer?: string
accessTokenProvider?: AccessTokenProvider
}
export type AccessTokenProvider = 'internal' | 'oidc' | 'oauth2'
export const generateAccessToken = async (
opts: Required<Pick<ITokenEndpointOpts, 'accessTokenSignerCallback' | 'tokenExpiresIn' | 'accessTokenIssuer' | 'accessTokenProvider'>> & {
additionalClaims?: Record<string, unknown>
preAuthorizedCode?: string
alg?: Alg
dPoPJwk?: JWK
},
): Promise<string> => {
const {
dPoPJwk,
accessTokenIssuer,
alg,
accessTokenSignerCallback,
tokenExpiresIn,
preAuthorizedCode,
additionalClaims,
accessTokenProvider = 'internal',
} = opts
// JWT uses seconds for iat and exp
if (accessTokenProvider !== 'internal') {
throw new TokenError(
400,
TokenErrorResponse.invalid_request,
`Access token provider ${accessTokenProvider} is an external access token provider. We cannot generate tokens ourselves in this case`,
)
}
const iat = new Date().getTime() / 1000
const exp = iat + tokenExpiresIn
const cnf = dPoPJwk ? { cnf: { jkt: await calculateJwkThumbprint(dPoPJwk, 'sha256') } } : undefined
const jwt: Jwt = {
header: { typ: 'JWT', alg: alg ?? Alg.ES256 },
payload: {
iat,
exp,
iss: accessTokenIssuer,
...cnf,
...(preAuthorizedCode && { preAuthorizedCode }),
// Protected resources simultaneously supporting both the DPoP and Bearer schemes need to update how the
// evaluation process is performed for bearer tokens to prevent downgraded usage of a DPoP-bound access token.
// Specifically, such a protected resource MUST reject a DPoP-bound access token received as a bearer token per [RFC6750].
token_type: dPoPJwk ? 'DPoP' : 'Bearer',
...additionalClaims,
},
}
return await accessTokenSignerCallback(jwt)
}
export const isValidGrant = (assertedState: CredentialOfferSession, grantType: string): boolean => {
if (assertedState.credentialOffer?.credential_offer?.grants) {
// TODO implement authorization_code
return (
Object.keys(assertedState.credentialOffer?.credential_offer?.grants).includes(GrantTypes.PRE_AUTHORIZED_CODE) &&
grantType === GrantTypes.PRE_AUTHORIZED_CODE
)
}
return false
}
export const assertValidAccessTokenRequest = async (
request: AccessTokenRequest,
opts: {
credentialOfferSessions: IStateManager<CredentialOfferSession>
expirationDuration: number
},
) => {
const { credentialOfferSessions, expirationDuration } = opts
// Only pre-auth supported for now
if (request.grant_type !== GrantTypes.PRE_AUTHORIZED_CODE) {
throw new TokenError(400, TokenErrorResponse.invalid_grant, UNSUPPORTED_GRANT_TYPE_ERROR)
}
// Pre-auth flow
if (!request[PRE_AUTH_CODE_LITERAL]) {
throw new TokenError(400, TokenErrorResponse.invalid_request, PRE_AUTHORIZED_CODE_REQUIRED_ERROR)
}
const credentialOfferSession = await credentialOfferSessions.getAsserted(request[PRE_AUTH_CODE_LITERAL])
credentialOfferSession.status = IssueStatus.ACCESS_TOKEN_REQUESTED
credentialOfferSession.lastUpdatedAt = +new Date()
await credentialOfferSessions.set(request[PRE_AUTH_CODE_LITERAL], credentialOfferSession)
if (!isValidGrant(credentialOfferSession, request.grant_type)) {
throw new TokenError(400, TokenErrorResponse.invalid_grant, UNSUPPORTED_GRANT_TYPE_ERROR)
}
/*
invalid_request:
the Authorization Server does not expect a PIN in the pre-authorized flow but the client provides a PIN
*/
if (
!credentialOfferSession.credentialOffer.credential_offer?.grants?.[GrantTypes.PRE_AUTHORIZED_CODE]?.tx_code &&
request.tx_code &&
!request.user_pin
) {
// >= v13
throw new TokenError(400, TokenErrorResponse.invalid_request, USER_PIN_NOT_REQUIRED_ERROR)
} else if (
!credentialOfferSession.credentialOffer.credential_offer?.grants?.[GrantTypes.PRE_AUTHORIZED_CODE]?.user_pin_required &&
request.user_pin &&
!request.tx_code
) {
// <= v12
throw new TokenError(400, TokenErrorResponse.invalid_request, USER_PIN_NOT_REQUIRED_ERROR)
}
/*
invalid_request:
the Authorization Server expects a PIN in the pre-authorized flow but the client does not provide a PIN
*/
if (
// >= v13
!!credentialOfferSession.credentialOffer.credential_offer?.grants?.[GrantTypes.PRE_AUTHORIZED_CODE]?.tx_code &&
!request.tx_code
) {
if (request.user_pin) {
throw new TokenError(400, TokenErrorResponse.invalid_request, USER_PIN_TX_CODE_SPEC_ERROR)
}
throw new TokenError(400, TokenErrorResponse.invalid_request, USER_PIN_REQUIRED_ERROR)
} else if (
// <= v12
credentialOfferSession.credentialOffer.credential_offer?.grants?.[GrantTypes.PRE_AUTHORIZED_CODE]?.user_pin_required &&
!credentialOfferSession.credentialOffer.credential_offer?.grants?.[GrantTypes.PRE_AUTHORIZED_CODE]?.tx_code &&
!request.user_pin
) {
if (request.tx_code) {
throw new TokenError(400, TokenErrorResponse.invalid_request, USER_PIN_TX_CODE_SPEC_ERROR)
}
throw new TokenError(400, TokenErrorResponse.invalid_request, USER_PIN_REQUIRED_ERROR)
}
if (isPreAuthorizedCodeExpired(credentialOfferSession, expirationDuration)) {
throw new TokenError(400, TokenErrorResponse.invalid_grant, EXPIRED_PRE_AUTHORIZED_CODE)
} else if (
request[PRE_AUTH_CODE_LITERAL] !==
credentialOfferSession.credentialOffer?.credential_offer?.grants?.[GrantTypes.PRE_AUTHORIZED_CODE]?.[PRE_AUTH_CODE_LITERAL]
) {
throw new TokenError(400, TokenErrorResponse.invalid_grant, INVALID_PRE_AUTHORIZED_CODE)
}
/*
invalid_grant:
the Authorization Server expects a PIN in the pre-authorized flow but the client provides the wrong PIN
the End-User provides the wrong Pre-Authorized Code or the Pre-Authorized Code has expired
*/
if (request.tx_code) {
const txCodeOffer = credentialOfferSession.credentialOffer.credential_offer?.grants?.[GrantTypes.PRE_AUTHORIZED_CODE]?.tx_code
if (!txCodeOffer) {
throw new TokenError(400, TokenErrorResponse.invalid_request, USER_PIN_NOT_REQUIRED_ERROR)
} else if (txCodeOffer.input_mode === 'text') {
if (!RegExp(`[\\D]{${txCodeOffer.length}`).test(request.tx_code)) {
throw new TokenError(400, TokenErrorResponse.invalid_grant, `${PIN_VALIDATION_ERROR} ${txCodeOffer.length}`)
}
} else {
if (!RegExp(`[\\d]{${txCodeOffer.length}}`).test(request.tx_code)) {
throw new TokenError(400, TokenErrorResponse.invalid_grant, `${PIN_VALIDATION_ERROR} ${txCodeOffer.length}`)
}
}
if (request.tx_code !== credentialOfferSession.txCode) {
throw new TokenError(400, TokenErrorResponse.invalid_grant, PIN_NOT_MATCH_ERROR)
}
} else if (request.user_pin) {
if (!/[\\d]{1,8}/.test(request.user_pin)) {
throw new TokenError(400, TokenErrorResponse.invalid_grant, `${PIN_VALIDATION_ERROR} 1-8`)
} else if (request.user_pin !== credentialOfferSession.txCode) {
throw new TokenError(400, TokenErrorResponse.invalid_grant, PIN_NOT_MATCH_ERROR)
}
}
return { preAuthSession: credentialOfferSession }
}
export const createAccessTokenResponse = async (
request: AccessTokenRequest,
opts: {
credentialOfferSessions: IStateManager<CredentialOfferSession>
cNonces: IStateManager<CNonceState>
cNonce?: string
cNonceExpiresIn?: number // expiration in seconds
tokenExpiresIn: number // expiration in seconds
// preAuthorizedCodeExpirationDuration?: number
accessTokenSignerCallback: JWTSignerCallback
accessTokenIssuer: string
accessTokenProvider?: AccessTokenProvider
interval?: number
dPoPJwk?: JWK
},
) => {
const {
dPoPJwk,
credentialOfferSessions,
cNonces,
cNonceExpiresIn,
tokenExpiresIn,
accessTokenIssuer,
accessTokenSignerCallback,
interval,
accessTokenProvider = 'internal',
} = opts
// Pre-auth flow
const preAuthorizedCode = request[PRE_AUTH_CODE_LITERAL] as string
const cNonce = opts.cNonce ?? uuidv4()
await cNonces.set(cNonce, { cNonce, createdAt: +new Date(), preAuthorizedCode })
const access_token = await generateAccessToken({
tokenExpiresIn,
accessTokenSignerCallback,
preAuthorizedCode,
accessTokenIssuer,
dPoPJwk,
accessTokenProvider,
})
const response: AccessTokenResponse = {
access_token,
token_type: dPoPJwk ? 'DPoP' : 'bearer',
expires_in: tokenExpiresIn,
c_nonce: cNonce,
c_nonce_expires_in: cNonceExpiresIn,
interval,
}
const credentialOfferSession = await credentialOfferSessions.getAsserted(preAuthorizedCode)
credentialOfferSession.status = IssueStatus.ACCESS_TOKEN_CREATED
credentialOfferSession.lastUpdatedAt = +new Date()
await credentialOfferSessions.set(preAuthorizedCode, credentialOfferSession)
return response
}