UNPKG

@sphereon/oid4vci-issuer

Version:

OpenID 4 Verifiable Credential Issuance issuer REST endpoints

269 lines (253 loc) • 10 kB
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 }