├── .changeset ├── afraid-falcons-work.md ├── afraid-waves-post.md ├── chatty-buttons-sip.md ├── commit.js ├── config.json ├── cruel-pants-search.md ├── fair-hairs-bathe.md ├── funny-actors-kiss.md ├── healthy-items-push.md ├── lemon-buses-pump.md ├── lovely-schools-brush.md ├── mighty-cougars-work.md ├── perfect-bees-clean.md ├── rare-pillows-taste.md ├── red-ladybugs-perform.md ├── selfish-dolls-fix.md ├── tame-plants-kneel.md ├── three-clouds-return.md ├── twelve-kangaroos-help.md └── twenty-shirts-love.md ├── .github └── workflows │ ├── ci.yml │ └── release.yml ├── .gitignore ├── README.md ├── biome.json ├── package.json ├── packages ├── oauth2 │ ├── CHANGELOG.md │ ├── README.md │ ├── package.json │ ├── src │ │ ├── Oauth2AuthorizationServer.ts │ │ ├── Oauth2Client.ts │ │ ├── Oauth2ResourceServer.ts │ │ ├── __tests__ │ │ │ ├── Oauth2AuthorizationServer.test.mts │ │ │ └── Oauth2ResourceServer.test.mts │ │ ├── access-token │ │ │ ├── __tests__ │ │ │ │ ├── parse-access-token-request.test.mts │ │ │ │ └── verify-access-token-request.test.mts │ │ │ ├── create-access-token-response.ts │ │ │ ├── create-access-token.ts │ │ │ ├── introspect-token.ts │ │ │ ├── parse-access-token-request.ts │ │ │ ├── retrieve-access-token.ts │ │ │ ├── verify-access-token-request.ts │ │ │ ├── verify-access-token.ts │ │ │ ├── z-access-token-jwt.ts │ │ │ ├── z-access-token.ts │ │ │ └── z-token-introspection.ts │ │ ├── authorization-challenge │ │ │ ├── create-authorization-challenge-response.ts │ │ │ ├── parse-authorization-challenge-request.ts │ │ │ ├── send-authorization-challenge.ts │ │ │ ├── verify-authorization-challenge-request.ts │ │ │ └── z-authorization-challenge.ts │ │ ├── authorization-request │ │ │ ├── create-authorization-request.ts │ │ │ ├── create-pushed-authorization-response.ts │ │ │ ├── parse-authorization-request.ts │ │ │ ├── parse-pushed-authorization-request.ts │ │ │ ├── verify-authorization-request.ts │ │ │ ├── verify-pushed-authorization-request.ts │ │ │ └── z-authorization-request.ts │ │ ├── callbacks.ts │ │ ├── client-attestation │ │ │ ├── clent-attestation.ts │ │ │ ├── client-attestation-pop.ts │ │ │ └── z-client-attestation.ts │ │ ├── client-authentication.ts │ │ ├── common │ │ │ ├── jwk │ │ │ │ ├── jwk-thumbprint.ts │ │ │ │ ├── jwks.ts │ │ │ │ └── z-jwk.ts │ │ │ ├── jwt │ │ │ │ ├── __tests__ │ │ │ │ │ └── decode-jwt.test.mts │ │ │ │ ├── decode-jwt-header.ts │ │ │ │ ├── decode-jwt.ts │ │ │ │ ├── verify-jwt.ts │ │ │ │ ├── z-jwe.ts │ │ │ │ └── z-jwt.ts │ │ │ ├── z-common.ts │ │ │ └── z-oauth2-error.ts │ │ ├── dpop │ │ │ ├── dpop-retry.ts │ │ │ ├── dpop.ts │ │ │ └── z-dpop.ts │ │ ├── error │ │ │ ├── Oauth2ClientAuthorizationChallengeError.ts │ │ │ ├── Oauth2ClientErrorResponseError.ts │ │ │ ├── Oauth2Error.ts │ │ │ ├── Oauth2InvalidFetchResponseError.ts │ │ │ ├── Oauth2JwtParseError.ts │ │ │ ├── Oauth2JwtVerificationError.ts │ │ │ ├── Oauth2ResourceUnauthorizedError.ts │ │ │ └── Oauth2ServerErrorResponseError.ts │ │ ├── index.ts │ │ ├── metadata │ │ │ ├── authorization-server │ │ │ │ ├── __tests__ │ │ │ │ │ └── z-authorization-server-metadata.test.mts │ │ │ │ ├── authorization-server-metadata.ts │ │ │ │ └── z-authorization-server-metadata.ts │ │ │ ├── fetch-jwks-uri.ts │ │ │ └── fetch-well-known-metadata.ts │ │ ├── pkce.ts │ │ ├── resource-request │ │ │ ├── make-resource-request.ts │ │ │ └── verify-resource-request.ts │ │ └── z-grant-type.ts │ ├── tests │ │ └── util.mts │ └── tsconfig.json ├── openid4vci │ ├── CHANGELOG.md │ ├── README.md │ ├── package.json │ ├── src │ │ ├── Openid4vciClient.ts │ │ ├── Openid4vciIssuer.ts │ │ ├── Openid4vciWalletProvider.ts │ │ ├── __tests__ │ │ │ ├── Openid4vciClient.test.mts │ │ │ ├── Openid4vciIssuer.test.mts │ │ │ └── __fixtures__ │ │ │ │ ├── bdr.ts │ │ │ │ ├── paradym.ts │ │ │ │ └── presentationDuringIssuance.ts │ │ ├── credential-offer │ │ │ ├── __tests__ │ │ │ │ ├── credential-offer.test.mts │ │ │ │ └── z-credential-offer.test.mts │ │ │ ├── credential-offer.ts │ │ │ └── z-credential-offer.ts │ │ ├── credential-request │ │ │ ├── __tests__ │ │ │ │ ├── parse-credential-request.test.mts │ │ │ │ └── z-credential-request.test.mts │ │ │ ├── credential-request-configurations.ts │ │ │ ├── credential-response.ts │ │ │ ├── format-payload.ts │ │ │ ├── parse-credential-request.ts │ │ │ ├── retrieve-credentials.ts │ │ │ ├── z-credential-request-common.ts │ │ │ ├── z-credential-request.ts │ │ │ └── z-credential-response.ts │ │ ├── error │ │ │ ├── Openid4vciError.ts │ │ │ ├── Openid4vciRetrieveCredentialsError.ts │ │ │ └── Openid4vciSendNotificationError.ts │ │ ├── formats │ │ │ ├── credential │ │ │ │ ├── index.ts │ │ │ │ ├── mso-mdoc │ │ │ │ │ ├── __tests__ │ │ │ │ │ │ └── z-mso-mdoc.test.mts │ │ │ │ │ └── z-mso-mdoc.ts │ │ │ │ ├── sd-jwt-dc │ │ │ │ │ ├── __tests__ │ │ │ │ │ │ └── z-sd-jwt-dc.test.mts │ │ │ │ │ └── z-sd-jwt-dc.ts │ │ │ │ ├── sd-jwt-vc │ │ │ │ │ ├── __tests__ │ │ │ │ │ │ └── z-sd-jwt-vc.test.mts │ │ │ │ │ └── z-sd-jwt-vc.ts │ │ │ │ └── w3c-vc │ │ │ │ │ ├── z-w3c-jwt-vc-json-ld.ts │ │ │ │ │ ├── z-w3c-jwt-vc-json.ts │ │ │ │ │ ├── z-w3c-ldp-vc.ts │ │ │ │ │ └── z-w3c-vc-common.ts │ │ │ └── proof-type │ │ │ │ ├── attestation │ │ │ │ ├── attestation-proof-type.ts │ │ │ │ └── z-attestation-proof-type.ts │ │ │ │ ├── index.ts │ │ │ │ └── jwt │ │ │ │ ├── jwt-proof-type.ts │ │ │ │ └── z-jwt-proof-type.ts │ │ ├── index.ts │ │ ├── key-attestation │ │ │ ├── key-attestation.ts │ │ │ └── z-key-attestation.ts │ │ ├── metadata │ │ │ ├── credential-issuer │ │ │ │ ├── __tests__ │ │ │ │ │ ├── credential-configurations.test.mts │ │ │ │ │ ├── z-credential-configurations.test.mts │ │ │ │ │ └── z-credential-issuer-metadata.test.mts │ │ │ │ ├── credential-configurations.ts │ │ │ │ ├── credential-issuer-metadata.ts │ │ │ │ ├── z-claims-description.ts │ │ │ │ ├── z-credential-configuration-supported-common.ts │ │ │ │ └── z-credential-issuer-metadata.ts │ │ │ └── fetch-issuer-metadata.ts │ │ ├── nonce │ │ │ ├── nonce-request.ts │ │ │ └── z-nonce.ts │ │ ├── notification │ │ │ ├── notification.ts │ │ │ └── z-notification.ts │ │ └── version.ts │ ├── tests │ │ └── full-flow.test.mts │ └── tsconfig.json ├── openid4vp │ ├── Readme.md │ ├── package.json │ ├── src │ │ ├── Openid4vpClient.ts │ │ ├── Openid4vpVerifier.ts │ │ ├── authorization-request │ │ │ ├── __tests__ │ │ │ │ └── parse-authorization-request-params.test.mts │ │ │ ├── create-authorization-request.ts │ │ │ ├── parse-authorization-request-params.ts │ │ │ ├── resolve-authorization-request.ts │ │ │ ├── validate-authorization-request-dc-api.ts │ │ │ ├── validate-authorization-request.ts │ │ │ ├── z-authorization-request-dc-api.ts │ │ │ └── z-authorization-request.ts │ │ ├── authorization-response │ │ │ ├── create-authorization-response.ts │ │ │ ├── parse-authorization-response-payload.ts │ │ │ ├── parse-authorization-response.ts │ │ │ ├── parse-jarm-authorization-response.ts │ │ │ ├── submit-authorization-response.ts │ │ │ ├── validate-authorization-response-result.ts │ │ │ ├── validate-authorization-response.ts │ │ │ └── z-authorization-response.ts │ │ ├── client-identifier-scheme │ │ │ ├── parse-client-identifier-scheme.ts │ │ │ ├── validate-verifier-attestation-jwt.ts │ │ │ └── z-client-id-scheme.ts │ │ ├── fetch-client-metadata.ts │ │ ├── index.ts │ │ ├── jar │ │ │ ├── create-jar-authorization-request.ts │ │ │ ├── handle-jar-request │ │ │ │ ├── validate-jar-request-against-session.ts │ │ │ │ └── verify-jar-request.ts │ │ │ ├── jar-request-object │ │ │ │ ├── fetch-jar-request-object.ts │ │ │ │ └── z-jar-request-object.ts │ │ │ ├── z-jar-authorization-request.ts │ │ │ ├── z-jar-authorization-server-metadata.ts │ │ │ └── z-jar-client-metadata.ts │ │ ├── jarm │ │ │ ├── jarm-authorizatino-response-send.ts │ │ │ ├── jarm-authorization-response-create.ts │ │ │ ├── jarm-authorization-response │ │ │ │ ├── jarm-validate-authorization-response.ts │ │ │ │ ├── verify-jarm-authorization-response.ts │ │ │ │ └── z-jarm-authorization-response.ts │ │ │ ├── jarm-extract-jwks.ts │ │ │ ├── jarm-response-mode.ts │ │ │ ├── metadata │ │ │ │ ├── jarm-assert-metadata-supported.ts │ │ │ │ ├── z-jarm-authorization-server-metadata.ts │ │ │ │ └── z-jarm-client-metadata.ts │ │ │ └── parse-jarm-authorization-response-direct-post-jwt.ts │ │ ├── models │ │ │ ├── z-client-metadata.ts │ │ │ ├── z-credential-formats.ts │ │ │ ├── z-pex.ts │ │ │ ├── z-proof-formats.ts │ │ │ ├── z-verifier-attestations.ts │ │ │ ├── z-vp-formats-supported.ts │ │ │ └── z-wallet-metadata.ts │ │ ├── transaction-data │ │ │ ├── parse-transaction-data.ts │ │ │ ├── verify-transaction-data.test.mts │ │ │ ├── verify-transaction-data.ts │ │ │ └── z-transaction-data.ts │ │ ├── version.ts │ │ └── vp-token │ │ │ ├── parse-vp-token.ts │ │ │ └── z-vp-token.ts │ ├── tests │ │ ├── full-flow.test.mts │ │ ├── parse-client-identifier-scheme.test.mts │ │ └── version-inference.test.mts │ └── tsconfig.json └── utils │ ├── CHANGELOG.md │ ├── README.md │ ├── package.json │ ├── src │ ├── __tests__ │ │ ├── validation-error.test.mts │ │ └── www-authenticate.test.mts │ ├── array.ts │ ├── config.ts │ ├── content-type.ts │ ├── date.ts │ ├── encoding.ts │ ├── error │ │ ├── FetchError.ts │ │ ├── InvalidFetchResponseError.ts │ │ ├── JsonParseError.ts │ │ └── ValidationError.ts │ ├── fetcher.ts │ ├── globals.ts │ ├── index.ts │ ├── object.ts │ ├── parse.ts │ ├── path.ts │ ├── type.ts │ ├── url.ts │ ├── validation.ts │ ├── www-authenticate.ts │ └── zod-error.ts │ └── tsconfig.json ├── pnpm-lock.yaml ├── pnpm-workspace.yaml ├── tsconfig.json └── vite.config.js /.changeset/afraid-falcons-work.md: -------------------------------------------------------------------------------- 1 | --- 2 | "@openid4vc/openid4vci": minor 3 | "@openid4vc/oauth2": minor 4 | "@openid4vc/utils": minor 5 | --- 6 | 7 | Add support for OpenID4VCI draft 15. It also includes improved support for client (wallet) attestations, and better support for server side verification. 8 | 9 | Due to the changes between Draft 14 and Draft 15 and it's up to the caller of this library to handle the difference between the versions. Draft 11 is still supported based on Draft 14 syntax (and thus will be automatically converted). 10 | -------------------------------------------------------------------------------- /.changeset/afraid-waves-post.md: -------------------------------------------------------------------------------- 1 | --- 2 | "@openid4vc/openid4vp": patch 3 | --- 4 | 5 | Added `verifier_attestations` to DC Api type 6 | -------------------------------------------------------------------------------- /.changeset/chatty-buttons-sip.md: -------------------------------------------------------------------------------- 1 | --- 2 | "@openid4vc/openid4vp": minor 3 | --- 4 | 5 | feat: initial version of openid4vp 6 | -------------------------------------------------------------------------------- /.changeset/commit.js: -------------------------------------------------------------------------------- 1 | const { execSync } = require('node:child_process') 2 | 3 | const getSignedOffBy = () => { 4 | const gitUserName = execSync('git config user.name').toString('utf-8').trim() 5 | const gitEmail = execSync('git config user.email').toString('utf-8').trim() 6 | 7 | return `Signed-off-by: ${gitUserName} <${gitEmail}>` 8 | } 9 | 10 | const getAddMessage = async (changeset) => { 11 | return `docs(changeset): ${changeset.summary}\n\n${getSignedOffBy()}\n` 12 | } 13 | 14 | const getVersionMessage = async () => { 15 | return `chore(release): new version\n\n${getSignedOffBy()}\n` 16 | } 17 | 18 | module.exports = { 19 | getAddMessage, 20 | getVersionMessage, 21 | } 22 | -------------------------------------------------------------------------------- /.changeset/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://unpkg.com/@changesets/config@3.0.1/schema.json", 3 | "changelog": "@changesets/cli/changelog", 4 | "commit": "./commit", 5 | "privatePackages": false, 6 | "fixed": [["@openid4vc/*"]], 7 | "access": "public", 8 | "baseBranch": "main", 9 | "updateInternalDependencies": "patch", 10 | "snapshot": { 11 | "useCalculatedVersion": true 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /.changeset/cruel-pants-search.md: -------------------------------------------------------------------------------- 1 | --- 2 | "@openid4vc/openid4vp": minor 3 | "@openid4vc/oauth2": minor 4 | --- 5 | 6 | apu and apv in JWE encryptor are now base64 encoded values, to align with JOSE 7 | -------------------------------------------------------------------------------- /.changeset/fair-hairs-bathe.md: -------------------------------------------------------------------------------- 1 | --- 2 | "@openid4vc/oauth2": patch 3 | --- 4 | 5 | fix: make key_ops array of strings instead of string in jwk 6 | -------------------------------------------------------------------------------- /.changeset/funny-actors-kiss.md: -------------------------------------------------------------------------------- 1 | --- 2 | "@openid4vc/openid4vci": minor 3 | "@openid4vc/oauth2": minor 4 | "@openid4vc/utils": minor 5 | --- 6 | 7 | fix typo in param from authorizationServerMetata to authorizationServerMetadata 8 | -------------------------------------------------------------------------------- /.changeset/healthy-items-push.md: -------------------------------------------------------------------------------- 1 | --- 2 | "@openid4vc/openid4vci": minor 3 | --- 4 | 5 | renamed oid4vci to openid4vci. This includes the package name, but also the class names and oid4vpRequestUrl to openid4vpRequestUrl 6 | -------------------------------------------------------------------------------- /.changeset/lemon-buses-pump.md: -------------------------------------------------------------------------------- 1 | --- 2 | "@openid4vc/openid4vci": minor 3 | "@openid4vc/oauth2": minor 4 | --- 5 | 6 | replace the `dpopJwk` return value with `dpop` object with `jwk` key. It now also returns the `jwkThumbprint` 7 | -------------------------------------------------------------------------------- /.changeset/lovely-schools-brush.md: -------------------------------------------------------------------------------- 1 | --- 2 | "@openid4vc/openid4vci": patch 3 | --- 4 | 5 | support key-attestation+jwt in addition to keyattestation+jwt when verifying key attestation 6 | -------------------------------------------------------------------------------- /.changeset/mighty-cougars-work.md: -------------------------------------------------------------------------------- 1 | --- 2 | "@openid4vc/openid4vci": patch 3 | "@openid4vc/oauth2": patch 4 | --- 5 | 6 | fix: path where oauth2 authorization server metadata is retrieved from. 7 | 8 | For OAuth you need to put `.well-known/oauth-authorization-server` between the origin and path (so `https://funke.animo.id/provider` becomes `https://funke.animo.id/.well-known/oauth-authorization-server/provider`). We were putting the well known path after the full issuer url. 9 | 10 | It will now first check the correct path, and fall back to the invalid path. 11 | -------------------------------------------------------------------------------- /.changeset/perfect-bees-clean.md: -------------------------------------------------------------------------------- 1 | --- 2 | "@openid4vc/openid4vci": minor 3 | "@openid4vc/oauth2": minor 4 | "@openid4vc/utils": minor 5 | --- 6 | 7 | Before this PR, all packages used Valibot for data validation. 8 | We have now fully transitioned to Zod. This introduces obvious breaking changes for some packages that re-exported Valibot types or schemas for example. 9 | -------------------------------------------------------------------------------- /.changeset/rare-pillows-taste.md: -------------------------------------------------------------------------------- 1 | --- 2 | "@openid4vc/openid4vp": patch 3 | --- 4 | 5 | Exposed types and schema for verifier attestations 6 | -------------------------------------------------------------------------------- /.changeset/red-ladybugs-perform.md: -------------------------------------------------------------------------------- 1 | --- 2 | "@openid4vc/openid4vp": minor 3 | "@openid4vc/oauth2": minor 4 | "@openid4vc/openid4vci": minor 5 | --- 6 | 7 | refactor: change the jwt signer method 'trustChain' to 'federation' and make 'trustChain' variable optional. 8 | -------------------------------------------------------------------------------- /.changeset/selfish-dolls-fix.md: -------------------------------------------------------------------------------- 1 | --- 2 | "@openid4vc/openid4vp": patch 3 | --- 4 | 5 | fix: export JarmMode enum 6 | -------------------------------------------------------------------------------- /.changeset/tame-plants-kneel.md: -------------------------------------------------------------------------------- 1 | --- 2 | "@openid4vc/openid4vp": patch 3 | --- 4 | 5 | - Added `verifier_attestations` parsing 6 | -------------------------------------------------------------------------------- /.changeset/three-clouds-return.md: -------------------------------------------------------------------------------- 1 | --- 2 | "@openid4vc/oauth2": patch 3 | --- 4 | 5 | feat: add `kid` to the JwtSigner interface 6 | -------------------------------------------------------------------------------- /.changeset/twelve-kangaroos-help.md: -------------------------------------------------------------------------------- 1 | --- 2 | "@openid4vc/oauth2": minor 3 | "@openid4vc/openid4vci": minor 4 | --- 5 | 6 | add support for sha-384, sha-512, rename SHA-256 to sha-256 to align with IANA hash algorithm names (https://www.iana.org/assignments/named-information/named-information.xhtml) 7 | -------------------------------------------------------------------------------- /.changeset/twenty-shirts-love.md: -------------------------------------------------------------------------------- 1 | --- 2 | "@openid4vc/openid4vp": patch 3 | "@openid4vc/oauth2": patch 4 | "@openid4vc/utils": patch 5 | "@openid4vc/openid4vci": patch 6 | --- 7 | 8 | fix: create fetch wrapper that always calls toString on URLSearchParams as React Native does not encode this correctly while Node.JS does 9 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: Continuous Integration 2 | 3 | on: 4 | pull_request_review: 5 | 6 | pull_request: 7 | branches: 8 | - main 9 | push: 10 | branches: 11 | - main 12 | 13 | jobs: 14 | validate: 15 | if: github.event_name != 'pull_request_review' || github.event.pull_request.head.ref == 'changeset-release/main' 16 | runs-on: ubuntu-24.04 17 | name: Validate 18 | steps: 19 | - name: Checkout Repo 20 | uses: actions/checkout@v4 21 | 22 | - uses: pnpm/action-setup@v4 23 | - name: Setup NodeJS 24 | uses: actions/setup-node@v4 25 | with: 26 | node-version: 20 27 | cache: "pnpm" 28 | 29 | - name: Install dependencies 30 | run: pnpm install --frozen-lockfile 31 | 32 | - name: Check Style 33 | run: pnpm style:check 34 | 35 | - name: Check Types 36 | run: pnpm types:check 37 | 38 | - name: Compile 39 | run: pnpm build 40 | 41 | tests: 42 | runs-on: ubuntu-24.04 43 | name: Tests 44 | if: github.event_name != 'pull_request_review' || github.event.pull_request.head.ref == 'changeset-release/main' 45 | 46 | strategy: 47 | fail-fast: false 48 | matrix: 49 | node-version: [18, 20] 50 | 51 | steps: 52 | - name: Checkout Repo 53 | uses: actions/checkout@v4 54 | 55 | - uses: pnpm/action-setup@v4 56 | - name: Setup NodeJS 57 | id: setup-node 58 | uses: actions/setup-node@v4 59 | with: 60 | node-version: ${{ matrix.node-version }} 61 | 62 | # See https://github.com/actions/setup-node/issues/641#issuecomment-1358859686 63 | - name: pnpm cache path 64 | id: pnpm-cache-path 65 | run: | 66 | echo "STORE_PATH=$(pnpm store path)" >> $GITHUB_OUTPUT 67 | 68 | - name: pnpm cache 69 | uses: actions/cache@v3 70 | with: 71 | path: ${{ steps.pnpm-cache-path.outputs.STORE_PATH }} 72 | key: ${{ runner.os }}-${{ steps.setup-node.outputs.node-version }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} 73 | restore-keys: | 74 | ${{ runner.os }}-${{ steps.setup-node.outputs.node-version }}-pnpm-store- 75 | 76 | - name: Install dependencies 77 | run: pnpm install --frozen-lockfile 78 | 79 | - name: Run tests 80 | run: pnpm test 81 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | dist 4 | .tgz -------------------------------------------------------------------------------- /biome.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://biomejs.dev/schemas/1.9.4/schema.json", 3 | "files": { 4 | "maxSize": 10000000 5 | }, 6 | "formatter": { 7 | "lineWidth": 120, 8 | "indentStyle": "space" 9 | }, 10 | "javascript": { 11 | "parser": { 12 | "unsafeParameterDecoratorsEnabled": true 13 | }, 14 | "formatter": { 15 | "semicolons": "asNeeded", 16 | "quoteStyle": "single", 17 | "trailingCommas": "es5", 18 | "lineWidth": 120, 19 | "indentStyle": "space" 20 | } 21 | }, 22 | "json": { 23 | "parser": { 24 | "allowComments": true 25 | } 26 | }, 27 | "organizeImports": { 28 | "enabled": true 29 | }, 30 | "linter": { 31 | "enabled": true, 32 | "rules": { 33 | "recommended": true, 34 | "style": { 35 | "noRestrictedGlobals": { 36 | "level": "error", 37 | "options": { 38 | "deniedGlobals": ["URL", "URLSearchParams", "fetch", "Response", "Headers"] 39 | } 40 | } 41 | }, 42 | "correctness": { 43 | "recommended": true, 44 | "noUnusedImports": "error" 45 | } 46 | } 47 | }, 48 | "vcs": { 49 | "useIgnoreFile": true, 50 | "clientKind": "git", 51 | "enabled": true 52 | }, 53 | "overrides": [ 54 | { 55 | "include": ["*.test.ts", "**/tests/*", "*.test.mts"], 56 | "linter": { 57 | "rules": { 58 | "style": { 59 | "noRestrictedGlobals": { 60 | "level": "info", 61 | "options": {} 62 | } 63 | } 64 | } 65 | } 66 | } 67 | ] 68 | } 69 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "oid4vc-ts", 3 | "private": true, 4 | "description": "Environment agnostic TypeScript implementation of OpenID4VC specifications", 5 | "packageManager": "pnpm@10.7.0+sha512.6b865ad4b62a1d9842b61d674a393903b871d9244954f652b8842c2b553c72176b278f64c463e52d40fff8aba385c235c8c9ecf5cc7de4fd78b8bb6d49633ab6", 6 | "repository": { 7 | "url": "https://github.com/openwallet-foundation-labs/oid4vc-ts", 8 | "type": "git" 9 | }, 10 | "author": "Animo Solutions", 11 | "license": "Apache-2.0", 12 | "scripts": { 13 | "types:check": "tsc --noEmit", 14 | "style:check": "biome check --unsafe", 15 | "style:fix": "biome check --write --unsafe", 16 | "build": "pnpm -r build", 17 | "test": "vitest", 18 | "release": "pnpm build && pnpm changeset publish --no-git-tag", 19 | "changeset-version": "pnpm changeset version && pnpm style:fix" 20 | }, 21 | "devDependencies": { 22 | "@biomejs/biome": "^1.9.4", 23 | "@changesets/cli": "^2.28.1", 24 | "msw": "^2.7.3", 25 | "tsup": "^8.4.0", 26 | "typescript": "^5.8.2", 27 | "vitest": "^3.0.9" 28 | }, 29 | "engines": { 30 | "node": ">=18" 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /packages/oauth2/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # @openid4vc/oauth2 2 | 3 | ## 0.2.0 4 | 5 | ### Minor Changes 6 | 7 | - 0f60387: feat: add client attestations 8 | - 3f6d360: change order of fetching authorization server metadata. First `oauth-authorization-server` metadata is fetched now. If that returns a 404, the `openid-configuration` will be fetched. 9 | 10 | ### Patch Changes 11 | 12 | - @openid4vc/utils@0.2.0 13 | 14 | ## 0.1.4 15 | 16 | ### Patch Changes 17 | 18 | - 12a517a: feat: add refresh_token grant type 19 | - @openid4vc/utils@0.1.4 20 | 21 | ## 0.1.3 22 | 23 | ### Patch Changes 24 | 25 | - d4b9279: chore: create github release 26 | - Updated dependencies [d4b9279] 27 | - @openid4vc/utils@0.1.3 28 | 29 | ## 0.1.2 30 | 31 | ### Patch Changes 32 | 33 | - 1de27e5: chore: correct formatting for publishing 34 | - Updated dependencies [1de27e5] 35 | - @openid4vc/utils@0.1.2 36 | 37 | ## 0.1.1 38 | 39 | ### Patch Changes 40 | 41 | - 6434781: docs: add readme 42 | - Updated dependencies [6434781] 43 | - @openid4vc/utils@0.1.1 44 | 45 | ## 0.1.0 46 | 47 | ### Minor Changes 48 | 49 | - 71326c8: feat: initial release 50 | 51 | ### Patch Changes 52 | 53 | - Updated dependencies [71326c8] 54 | - @openid4vc/utils@0.1.0 55 | -------------------------------------------------------------------------------- /packages/oauth2/README.md: -------------------------------------------------------------------------------- 1 |

OpenID for Verifiable Credentials - OAuth2

2 | 3 |

4 | 5 | 6 | 7 |

8 | 9 | Check out the documentation in the [Github repository](https://github.com/openwallet-foundation-labs/oid4vc-ts). 10 | -------------------------------------------------------------------------------- /packages/oauth2/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@openid4vc/oauth2", 3 | "version": "0.2.0", 4 | "files": ["dist"], 5 | "license": "Apache-2.0", 6 | "exports": "./src/index.ts", 7 | "homepage": "https://github.com/openwallet-foundation-labs/oid4vc-ts/tree/main/packages/oauth2", 8 | "repository": { 9 | "type": "git", 10 | "url": "https://github.com/openwallet-foundation-labs/oid4vc-ts", 11 | "directory": "packages/oauth2" 12 | }, 13 | "publishConfig": { 14 | "main": "./dist/index.js", 15 | "module": "./dist/index.mjs", 16 | "types": "./dist/index.d.ts", 17 | "exports": { 18 | ".": { 19 | "import": "./dist/index.mjs", 20 | "require": "./dist/index.js", 21 | "types": "./dist/index.d.ts" 22 | }, 23 | "./package.json": "./package.json" 24 | } 25 | }, 26 | "scripts": { 27 | "build": "tsup src/index.ts --format cjs,esm --dts --clean --sourcemap" 28 | }, 29 | "dependencies": { 30 | "@openid4vc/utils": "workspace:*", 31 | "zod": "catalog:" 32 | }, 33 | "devDependencies": { 34 | "jose": "catalog:" 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /packages/oauth2/src/Oauth2ResourceServer.ts: -------------------------------------------------------------------------------- 1 | import { type VerifyResourceRequestOptions, verifyResourceRequest } from '.' 2 | import type { CallbackContext } from './callbacks' 3 | 4 | export interface Oauth2ResourceServerOptions { 5 | /** 6 | * Callbacks required for the oauth2 resource server 7 | */ 8 | callbacks: Pick 9 | } 10 | 11 | export class Oauth2ResourceServer { 12 | public constructor(private options: Oauth2ResourceServerOptions) {} 13 | 14 | public async verifyResourceRequest(options: Omit) { 15 | return verifyResourceRequest({ 16 | callbacks: this.options.callbacks, 17 | ...options, 18 | }) 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /packages/oauth2/src/access-token/create-access-token-response.ts: -------------------------------------------------------------------------------- 1 | import { parseWithErrorHandling } from '@openid4vc/utils' 2 | import type { CallbackContext } from '../callbacks' 3 | import { type AccessTokenResponse, zAccessTokenResponse } from './z-access-token' 4 | 5 | export interface CreateAccessTokenResponseOptions { 6 | callbacks: Pick 7 | 8 | /** 9 | * The access token 10 | */ 11 | accessToken: string 12 | 13 | /** 14 | * The type of token. Should be DPoP if the access token 15 | * is bound to a dpop key 16 | */ 17 | tokenType: 'DPoP' | 'Bearer' | (string & {}) 18 | 19 | /** 20 | * Number of seconds after which the access tokens expires. 21 | */ 22 | expiresInSeconds: number 23 | 24 | /** 25 | * New cNonce value 26 | */ 27 | cNonce?: string 28 | cNonceExpiresIn?: number 29 | 30 | /** 31 | * Additional payload to include in the access token response. 32 | * 33 | * Will be applied after default payload to allow overriding over values, but be careful. 34 | */ 35 | additionalPayload?: Record 36 | } 37 | 38 | export async function createAccessTokenResponse(options: CreateAccessTokenResponseOptions) { 39 | const accessTokenResponse = parseWithErrorHandling(zAccessTokenResponse, { 40 | access_token: options.accessToken, 41 | token_type: options.tokenType, 42 | expires_in: options.expiresInSeconds, 43 | c_nonce: options.cNonce, 44 | c_nonce_expires_in: options.cNonceExpiresIn, 45 | ...options.additionalPayload, 46 | } satisfies AccessTokenResponse) 47 | 48 | return accessTokenResponse 49 | } 50 | -------------------------------------------------------------------------------- /packages/oauth2/src/access-token/introspect-token.ts: -------------------------------------------------------------------------------- 1 | import { ContentType, createZodFetcher, objectToQueryParams, parseWithErrorHandling } from '@openid4vc/utils' 2 | import { InvalidFetchResponseError } from '@openid4vc/utils' 3 | import { Oauth2Error } from '../error/Oauth2Error' 4 | import type { AuthorizationServerMetadata } from '../metadata/authorization-server/z-authorization-server-metadata' 5 | 6 | import { Headers } from '@openid4vc/utils' 7 | import type { CallbackContext } from '../callbacks' 8 | import { 9 | type TokenIntrospectionRequest, 10 | zTokenIntrospectionRequest, 11 | zTokenIntrospectionResponse, 12 | } from './z-token-introspection' 13 | 14 | export interface IntrospectTokenOptions { 15 | /** 16 | * Metadata of the authorization server. Must contain an `introspection_endpoint` 17 | */ 18 | authorizationServerMetadata: AuthorizationServerMetadata 19 | 20 | /** 21 | * The provided acccess token 22 | */ 23 | token: string 24 | 25 | /** 26 | * The scheme of the access token, will be sent along with the token 27 | * as a hint. 28 | */ 29 | tokenTypeHint?: string 30 | 31 | /** 32 | * Additional payload to include in the introspection equest. Items will be encoded and sent 33 | * using x-www-form-urlencoded format. Nested items (JSON) will be stringified and url encoded. 34 | */ 35 | additionalPayload?: Record 36 | 37 | callbacks: Pick 38 | } 39 | 40 | export async function introspectToken(options: IntrospectTokenOptions) { 41 | const fetchWithZod = createZodFetcher(options.callbacks.fetch) 42 | 43 | const introspectionRequest = parseWithErrorHandling(zTokenIntrospectionRequest, { 44 | token: options.token, 45 | token_type_hint: options.tokenTypeHint, 46 | ...options.additionalPayload, 47 | } satisfies TokenIntrospectionRequest) 48 | 49 | const introspectionEndpoint = options.authorizationServerMetadata.introspection_endpoint 50 | if (!introspectionEndpoint) { 51 | throw new Oauth2Error(`Missing required 'introspection_endpoint' parameter in authorization server metadata`) 52 | } 53 | 54 | const headers = new Headers({ 55 | 'Content-Type': ContentType.XWwwFormUrlencoded, 56 | }) 57 | 58 | // Apply client authentication 59 | await options.callbacks.clientAuthentication({ 60 | url: introspectionEndpoint, 61 | method: 'POST', 62 | authorizationServerMetadata: options.authorizationServerMetadata, 63 | body: introspectionRequest, 64 | contentType: ContentType.XWwwFormUrlencoded, 65 | headers, 66 | }) 67 | 68 | const { result, response } = await fetchWithZod( 69 | zTokenIntrospectionResponse, 70 | ContentType.Json, 71 | introspectionEndpoint, 72 | { 73 | body: objectToQueryParams(introspectionRequest).toString(), 74 | method: 'POST', 75 | headers, 76 | } 77 | ) 78 | 79 | // TODO: better error handling (error response?) 80 | if (!response.ok || !result?.success) { 81 | throw new InvalidFetchResponseError( 82 | `Unable to introspect token from '${introspectionEndpoint}'. Received response with status ${response.status}`, 83 | await response.clone().text(), 84 | response 85 | ) 86 | } 87 | 88 | return result.data 89 | } 90 | -------------------------------------------------------------------------------- /packages/oauth2/src/access-token/verify-access-token.ts: -------------------------------------------------------------------------------- 1 | import type { CallbackContext } from '../callbacks' 2 | import { extractJwkFromJwksForJwt } from '../common/jwk/jwks' 3 | import { decodeJwt } from '../common/jwt/decode-jwt' 4 | import { verifyJwt } from '../common/jwt/verify-jwt' 5 | import { Oauth2Error } from '../error/Oauth2Error' 6 | import type { AuthorizationServerMetadata } from '../metadata/authorization-server/z-authorization-server-metadata' 7 | import { fetchJwks } from '../metadata/fetch-jwks-uri' 8 | import { zAccessTokenProfileJwtHeader, zAccessTokenProfileJwtPayload } from './z-access-token-jwt' 9 | 10 | export enum SupportedAuthenticationScheme { 11 | Bearer = 'Bearer', 12 | DPoP = 'DPoP', 13 | } 14 | 15 | export interface VerifyJwtProfileAccessTokenOptions { 16 | /** 17 | * The access token 18 | */ 19 | accessToken: string 20 | 21 | /** 22 | * Callbacks used for verifying the access token 23 | */ 24 | callbacks: Pick 25 | 26 | /** 27 | * If not provided current time will be used 28 | */ 29 | now?: Date 30 | 31 | /** 32 | * Identifier of the resource server 33 | */ 34 | resourceServer: string 35 | 36 | /** 37 | * List of authorization servers that this resource endpoint supports 38 | */ 39 | authorizationServers: AuthorizationServerMetadata[] 40 | } 41 | 42 | /** 43 | * Verify an access token as a JWT Profile access token. 44 | * 45 | * @throws {@link ValidationError} if the JWT header or payload does not align with JWT Profile rules 46 | * @throws {@link Oauth2JwtParseError} if the jwt is not a valid jwt format, or the jwt header/payload cannot be parsed as JSON 47 | * @throws {@link Oauth2JwtVerificationError} if the JWT verification fails (signature or nbf/exp) 48 | * @throws {@link Oauth2JwtVerificationError} if the JWT verification fails (signature or nbf/exp) 49 | */ 50 | export async function verifyJwtProfileAccessToken(options: VerifyJwtProfileAccessTokenOptions) { 51 | const decodedJwt = decodeJwt({ 52 | jwt: options.accessToken, 53 | headerSchema: zAccessTokenProfileJwtHeader, 54 | payloadSchema: zAccessTokenProfileJwtPayload, 55 | }) 56 | 57 | const authorizationServer = options.authorizationServers.find(({ issuer }) => decodedJwt.payload.iss === issuer) 58 | if (!authorizationServer) { 59 | // Authorization server not found 60 | throw new Oauth2Error( 61 | `Access token jwt contains unrecognized authorization server 'iss' value of '${decodedJwt.payload.iss}'` 62 | ) 63 | } 64 | 65 | const jwksUrl = authorizationServer.jwks_uri 66 | if (!jwksUrl) { 67 | throw new Oauth2Error( 68 | `Authorization server '${authorizationServer.issuer}' does not have a 'jwks_uri' parameter to fetch JWKs.` 69 | ) 70 | } 71 | 72 | const jwks = await fetchJwks(jwksUrl, options.callbacks.fetch) 73 | const publicJwk = extractJwkFromJwksForJwt({ 74 | kid: decodedJwt.header.kid, 75 | jwks, 76 | use: 'sig', 77 | }) 78 | 79 | await verifyJwt({ 80 | compact: options.accessToken, 81 | header: decodedJwt.header, 82 | payload: decodedJwt.payload, 83 | signer: { method: 'jwk', publicJwk, alg: decodedJwt.header.alg }, 84 | verifyJwtCallback: options.callbacks.verifyJwt, 85 | errorMessage: 'Error during verification of access token jwt.', 86 | now: options.now, 87 | expectedAudience: options.resourceServer, 88 | }) 89 | 90 | return { 91 | header: decodedJwt.header, 92 | payload: decodedJwt.payload, 93 | authorizationServer, 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /packages/oauth2/src/access-token/z-access-token-jwt.ts: -------------------------------------------------------------------------------- 1 | import { zInteger } from '@openid4vc/utils' 2 | import z from 'zod' 3 | import { zJwtHeader, zJwtPayload } from '../common/jwt/z-jwt' 4 | 5 | export const zAccessTokenProfileJwtHeader = z 6 | .object({ 7 | ...zJwtHeader.shape, 8 | typ: z.enum(['application/at+jwt', 'at+jwt']), 9 | }) 10 | .passthrough() 11 | export type AccessTokenProfileJwtHeader = z.infer 12 | 13 | export const zAccessTokenProfileJwtPayload = z 14 | .object({ 15 | ...zJwtPayload.shape, 16 | iss: z.string(), 17 | exp: zInteger, 18 | iat: zInteger, 19 | aud: z.string(), 20 | sub: z.string(), 21 | 22 | // REQUIRED according to RFC 9068, but OpenID4VCI allows anonymous access 23 | client_id: z.optional(z.string()), 24 | jti: z.string(), 25 | 26 | // SHOULD be included in the authorization request contained it 27 | scope: z.optional(z.string()), 28 | }) 29 | .passthrough() 30 | 31 | export type AccessTokenProfileJwtPayload = z.infer 32 | -------------------------------------------------------------------------------- /packages/oauth2/src/access-token/z-access-token.ts: -------------------------------------------------------------------------------- 1 | import z from 'zod' 2 | 3 | import { zHttpsUrl } from '@openid4vc/utils' 4 | import { zOauth2ErrorResponse } from '../common/z-oauth2-error' 5 | import { 6 | zAuthorizationCodeGrantIdentifier, 7 | zPreAuthorizedCodeGrantIdentifier, 8 | zRefreshTokenGrantIdentifier, 9 | } from '../z-grant-type' 10 | 11 | export const zAccessTokenRequest = z.intersection( 12 | z 13 | .object({ 14 | // Pre authorized code flow 15 | 'pre-authorized_code': z.optional(z.string()), 16 | 17 | // Authorization code flow 18 | code: z.optional(z.string()), 19 | redirect_uri: z.string().url().optional(), 20 | 21 | // Refresh token grant 22 | refresh_token: z.optional(z.string()), 23 | 24 | resource: z.optional(zHttpsUrl), 25 | code_verifier: z.optional(z.string()), 26 | 27 | grant_type: z.union([ 28 | zPreAuthorizedCodeGrantIdentifier, 29 | zAuthorizationCodeGrantIdentifier, 30 | zRefreshTokenGrantIdentifier, 31 | // string makes the previous ones unessary, but it does help with error messages 32 | z.string(), 33 | ]), 34 | }) 35 | .passthrough(), 36 | z 37 | .object({ 38 | tx_code: z.optional(z.string()), 39 | // user_pin is from OpenID4VCI draft 11 40 | user_pin: z.optional(z.string()), 41 | }) 42 | .passthrough() 43 | .refine(({ tx_code, user_pin }) => !tx_code || !user_pin || user_pin === tx_code, { 44 | message: `If both 'tx_code' and 'user_pin' are present they must match`, 45 | }) 46 | .transform(({ tx_code, user_pin, ...rest }) => { 47 | return { 48 | ...rest, 49 | ...((tx_code ?? user_pin) ? { tx_code: tx_code ?? user_pin } : {}), 50 | } 51 | }) 52 | ) 53 | export type AccessTokenRequest = z.infer 54 | 55 | export const zAccessTokenResponse = z 56 | .object({ 57 | access_token: z.string(), 58 | token_type: z.string(), 59 | 60 | expires_in: z.optional(z.number().int()), 61 | scope: z.optional(z.string()), 62 | state: z.optional(z.string()), 63 | 64 | refresh_token: z.optional(z.string()), 65 | 66 | // OpenID4VCI specific parameters 67 | c_nonce: z.optional(z.string()), 68 | c_nonce_expires_in: z.optional(z.number().int()), 69 | 70 | // TODO: add additional params 71 | authorization_details: z 72 | .array( 73 | z 74 | .object({ 75 | // required when type is openid_credential (so we probably need a discriminator) 76 | // credential_identifiers: z.array(z.string()), 77 | }) 78 | .passthrough() 79 | ) 80 | .optional(), 81 | }) 82 | .passthrough() 83 | 84 | export type AccessTokenResponse = z.infer 85 | 86 | export const zAccessTokenErrorResponse = zOauth2ErrorResponse 87 | export type AccessTokenErrorResponse = z.infer 88 | -------------------------------------------------------------------------------- /packages/oauth2/src/access-token/z-token-introspection.ts: -------------------------------------------------------------------------------- 1 | import { zInteger } from '@openid4vc/utils' 2 | import z from 'zod' 3 | import { zJwtConfirmationPayload } from '../common/jwt/z-jwt' 4 | 5 | export const zTokenIntrospectionRequest = z 6 | .object({ 7 | token: z.string(), 8 | token_type_hint: z.optional(z.string()), 9 | }) 10 | .passthrough() 11 | 12 | export type TokenIntrospectionRequest = z.infer 13 | 14 | export const zTokenIntrospectionResponse = z 15 | .object({ 16 | active: z.boolean(), 17 | scope: z.optional(z.string()), 18 | client_id: z.optional(z.string()), 19 | username: z.optional(z.string()), 20 | token_type: z.optional(z.string()), 21 | 22 | exp: z.optional(zInteger), 23 | iat: z.optional(zInteger), 24 | nbf: z.optional(zInteger), 25 | 26 | sub: z.optional(z.string()), 27 | aud: z.optional(z.string()), 28 | 29 | iss: z.optional(z.string()), 30 | jti: z.optional(z.string()), 31 | 32 | cnf: z.optional(zJwtConfirmationPayload), 33 | }) 34 | .passthrough() 35 | 36 | export type TokenIntrospectionResponse = z.infer 37 | -------------------------------------------------------------------------------- /packages/oauth2/src/authorization-challenge/parse-authorization-challenge-request.ts: -------------------------------------------------------------------------------- 1 | import { formatZodError } from '@openid4vc/utils' 2 | import { 3 | type ParseAuthorizationRequestResult, 4 | parseAuthorizationRequest, 5 | } from '../authorization-request/parse-authorization-request' 6 | import type { RequestLike } from '../common/z-common' 7 | import { Oauth2ErrorCodes } from '../common/z-oauth2-error' 8 | import { Oauth2ServerErrorResponseError } from '../error/Oauth2ServerErrorResponseError' 9 | import { type AuthorizationChallengeRequest, zAuthorizationChallengeRequest } from './z-authorization-challenge' 10 | 11 | export interface ParseAuthorizationChallengeRequestOptions { 12 | request: RequestLike 13 | 14 | authorizationChallengeRequest: unknown 15 | } 16 | 17 | export interface ParseAuthorizationChallengeRequestResult extends ParseAuthorizationRequestResult { 18 | authorizationChallengeRequest: AuthorizationChallengeRequest 19 | } 20 | 21 | /** 22 | * Parse an authorization challenge request. 23 | * 24 | * @throws {Oauth2ServerErrorResponseError} 25 | */ 26 | export function parseAuthorizationChallengeRequest( 27 | options: ParseAuthorizationChallengeRequestOptions 28 | ): ParseAuthorizationChallengeRequestResult { 29 | const parsedAuthorizationChallengeRequest = zAuthorizationChallengeRequest.safeParse( 30 | options.authorizationChallengeRequest 31 | ) 32 | if (!parsedAuthorizationChallengeRequest.success) { 33 | throw new Oauth2ServerErrorResponseError({ 34 | error: Oauth2ErrorCodes.InvalidRequest, 35 | error_description: `Error occured during validation of authorization challenge request.\n${formatZodError(parsedAuthorizationChallengeRequest.error)}`, 36 | }) 37 | } 38 | 39 | const authorizationChallengeRequest = parsedAuthorizationChallengeRequest.data 40 | const { clientAttestation, dpop } = parseAuthorizationRequest({ 41 | authorizationRequest: authorizationChallengeRequest, 42 | request: options.request, 43 | }) 44 | 45 | return { 46 | authorizationChallengeRequest: parsedAuthorizationChallengeRequest.data, 47 | 48 | dpop, 49 | clientAttestation, 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /packages/oauth2/src/authorization-challenge/verify-authorization-challenge-request.ts: -------------------------------------------------------------------------------- 1 | import { 2 | type VerifyAuthorizationRequestOptions, 3 | type VerifyAuthorizationRequestReturn, 4 | verifyAuthorizationRequest, 5 | } from '../authorization-request/verify-authorization-request' 6 | import type { AuthorizationChallengeRequest } from './z-authorization-challenge' 7 | 8 | export type VerifyAuthorizationChallengeRequestReturn = VerifyAuthorizationRequestReturn 9 | export interface VerifyAuthorizationChallengeRequestOptions 10 | extends Omit { 11 | authorizationChallengeRequest: AuthorizationChallengeRequest 12 | } 13 | 14 | export async function verifyAuthorizationChallengeRequest( 15 | options: VerifyAuthorizationChallengeRequestOptions 16 | ): Promise { 17 | const { clientAttestation, dpop } = await verifyAuthorizationRequest({ 18 | ...options, 19 | authorizationRequest: options.authorizationChallengeRequest, 20 | }) 21 | 22 | return { 23 | dpop, 24 | clientAttestation, 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /packages/oauth2/src/authorization-challenge/z-authorization-challenge.ts: -------------------------------------------------------------------------------- 1 | import { zInteger } from '@openid4vc/utils' 2 | 3 | import z from 'zod' 4 | import { zAuthorizationRequest } from '../authorization-request/z-authorization-request' 5 | import { zOauth2ErrorResponse } from '../common/z-oauth2-error' 6 | 7 | export const zAuthorizationChallengeRequest = z 8 | .object({ 9 | // authorization challenge request can include same parameters as an authorization request 10 | // except for response_type (always `code`), and `client_id` is optional (becase 11 | // it's possible to do client authentication using different methods) 12 | ...zAuthorizationRequest.omit({ response_type: true, client_id: true }).shape, 13 | client_id: z.optional(zAuthorizationRequest.shape.client_id), 14 | 15 | auth_session: z.optional(z.string()), 16 | 17 | // DRAFT presentation during issuance 18 | presentation_during_issuance_session: z.optional(z.string()), 19 | }) 20 | .passthrough() 21 | export type AuthorizationChallengeRequest = z.infer 22 | 23 | export const zAuthorizationChallengeResponse = z 24 | .object({ 25 | authorization_code: z.string(), 26 | }) 27 | .passthrough() 28 | export type AuthorizationChallengeResponse = z.infer 29 | 30 | export const zAuthorizationChallengeErrorResponse = z 31 | .object({ 32 | ...zOauth2ErrorResponse.shape, 33 | auth_session: z.optional(z.string()), 34 | request_uri: z.optional(z.string()), 35 | expires_in: z.optional(zInteger), 36 | 37 | // DRAFT: presentation during issuance 38 | presentation: z.optional(z.string()), 39 | }) 40 | .passthrough() 41 | export type AuthorizationChallengeErrorResponse = z.infer 42 | -------------------------------------------------------------------------------- /packages/oauth2/src/authorization-request/create-pushed-authorization-response.ts: -------------------------------------------------------------------------------- 1 | import { type StringWithAutoCompletion, parseWithErrorHandling } from '@openid4vc/utils' 2 | import { zAccessTokenErrorResponse } from '../access-token/z-access-token' 3 | import type { Oauth2ErrorCodes } from '../common/z-oauth2-error' 4 | import { 5 | type PushedAuthorizationErrorResponse, 6 | type PushedAuthorizationResponse, 7 | zPushedAuthorizationResponse, 8 | } from './z-authorization-request' 9 | 10 | export interface CreatePushedAuthorizationResponseOptions { 11 | /** 12 | * The request uri where the client should redirect to 13 | */ 14 | requestUri: string 15 | 16 | /** 17 | * Number of seconds after which the `requestUri` will expire. 18 | */ 19 | expiresInSeconds: number 20 | 21 | /** 22 | * Additional payload to include in the pushed authorization response. 23 | */ 24 | additionalPayload?: Record 25 | } 26 | 27 | /** 28 | * Create an pushed authorization response 29 | * 30 | * @throws {ValidationError} if an error occured during verification of the {@link PushedAuthorizationResponse} 31 | */ 32 | export function createPushedAuthorizationResponse(options: CreatePushedAuthorizationResponseOptions) { 33 | const pushedAuthorizationResponse = parseWithErrorHandling(zPushedAuthorizationResponse, { 34 | ...options.additionalPayload, 35 | expires_in: options.expiresInSeconds, 36 | request_uri: options.requestUri, 37 | } satisfies PushedAuthorizationResponse) 38 | 39 | return { pushedAuthorizationResponse } 40 | } 41 | 42 | export interface CreatePushedAuthorizationErrorResponseOptions { 43 | /** 44 | * The pushed authorization error 45 | */ 46 | error: StringWithAutoCompletion 47 | 48 | /** 49 | * Optional error description 50 | */ 51 | errorDescription?: string 52 | 53 | /** 54 | * Additional payload to include in the pushed authorization error response. 55 | */ 56 | additionalPayload?: Record 57 | } 58 | 59 | /** 60 | * Create a pushed authorization error response 61 | * 62 | * @throws {ValidationError} if an error occured during validation of the {@link PushedAuthorizationErrorResponse} 63 | */ 64 | export function createPushedAuthorizationErrorResponse(options: CreatePushedAuthorizationErrorResponseOptions) { 65 | const pushedAuthorizationErrorResponse = parseWithErrorHandling(zAccessTokenErrorResponse, { 66 | ...options.additionalPayload, 67 | error: options.error, 68 | error_description: options.errorDescription, 69 | } satisfies PushedAuthorizationErrorResponse) 70 | 71 | return pushedAuthorizationErrorResponse 72 | } 73 | -------------------------------------------------------------------------------- /packages/oauth2/src/authorization-request/parse-pushed-authorization-request.ts: -------------------------------------------------------------------------------- 1 | import { formatZodError } from '@openid4vc/utils' 2 | import type { RequestLike } from '../common/z-common' 3 | import { Oauth2ErrorCodes } from '../common/z-oauth2-error' 4 | import { Oauth2ServerErrorResponseError } from '../error/Oauth2ServerErrorResponseError' 5 | import { type ParseAuthorizationRequestResult, parseAuthorizationRequest } from './parse-authorization-request' 6 | import { type AuthorizationRequest, zAuthorizationRequest } from './z-authorization-request' 7 | 8 | export interface ParsePushedAuthorizationRequestOptions { 9 | request: RequestLike 10 | authorizationRequest: unknown 11 | } 12 | export interface ParsePushedAuthorizationRequestResult extends ParseAuthorizationRequestResult { 13 | authorizationRequest: AuthorizationRequest 14 | } 15 | 16 | /** 17 | * Parse an pushed authorization request. 18 | * 19 | * @throws {Oauth2ServerErrorResponseError} 20 | */ 21 | export function parsePushedAuthorizationRequest( 22 | options: ParsePushedAuthorizationRequestOptions 23 | ): ParsePushedAuthorizationRequestResult { 24 | const parsedAuthorizationRequest = zAuthorizationRequest.safeParse(options.authorizationRequest) 25 | if (!parsedAuthorizationRequest.success) { 26 | throw new Oauth2ServerErrorResponseError({ 27 | error: Oauth2ErrorCodes.InvalidRequest, 28 | error_description: `Error occured during validation of pushed authorization request.\n${formatZodError(parsedAuthorizationRequest.error)}`, 29 | }) 30 | } 31 | 32 | const authorizationRequest = parsedAuthorizationRequest.data 33 | const { clientAttestation, dpop } = parseAuthorizationRequest({ 34 | authorizationRequest, 35 | request: options.request, 36 | }) 37 | 38 | return { 39 | authorizationRequest, 40 | 41 | dpop, 42 | clientAttestation, 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /packages/oauth2/src/authorization-request/verify-pushed-authorization-request.ts: -------------------------------------------------------------------------------- 1 | import { 2 | type VerifyAuthorizationRequestOptions, 3 | type VerifyAuthorizationRequestReturn, 4 | verifyAuthorizationRequest, 5 | } from './verify-authorization-request' 6 | import type { AuthorizationRequest } from './z-authorization-request' 7 | 8 | export type VerifyPushedAuthorizationRequestReturn = VerifyAuthorizationRequestReturn 9 | export interface VerifyPushedAuthorizationRequestOptions extends VerifyAuthorizationRequestOptions { 10 | authorizationRequest: AuthorizationRequest 11 | } 12 | 13 | export async function verifyPushedAuthorizationRequest( 14 | options: VerifyPushedAuthorizationRequestOptions 15 | ): Promise { 16 | const { clientAttestation, dpop } = await verifyAuthorizationRequest(options) 17 | 18 | return { 19 | dpop, 20 | clientAttestation, 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /packages/oauth2/src/authorization-request/z-authorization-request.ts: -------------------------------------------------------------------------------- 1 | import { zHttpsUrl } from '@openid4vc/utils' 2 | import z from 'zod' 3 | import { zOauth2ErrorResponse } from '../common/z-oauth2-error' 4 | 5 | // TODO: should create different request validations for different 6 | // response types. Currently we basically only support `code` 7 | export const zAuthorizationRequest = z 8 | .object({ 9 | response_type: z.string(), 10 | client_id: z.string(), 11 | 12 | issuer_state: z.optional(z.string()), 13 | redirect_uri: z.string().url().optional(), 14 | resource: z.optional(zHttpsUrl), 15 | scope: z.optional(z.string()), 16 | 17 | // DPoP jwk thumbprint 18 | dpop_jkt: z.optional(z.string().base64url()), 19 | 20 | code_challenge: z.optional(z.string()), 21 | code_challenge_method: z.optional(z.string()), 22 | }) 23 | .passthrough() 24 | export type AuthorizationRequest = z.infer 25 | 26 | export const zPushedAuthorizationRequest = z 27 | .object({ 28 | request_uri: z.string(), 29 | client_id: z.string(), 30 | }) 31 | .passthrough() 32 | export type PushedAuthorizationRequest = z.infer 33 | 34 | export const zPushedAuthorizationResponse = z 35 | .object({ 36 | request_uri: z.string(), 37 | expires_in: z.number().int(), 38 | }) 39 | .passthrough() 40 | export type PushedAuthorizationResponse = z.infer 41 | 42 | export const zPushedAuthorizationErrorResponse = zOauth2ErrorResponse 43 | export type PushedAuthorizationErrorResponse = z.infer 44 | -------------------------------------------------------------------------------- /packages/oauth2/src/client-attestation/z-client-attestation.ts: -------------------------------------------------------------------------------- 1 | import { zJwtHeader, zJwtPayload } from '../common/jwt/z-jwt' 2 | 3 | import { zHttpsUrl, zInteger } from '@openid4vc/utils' 4 | import z from 'zod' 5 | import { zJwk } from '../common/jwk/z-jwk' 6 | 7 | export const zOauthClientAttestationHeader = z.literal('OAuth-Client-Attestation') 8 | export const oauthClientAttestationHeader = zOauthClientAttestationHeader.value 9 | 10 | export const zClientAttestationJwtPayload = z 11 | .object({ 12 | ...zJwtPayload.shape, 13 | iss: z.string(), 14 | sub: z.string(), 15 | exp: zInteger, 16 | cnf: z 17 | .object({ 18 | jwk: zJwk, 19 | }) 20 | .passthrough(), 21 | 22 | // OID4VCI Wallet Attestation Extensions 23 | wallet_name: z.string().optional(), 24 | wallet_link: z.string().url().optional(), 25 | }) 26 | .passthrough() 27 | export type ClientAttestationJwtPayload = z.infer 28 | 29 | export const zClientAttestationJwtHeader = z 30 | .object({ 31 | ...zJwtHeader.shape, 32 | typ: z.literal('oauth-client-attestation+jwt'), 33 | }) 34 | .passthrough() 35 | 36 | export type ClientAttestationJwtHeader = z.infer 37 | 38 | export const zOauthClientAttestationPopHeader = z.literal('OAuth-Client-Attestation-PoP') 39 | export const oauthClientAttestationPopHeader = zOauthClientAttestationPopHeader.value 40 | 41 | export const zClientAttestationPopJwtPayload = z 42 | .object({ 43 | ...zJwtPayload.shape, 44 | iss: z.string(), 45 | exp: zInteger, 46 | aud: zHttpsUrl, 47 | 48 | jti: z.string(), 49 | nonce: z.optional(z.string()), 50 | }) 51 | .passthrough() 52 | export type ClientAttestationPopJwtPayload = z.infer 53 | 54 | export const zClientAttestationPopJwtHeader = z 55 | .object({ 56 | ...zJwtHeader.shape, 57 | typ: z.literal('oauth-client-attestation-pop+jwt'), 58 | }) 59 | .passthrough() 60 | export type ClientAttestationPopJwtHeader = z.infer 61 | -------------------------------------------------------------------------------- /packages/oauth2/src/common/jwk/jwk-thumbprint.ts: -------------------------------------------------------------------------------- 1 | import type { HashAlgorithm, HashCallback } from '../../callbacks' 2 | 3 | import { decodeUtf8String, encodeToBase64Url, parseWithErrorHandling } from '@openid4vc/utils' 4 | import z from 'zod' 5 | import type { Jwk } from './z-jwk' 6 | 7 | export const zJwkThumbprintComponents = z 8 | .discriminatedUnion('kty', [ 9 | z.object({ 10 | kty: z.literal('EC'), 11 | crv: z.string(), 12 | x: z.string(), 13 | y: z.string(), 14 | }), 15 | z.object({ 16 | kty: z.literal('OKP'), 17 | crv: z.string(), 18 | x: z.string(), 19 | }), 20 | z.object({ 21 | kty: z.literal('RSA'), 22 | e: z.string(), 23 | n: z.string(), 24 | }), 25 | z.object({ 26 | kty: z.literal('oct'), 27 | k: z.string(), 28 | }), 29 | ]) 30 | .transform((data) => { 31 | if (data.kty === 'EC') { 32 | return { crv: data.crv, kty: data.kty, x: data.x, y: data.y } 33 | } 34 | 35 | if (data.kty === 'OKP') { 36 | return { crv: data.crv, kty: data.kty, x: data.x } 37 | } 38 | 39 | if (data.kty === 'RSA') { 40 | return { e: data.e, kty: data.kty, n: data.n } 41 | } 42 | 43 | if (data.kty === 'oct') { 44 | return { k: data.k, kty: data.kty } 45 | } 46 | 47 | throw new Error('Unsupported kty') 48 | }) 49 | 50 | export interface CalculateJwkThumbprintOptions { 51 | /** 52 | * The jwk to calcualte the thumbprint for. 53 | */ 54 | jwk: Jwk 55 | 56 | /** 57 | * The hashing algorithm to use for calculating the thumbprint 58 | */ 59 | hashAlgorithm: HashAlgorithm 60 | 61 | /** 62 | * The hash callback to calculate the digest 63 | */ 64 | hashCallback: HashCallback 65 | } 66 | 67 | export async function calculateJwkThumbprint(options: CalculateJwkThumbprintOptions): Promise { 68 | const jwkThumbprintComponents = parseWithErrorHandling( 69 | zJwkThumbprintComponents, 70 | options.jwk, 71 | `Provided jwk does not match a supported jwk structure. Either the 'kty' is not supported, or required values are missing.` 72 | ) 73 | 74 | const thumbprint = encodeToBase64Url( 75 | await options.hashCallback(decodeUtf8String(JSON.stringify(jwkThumbprintComponents)), options.hashAlgorithm) 76 | ) 77 | return thumbprint 78 | } 79 | -------------------------------------------------------------------------------- /packages/oauth2/src/common/jwk/jwks.ts: -------------------------------------------------------------------------------- 1 | import { type CallbackContext, HashAlgorithm } from '../../callbacks' 2 | import { Oauth2Error } from '../../error/Oauth2Error' 3 | import { calculateJwkThumbprint } from './jwk-thumbprint' 4 | import type { Jwk, JwkSet } from './z-jwk' 5 | 6 | interface ExtractJwkFromJwksForJwtOptions { 7 | kid?: string 8 | use: 'enc' | 'sig' 9 | 10 | /** 11 | * The JWKs 12 | */ 13 | jwks: JwkSet 14 | } 15 | 16 | /** 17 | * 18 | * @param header 19 | * @param jwks 20 | */ 21 | export function extractJwkFromJwksForJwt(options: ExtractJwkFromJwksForJwtOptions) { 22 | const jwksForUse = options.jwks.keys.filter(({ use }) => !use || use === options.use) 23 | const jwkForKid = options.kid ? jwksForUse.find(({ kid }) => kid === options.kid) : undefined 24 | 25 | if (jwkForKid) { 26 | return jwkForKid 27 | } 28 | 29 | if (jwksForUse.length === 1) { 30 | return jwksForUse[0] 31 | } 32 | 33 | throw new Oauth2Error( 34 | `Unable to extract jwk from jwks for use '${options.use}'${options.kid ? `with kid '${options.kid}'.` : '. No kid provided and more than jwk.'}` 35 | ) 36 | } 37 | 38 | export async function isJwkInSet({ 39 | jwk, 40 | jwks, 41 | callbacks, 42 | }: { 43 | jwk: Jwk 44 | jwks: Jwk[] 45 | callbacks: Pick 46 | }) { 47 | const jwkThumbprint = await calculateJwkThumbprint({ 48 | hashAlgorithm: HashAlgorithm.Sha256, 49 | hashCallback: callbacks.hash, 50 | jwk, 51 | }) 52 | 53 | for (const jwkFromSet of jwks) { 54 | const jwkFromSetThumbprint = await calculateJwkThumbprint({ 55 | hashAlgorithm: HashAlgorithm.Sha256, 56 | hashCallback: callbacks.hash, 57 | jwk: jwkFromSet, 58 | }) 59 | 60 | if (jwkFromSetThumbprint === jwkThumbprint) return true 61 | } 62 | 63 | return false 64 | } 65 | -------------------------------------------------------------------------------- /packages/oauth2/src/common/jwk/z-jwk.ts: -------------------------------------------------------------------------------- 1 | import z from 'zod' 2 | 3 | export const zJwk = z 4 | .object({ 5 | kty: z.string(), 6 | crv: z.optional(z.string()), 7 | x: z.optional(z.string()), 8 | y: z.optional(z.string()), 9 | e: z.optional(z.string()), 10 | n: z.optional(z.string()), 11 | alg: z.optional(z.string()), 12 | d: z.optional(z.string()), 13 | dp: z.optional(z.string()), 14 | dq: z.optional(z.string()), 15 | ext: z.optional(z.boolean()), 16 | k: z.optional(z.string()), 17 | key_ops: z.optional(z.array(z.string())), 18 | kid: z.optional(z.string()), 19 | oth: z.optional( 20 | z.array( 21 | z 22 | .object({ 23 | d: z.optional(z.string()), 24 | r: z.optional(z.string()), 25 | t: z.optional(z.string()), 26 | }) 27 | .passthrough() 28 | ) 29 | ), 30 | p: z.optional(z.string()), 31 | q: z.optional(z.string()), 32 | qi: z.optional(z.string()), 33 | use: z.optional(z.string()), 34 | x5c: z.optional(z.array(z.string())), 35 | x5t: z.optional(z.string()), 36 | 'x5t#S256': z.optional(z.string()), 37 | x5u: z.optional(z.string()), 38 | }) 39 | .passthrough() 40 | 41 | export type Jwk = z.infer 42 | 43 | export const zJwkSet = z.object({ keys: z.array(zJwk) }).passthrough() 44 | 45 | export type JwkSet = z.infer 46 | -------------------------------------------------------------------------------- /packages/oauth2/src/common/jwt/decode-jwt-header.ts: -------------------------------------------------------------------------------- 1 | import { 2 | type BaseSchema, 3 | decodeBase64, 4 | encodeToUtf8String, 5 | parseWithErrorHandling, 6 | stringToJsonWithErrorHandling, 7 | } from '@openid4vc/utils' 8 | import { Oauth2JwtParseError } from '../../error/Oauth2JwtParseError' 9 | import type { InferSchemaOrDefaultOutput } from './decode-jwt' 10 | import { zJwtHeader } from './z-jwt' 11 | 12 | export interface DecodeJwtHeaderOptions { 13 | /** 14 | * The comapct encoded jwt 15 | */ 16 | jwt: string 17 | 18 | /** 19 | * Schema to use for validating the header. If not provided the 20 | * default `vJwtHeader` schema will be used 21 | */ 22 | headerSchema?: HeaderSchema 23 | } 24 | 25 | export type DecodeJwtHeaderResult = { 26 | header: InferSchemaOrDefaultOutput 27 | } 28 | 29 | export function decodeJwtHeader( 30 | options: DecodeJwtHeaderOptions 31 | ): DecodeJwtHeaderResult { 32 | const jwtParts = options.jwt.split('.') 33 | if (jwtParts.length <= 2) { 34 | throw new Oauth2JwtParseError('Jwt is not a valid jwt, unable to decode') 35 | } 36 | 37 | let headerJson: Record 38 | try { 39 | headerJson = stringToJsonWithErrorHandling( 40 | encodeToUtf8String(decodeBase64(jwtParts[0])), 41 | 'Unable to parse jwt header to JSON' 42 | ) 43 | } catch (error) { 44 | throw new Oauth2JwtParseError(`Error parsing JWT. ${error instanceof Error ? error.message : ''}`) 45 | } 46 | 47 | const header = parseWithErrorHandling(options.headerSchema ?? zJwtHeader, headerJson) as InferSchemaOrDefaultOutput< 48 | HeaderSchema, 49 | typeof zJwtHeader 50 | > 51 | 52 | return { 53 | header, 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /packages/oauth2/src/common/jwt/z-jwe.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod' 2 | 3 | export const zCompactJwe = z 4 | .string() 5 | .regex(/^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]*\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/, { 6 | message: 'Not a valid compact jwe', 7 | }) 8 | -------------------------------------------------------------------------------- /packages/oauth2/src/common/z-common.ts: -------------------------------------------------------------------------------- 1 | import type { FetchHeaders, HttpMethod } from '@openid4vc/utils' 2 | import z from 'zod' 3 | 4 | export const zAlgValueNotNone = z.string().refine((alg) => alg !== 'none', { message: `alg value may not be 'none'` }) 5 | 6 | export interface RequestLike { 7 | headers: FetchHeaders 8 | method: HttpMethod 9 | url: string 10 | } 11 | -------------------------------------------------------------------------------- /packages/oauth2/src/common/z-oauth2-error.ts: -------------------------------------------------------------------------------- 1 | import z from 'zod' 2 | 3 | export enum Oauth2ErrorCodes { 4 | ServerError = 'server_error', 5 | 6 | // Resource Indicators 7 | InvalidTarget = 'invalid_target', 8 | 9 | // Oauth2 10 | InvalidRequest = 'invalid_request', 11 | InvalidToken = 'invalid_token', 12 | InsufficientScope = 'insufficient_scope', 13 | InvalidGrant = 'invalid_grant', 14 | InvalidClient = 'invalid_client', 15 | UnauthorizedClient = 'unauthorized_client', 16 | UnsupportedGrantType = 'unsupported_grant_type', 17 | InvalidScope = 'invalid_scope', 18 | 19 | // DPoP 20 | InvalidDpopProof = 'invalid_dpop_proof', 21 | UseDpopNonce = 'use_dpop_nonce', 22 | 23 | // FiPA 24 | RedirectToWeb = 'redirect_to_web', 25 | InvalidSession = 'invalid_session', 26 | InsufficientAuthorization = 'insufficient_authorization', 27 | 28 | // OpenID4VCI 29 | InvalidCredentialRequest = 'invalid_credential_request', 30 | CredentialRequestDenied = 'credential_request_denied', 31 | UnsupportedCredentialType = 'unsupported_credential_type', 32 | UnsupportedCredentialFormat = 'unsupported_credential_format', 33 | InvalidProof = 'invalid_proof', 34 | InvalidNonce = 'invalid_nonce', 35 | InvalidEncryptionParameters = 'invalid_encryption_parameters', 36 | 37 | // Jar 38 | InvalidRequestUri = 'invalid_request_uri', 39 | InvalidRequestObject = 'invalid_request_object', 40 | RequestNotSupported = 'request_not_supported', 41 | RequestUriNotSupported = 'request_uri_not_supported', 42 | 43 | // OpenID4VP 44 | VpFormatsNotSupported = 'vp_formats_not_supported', 45 | AccessDenied = 'access_denied', 46 | InvalidPresentationDefinitionUri = 'invalid_presentation_definition_uri', 47 | InvalidPresentationDefinitionReference = 'invalid_presentation_definition_reference', 48 | InvalidRequestUriMethod = 'invalid_request_uri_method', 49 | InvalidTransactionData = 'invalid_transaction_data', 50 | WalletUnavailable = 'wallet_unavailable', 51 | } 52 | 53 | export const zOauth2ErrorResponse = z 54 | .object({ 55 | error: z.union([z.nativeEnum(Oauth2ErrorCodes), z.string()]), 56 | error_description: z.string().optional(), 57 | error_uri: z.string().optional(), 58 | }) 59 | .passthrough() 60 | 61 | export type Oauth2ErrorResponse = z.infer 62 | -------------------------------------------------------------------------------- /packages/oauth2/src/dpop/z-dpop.ts: -------------------------------------------------------------------------------- 1 | import { zJwtHeader, zJwtPayload } from '../common/jwt/z-jwt' 2 | 3 | import { zHttpMethod, zHttpsUrl, zInteger } from '@openid4vc/utils' 4 | import z from 'zod' 5 | import { zJwk } from '../common/jwk/z-jwk' 6 | 7 | export const zDpopJwtPayload = z 8 | .object({ 9 | ...zJwtPayload.shape, 10 | iat: zInteger, 11 | htu: zHttpsUrl, 12 | htm: zHttpMethod, 13 | jti: z.string(), 14 | 15 | // Only required when presenting in combination with access token 16 | ath: z.optional(z.string()), 17 | }) 18 | .passthrough() 19 | export type DpopJwtPayload = z.infer 20 | 21 | export const zDpopJwtHeader = z 22 | .object({ 23 | ...zJwtHeader.shape, 24 | typ: z.literal('dpop+jwt'), 25 | jwk: zJwk, 26 | }) 27 | .passthrough() 28 | export type DpopJwtHeader = z.infer 29 | -------------------------------------------------------------------------------- /packages/oauth2/src/error/Oauth2ClientAuthorizationChallengeError.ts: -------------------------------------------------------------------------------- 1 | import type { FetchResponse } from '@openid4vc/utils' 2 | import type { AuthorizationChallengeErrorResponse } from '../authorization-challenge/z-authorization-challenge' 3 | import { Oauth2ClientErrorResponseError } from './Oauth2ClientErrorResponseError' 4 | 5 | export class Oauth2ClientAuthorizationChallengeError extends Oauth2ClientErrorResponseError { 6 | public constructor( 7 | message: string, 8 | public readonly errorResponse: AuthorizationChallengeErrorResponse, 9 | response: FetchResponse 10 | ) { 11 | super(message, errorResponse, response) 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /packages/oauth2/src/error/Oauth2ClientErrorResponseError.ts: -------------------------------------------------------------------------------- 1 | import type { FetchResponse } from '@openid4vc/utils' 2 | import type { Oauth2ErrorResponse } from '../common/z-oauth2-error' 3 | import { Oauth2Error } from './Oauth2Error' 4 | 5 | export class Oauth2ClientErrorResponseError extends Oauth2Error { 6 | public readonly response: FetchResponse 7 | 8 | public constructor( 9 | message: string, 10 | public readonly errorResponse: Oauth2ErrorResponse, 11 | response: FetchResponse 12 | ) { 13 | super(`${message}\n${JSON.stringify(errorResponse, null, 2)}`) 14 | this.response = response.clone() 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /packages/oauth2/src/error/Oauth2Error.ts: -------------------------------------------------------------------------------- 1 | export interface Oauth2ErrorOptions { 2 | cause?: unknown 3 | } 4 | 5 | export class Oauth2Error extends Error { 6 | public readonly cause?: unknown 7 | 8 | public constructor(message?: string, options?: Oauth2ErrorOptions) { 9 | const errorMessage = message ?? 'Unknown error occured.' 10 | const causeMessage = 11 | options?.cause instanceof Error ? ` ${options.cause.message}` : options?.cause ? ` ${options?.cause}` : '' 12 | 13 | super(`${errorMessage}${causeMessage}`) 14 | this.cause = options?.cause 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /packages/oauth2/src/error/Oauth2InvalidFetchResponseError.ts: -------------------------------------------------------------------------------- 1 | import type { FetchResponse } from '@openid4vc/utils' 2 | import { Oauth2Error } from './Oauth2Error' 3 | 4 | export class InvalidFetchResponseError extends Oauth2Error { 5 | public readonly response: FetchResponse 6 | 7 | public constructor( 8 | message: string, 9 | public readonly textResponse: string, 10 | response: FetchResponse 11 | ) { 12 | super(`${message}\n${textResponse}`) 13 | this.response = response.clone() 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /packages/oauth2/src/error/Oauth2JwtParseError.ts: -------------------------------------------------------------------------------- 1 | import { Oauth2Error } from './Oauth2Error' 2 | 3 | export class Oauth2JwtParseError extends Oauth2Error { 4 | public constructor(message?: string) { 5 | const errorMessage = message ?? 'Error parsing jwt' 6 | 7 | super(errorMessage) 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /packages/oauth2/src/error/Oauth2JwtVerificationError.ts: -------------------------------------------------------------------------------- 1 | import { Oauth2Error, type Oauth2ErrorOptions } from './Oauth2Error' 2 | 3 | export class Oauth2JwtVerificationError extends Oauth2Error { 4 | public constructor(message?: string, options?: Oauth2ErrorOptions) { 5 | const errorMessage = message ?? 'Error verifiying jwt.' 6 | 7 | super(errorMessage, options) 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /packages/oauth2/src/error/Oauth2ResourceUnauthorizedError.ts: -------------------------------------------------------------------------------- 1 | import { encodeWwwAuthenticateHeader, parseWwwAuthenticateHeader } from '@openid4vc/utils' 2 | import type { SupportedAuthenticationScheme } from '../access-token/verify-access-token' 3 | import type { Oauth2ErrorCodes } from '../common/z-oauth2-error' 4 | import { Oauth2Error } from './Oauth2Error' 5 | 6 | export interface WwwAuthenticateHeaderChallenge { 7 | scheme: SupportedAuthenticationScheme | (string & {}) 8 | 9 | /** 10 | * Space delimited scope value that lists scopes required 11 | * to access this resource. 12 | */ 13 | scope?: string 14 | 15 | /** 16 | * Error should only be undefined if no access token was provided at all 17 | */ 18 | error?: Oauth2ErrorCodes | string 19 | error_description?: string 20 | 21 | /** 22 | * Additional payload items to include in the Www-Authenticate 23 | * header response. 24 | */ 25 | additionalPayload?: Record 26 | } 27 | 28 | export class Oauth2ResourceUnauthorizedError extends Oauth2Error { 29 | public readonly wwwAuthenticateHeaders: WwwAuthenticateHeaderChallenge[] 30 | 31 | public constructor( 32 | internalMessage: string | undefined, 33 | wwwAuthenticateHeaders: WwwAuthenticateHeaderChallenge | Array 34 | ) { 35 | super(`${internalMessage}\n${JSON.stringify(wwwAuthenticateHeaders, null, 2)}`) 36 | this.wwwAuthenticateHeaders = Array.isArray(wwwAuthenticateHeaders) 37 | ? wwwAuthenticateHeaders 38 | : [wwwAuthenticateHeaders] 39 | } 40 | 41 | static fromHeaderValue(value: string) { 42 | const headers = parseWwwAuthenticateHeader(value) 43 | return new Oauth2ResourceUnauthorizedError( 44 | undefined, 45 | headers.map( 46 | ({ scheme, payload: { error, error_description, scope, ...additionalPayload } }) => 47 | ({ 48 | scheme, 49 | error: Array.isArray(error) ? error.join(',') : (error ?? undefined), 50 | error_description: Array.isArray(error_description) 51 | ? error_description.join(',') 52 | : (error_description ?? undefined), 53 | scope: Array.isArray(scope) ? scope.join(',') : (scope ?? undefined), 54 | ...additionalPayload, 55 | }) satisfies WwwAuthenticateHeaderChallenge 56 | ) 57 | ) 58 | } 59 | 60 | public toHeaderValue() { 61 | return encodeWwwAuthenticateHeader( 62 | this.wwwAuthenticateHeaders.map((header) => ({ 63 | scheme: header.scheme, 64 | payload: { 65 | error: header.error ?? null, 66 | error_description: header.error_description ?? null, 67 | scope: header.scope ?? null, 68 | ...header.additionalPayload, 69 | }, 70 | })) 71 | ) 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /packages/oauth2/src/error/Oauth2ServerErrorResponseError.ts: -------------------------------------------------------------------------------- 1 | import type { Oauth2ErrorResponse } from '../common/z-oauth2-error' 2 | import type { Oauth2ErrorOptions } from '../error/Oauth2Error' 3 | import { Oauth2Error } from './Oauth2Error' 4 | 5 | interface Oauth2ServerErrorResponseErrorOptions extends Oauth2ErrorOptions { 6 | internalMessage?: string 7 | 8 | /** 9 | * @default 400 10 | */ 11 | status?: number 12 | } 13 | 14 | export class Oauth2ServerErrorResponseError extends Oauth2Error { 15 | public readonly status: number 16 | 17 | public constructor( 18 | public readonly errorResponse: Oauth2ErrorResponse, 19 | options?: Oauth2ServerErrorResponseErrorOptions 20 | ) { 21 | super( 22 | `${options?.internalMessage ?? errorResponse.error_description}\n${JSON.stringify(errorResponse, null, 2)}`, 23 | options 24 | ) 25 | this.status = options?.status ?? 400 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /packages/oauth2/src/metadata/authorization-server/__tests__/z-authorization-server-metadata.test.mts: -------------------------------------------------------------------------------- 1 | import { expect, test } from 'vitest' 2 | import { zAuthorizationServerMetadata } from '../z-authorization-server-metadata.js' 3 | 4 | test('should parse authorization server metadata', () => { 5 | // Correct 6 | const r1 = zAuthorizationServerMetadata.safeParse({ 7 | issuer: 'https://authorization.com', 8 | token_endpoint: 'https://authorization.com/token', 9 | }) 10 | 11 | expect(r1.success).toBe(true) 12 | 13 | // Incorrect 14 | const r2 = zAuthorizationServerMetadata.safeParse({ 15 | issuer: 'uri:not-valid', 16 | }) 17 | expect(r2.success).toBe(false) 18 | expect(r2.error?.issues).toBeInstanceOf(Array) 19 | }) 20 | -------------------------------------------------------------------------------- /packages/oauth2/src/metadata/authorization-server/z-authorization-server-metadata.ts: -------------------------------------------------------------------------------- 1 | import { zHttpsUrl } from '@openid4vc/utils' 2 | import z from 'zod' 3 | import { zAlgValueNotNone } from '../../common/z-common' 4 | 5 | const knownClientAuthenticationMethod = z.enum([ 6 | 'client_secret_basic', 7 | 'client_secret_post', 8 | 'attest_jwt_client_auth', 9 | 'client_secret_jwt', 10 | 'private_key_jwt', 11 | ]) 12 | 13 | export const zAuthorizationServerMetadata = z 14 | .object({ 15 | issuer: zHttpsUrl, 16 | token_endpoint: zHttpsUrl, 17 | token_endpoint_auth_methods_supported: z.optional(z.array(z.union([knownClientAuthenticationMethod, z.string()]))), 18 | authorization_endpoint: z.optional(zHttpsUrl), 19 | jwks_uri: z.optional(zHttpsUrl), 20 | 21 | // RFC7636 22 | code_challenge_methods_supported: z.optional(z.array(z.string())), 23 | 24 | // RFC9449 25 | dpop_signing_alg_values_supported: z.optional(z.array(z.string())), 26 | 27 | // RFC9126 28 | require_pushed_authorization_requests: z.optional(z.boolean()), 29 | pushed_authorization_request_endpoint: z.optional(zHttpsUrl), 30 | 31 | // RFC9068 32 | introspection_endpoint: z.optional(zHttpsUrl), 33 | introspection_endpoint_auth_methods_supported: z.optional( 34 | z.array(z.union([knownClientAuthenticationMethod, z.string()])) 35 | ), 36 | introspection_endpoint_auth_signing_alg_values_supported: z.optional(z.array(zAlgValueNotNone)), 37 | 38 | // FiPA (no RFC yet) 39 | authorization_challenge_endpoint: z.optional(zHttpsUrl), 40 | 41 | // From OpenID4VCI specification 42 | 'pre-authorized_grant_anonymous_access_supported': z.optional(z.boolean()), 43 | 44 | // Attestation Based Client Auth (draft 5) 45 | client_attestation_pop_nonce_required: z.boolean().optional(), 46 | }) 47 | .passthrough() 48 | .refine( 49 | ({ 50 | introspection_endpoint_auth_methods_supported: methodsSupported, 51 | introspection_endpoint_auth_signing_alg_values_supported: algValuesSupported, 52 | }) => { 53 | if (!methodsSupported) return true 54 | if (!methodsSupported.includes('private_key_jwt') && !methodsSupported.includes('client_secret_jwt')) return true 55 | 56 | return algValuesSupported !== undefined && algValuesSupported.length > 0 57 | }, 58 | `Metadata value 'introspection_endpoint_auth_signing_alg_values_supported' must be defined if metadata 'introspection_endpoint_auth_methods_supported' value contains values 'private_key_jwt' or 'client_secret_jwt'` 59 | ) 60 | 61 | export type AuthorizationServerMetadata = z.infer 62 | -------------------------------------------------------------------------------- /packages/oauth2/src/metadata/fetch-jwks-uri.ts: -------------------------------------------------------------------------------- 1 | import { ContentType, type Fetch, createZodFetcher } from '@openid4vc/utils' 2 | import { InvalidFetchResponseError } from '@openid4vc/utils' 3 | import { ValidationError } from '../../../utils/src/error/ValidationError' 4 | import { type JwkSet, zJwkSet } from '../common/jwk/z-jwk' 5 | 6 | /** 7 | * Fetch JWKs from a provided JWKs URI. 8 | * 9 | * Returns validated metadata if successfull response 10 | * Throws error otherwise 11 | * 12 | * @throws {ValidationError} if successfull response but validation of response failed 13 | * @throws {InvalidFetchResponseError} if unsuccesful response 14 | */ 15 | export async function fetchJwks(jwksUrl: string, fetch?: Fetch): Promise { 16 | const fetcher = createZodFetcher(fetch) 17 | 18 | const { result, response } = await fetcher(zJwkSet, ContentType.JwkSet, jwksUrl) 19 | if (!response.ok) { 20 | throw new InvalidFetchResponseError( 21 | `Fetching JWKs from jwks_uri '${jwksUrl}' resulted in an unsuccessfull response with status code '${response.status}'.`, 22 | await response.clone().text(), 23 | response 24 | ) 25 | } 26 | 27 | if (!result?.success) { 28 | throw new ValidationError(`Validation of JWKs from jwks_uri '${jwksUrl}' failed`, result?.error) 29 | } 30 | 31 | return result.data 32 | } 33 | -------------------------------------------------------------------------------- /packages/oauth2/src/metadata/fetch-well-known-metadata.ts: -------------------------------------------------------------------------------- 1 | import { type BaseSchema, ContentType, type Fetch, createZodFetcher } from '@openid4vc/utils' 2 | import { InvalidFetchResponseError } from '@openid4vc/utils' 3 | import type z from 'zod' 4 | import { ValidationError } from '../../../utils/src/error/ValidationError' 5 | 6 | /** 7 | * Fetch well known metadata and validate the response. 8 | * 9 | * Returns null if 404 is returned 10 | * Returns validated metadata if successfull response 11 | * Throws error otherwise 12 | * 13 | * @throws {ValidationError} if successfull response but validation of response failed 14 | * @throws {InvalidFetchResponseError} if no successfull or 404 response 15 | * @throws {Error} if parsing json from response fails 16 | */ 17 | export async function fetchWellKnownMetadata( 18 | wellKnownMetadataUrl: string, 19 | schema: Schema, 20 | fetch?: Fetch 21 | ): Promise | null> { 22 | const fetcher = createZodFetcher(fetch) 23 | 24 | const { result, response } = await fetcher(schema, ContentType.Json, wellKnownMetadataUrl) 25 | if (response.status === 404) { 26 | return null 27 | } 28 | 29 | if (!response.ok) { 30 | throw new InvalidFetchResponseError( 31 | `Fetching well known metadata from '${wellKnownMetadataUrl}' resulted in an unsuccessfull response with status '${response.status}'.`, 32 | await response.clone().text(), 33 | response 34 | ) 35 | } 36 | 37 | if (!result?.success) { 38 | throw new ValidationError(`Validation of metadata from '${wellKnownMetadataUrl}' failed`, result?.error) 39 | } 40 | 41 | return result.data 42 | } 43 | -------------------------------------------------------------------------------- /packages/oauth2/src/pkce.ts: -------------------------------------------------------------------------------- 1 | import { decodeUtf8String, encodeToBase64Url } from '@openid4vc/utils' 2 | import { type CallbackContext, HashAlgorithm, type HashCallback } from './callbacks' 3 | import { Oauth2Error } from './error/Oauth2Error' 4 | 5 | export enum PkceCodeChallengeMethod { 6 | Plain = 'plain', 7 | S256 = 'S256', 8 | } 9 | 10 | export interface CreatePkceOptions { 11 | /** 12 | * Also allows string values so it can be directly passed from the 13 | * 'code_challenge_methods_supported' metadata parameter 14 | */ 15 | allowedCodeChallengeMethods?: Array 16 | 17 | /** 18 | * Code verifier to use. If not provided a value will be generated. 19 | */ 20 | codeVerifier?: string 21 | 22 | callbacks: Pick 23 | } 24 | 25 | export interface CreatePkceReturn { 26 | codeVerifier: string 27 | codeChallenge: string 28 | codeChallengeMethod: PkceCodeChallengeMethod 29 | } 30 | 31 | export async function createPkce(options: CreatePkceOptions): Promise { 32 | const allowedCodeChallengeMethods = options.allowedCodeChallengeMethods ?? [ 33 | PkceCodeChallengeMethod.S256, 34 | PkceCodeChallengeMethod.Plain, 35 | ] 36 | 37 | if (allowedCodeChallengeMethods.length === 0) { 38 | throw new Oauth2Error(`Unable to create PKCE code verifier. 'allowedCodeChallengeMethods' is an empty array.`) 39 | } 40 | 41 | const codeChallengeMethod = allowedCodeChallengeMethods.includes(PkceCodeChallengeMethod.S256) 42 | ? PkceCodeChallengeMethod.S256 43 | : PkceCodeChallengeMethod.Plain 44 | 45 | const codeVerifier = options.codeVerifier ?? encodeToBase64Url(await options.callbacks.generateRandom(64)) 46 | return { 47 | codeVerifier, 48 | codeChallenge: await calculateCodeChallenge({ 49 | codeChallengeMethod, 50 | codeVerifier, 51 | hashCallback: options.callbacks.hash, 52 | }), 53 | codeChallengeMethod, 54 | } 55 | } 56 | 57 | export interface VerifyPkceOptions { 58 | /** 59 | * secure random code verifier 60 | */ 61 | codeVerifier: string 62 | 63 | codeChallenge: string 64 | codeChallengeMethod: PkceCodeChallengeMethod 65 | 66 | callbacks: Pick 67 | } 68 | 69 | export async function verifyPkce(options: VerifyPkceOptions) { 70 | const calculatedCodeChallenge = await calculateCodeChallenge({ 71 | codeChallengeMethod: options.codeChallengeMethod, 72 | codeVerifier: options.codeVerifier, 73 | hashCallback: options.callbacks.hash, 74 | }) 75 | 76 | if (options.codeChallenge !== calculatedCodeChallenge) { 77 | throw new Oauth2Error( 78 | `Derived code challenge '${calculatedCodeChallenge}' from code_verifier '${options.codeVerifier}' using code challenge method '${options.codeChallengeMethod}' does not match the expected code challenge.` 79 | ) 80 | } 81 | } 82 | 83 | async function calculateCodeChallenge(options: { 84 | codeVerifier: string 85 | codeChallengeMethod: PkceCodeChallengeMethod 86 | hashCallback: HashCallback 87 | }) { 88 | if (options.codeChallengeMethod === PkceCodeChallengeMethod.Plain) { 89 | return options.codeVerifier 90 | } 91 | 92 | if (options.codeChallengeMethod === PkceCodeChallengeMethod.S256) { 93 | return encodeToBase64Url(await options.hashCallback(decodeUtf8String(options.codeVerifier), HashAlgorithm.Sha256)) 94 | } 95 | 96 | throw new Oauth2Error(`Unsupported code challenge method ${options.codeChallengeMethod}`) 97 | } 98 | -------------------------------------------------------------------------------- /packages/oauth2/src/z-grant-type.ts: -------------------------------------------------------------------------------- 1 | import z from 'zod' 2 | 3 | export const zPreAuthorizedCodeGrantIdentifier = z.literal('urn:ietf:params:oauth:grant-type:pre-authorized_code') 4 | export const preAuthorizedCodeGrantIdentifier = zPreAuthorizedCodeGrantIdentifier.value 5 | export type PreAuthorizedCodeGrantIdentifier = z.infer 6 | 7 | export const zAuthorizationCodeGrantIdentifier = z.literal('authorization_code') 8 | export const authorizationCodeGrantIdentifier = zAuthorizationCodeGrantIdentifier.value 9 | export type AuthorizationCodeGrantIdentifier = z.infer 10 | 11 | export const zRefreshTokenGrantIdentifier = z.literal('refresh_token') 12 | export const refreshTokenGrantIdentifier = zRefreshTokenGrantIdentifier.value 13 | export type RefreshTokenGrantIdentifier = z.infer 14 | -------------------------------------------------------------------------------- /packages/oauth2/tests/util.mts: -------------------------------------------------------------------------------- 1 | import crypto, { webcrypto } from 'node:crypto' 2 | import { decodeBase64, encodeToUtf8String } from '@openid4vc/utils' 3 | import * as jose from 'jose' 4 | import { type CallbackContext, HashAlgorithm, type SignJwtCallback } from '../src/callbacks.js' 5 | import { clientAuthenticationNone } from '../src/client-authentication.js' 6 | import { calculateJwkThumbprint } from '../src/common/jwk/jwk-thumbprint.js' 7 | import type { Jwk } from '../src/common/jwk/z-jwk.js' 8 | 9 | // Needed for Node 18 support with jose6. We can soon drop node18 support. 10 | if (process.versions.node.startsWith('18.')) { 11 | // @ts-ignore 12 | globalThis.crypto = webcrypto 13 | } 14 | 15 | export function parseXwwwFormUrlEncoded(text: string) { 16 | return Object.fromEntries(Array.from(new URLSearchParams(text).entries())) 17 | } 18 | 19 | export const callbacks = { 20 | hash: (data, alg) => crypto.createHash(alg.replace('-', '').toLowerCase()).update(data).digest(), 21 | generateRandom: (bytes) => crypto.randomBytes(bytes), 22 | clientAuthentication: clientAuthenticationNone({ 23 | clientId: 'some-random-client-id', 24 | }), 25 | verifyJwt: async (signer, { compact, payload }) => { 26 | let jwk: Jwk 27 | if (signer.method === 'did') { 28 | jwk = JSON.parse(encodeToUtf8String(decodeBase64(signer.didUrl.split('#')[0].replace('did:jwk:', '')))) 29 | } else if (signer.method === 'jwk') { 30 | jwk = signer.publicJwk 31 | } else { 32 | throw new Error('Signer method not supported') 33 | } 34 | 35 | const josePublicKey = await jose.importJWK(jwk as jose.JWK, signer.alg) 36 | try { 37 | await jose.jwtVerify(compact, josePublicKey, { 38 | currentDate: payload.exp ? new Date((payload.exp - 300) * 1000) : undefined, 39 | }) 40 | return { 41 | verified: true, 42 | signerJwk: jwk, 43 | } 44 | } catch (error) { 45 | return { 46 | verified: false, 47 | } 48 | } 49 | }, 50 | } as const satisfies Partial 51 | 52 | export const getSignJwtCallback = (privateJwks: Jwk[]): SignJwtCallback => { 53 | return async (signer, { header, payload }) => { 54 | let jwk: Jwk 55 | if (signer.method === 'did') { 56 | jwk = JSON.parse(encodeToUtf8String(decodeBase64(signer.didUrl.split('#')[0].replace('did:jwk:', '')))) 57 | } else if (signer.method === 'jwk') { 58 | jwk = signer.publicJwk 59 | } else { 60 | throw new Error('Signer method not supported') 61 | } 62 | 63 | const jwkThumprint = await calculateJwkThumbprint({ 64 | jwk, 65 | hashAlgorithm: HashAlgorithm.Sha256, 66 | hashCallback: callbacks.hash, 67 | }) 68 | 69 | const privateJwk = await Promise.all( 70 | privateJwks.map(async (jwk) => 71 | (await calculateJwkThumbprint({ 72 | hashAlgorithm: HashAlgorithm.Sha256, 73 | hashCallback: callbacks.hash, 74 | jwk, 75 | })) === jwkThumprint 76 | ? jwk 77 | : undefined 78 | ) 79 | ).then((jwks) => jwks.find((jwk) => jwk !== undefined)) 80 | 81 | if (!privateJwk) { 82 | throw new Error(`No private key available for public jwk \n${JSON.stringify(jwk, null, 2)}`) 83 | } 84 | 85 | const josePrivateKey = await jose.importJWK(privateJwk as jose.JWK, signer.alg) 86 | const jwt = await new jose.SignJWT(payload).setProtectedHeader(header).sign(josePrivateKey) 87 | 88 | return { 89 | jwt: jwt, 90 | signerJwk: jwk, 91 | } 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /packages/oauth2/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "build" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /packages/openid4vci/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # @openid4vc/openid4vci 2 | 3 | ## 0.2.0 4 | 5 | ### Minor Changes 6 | 7 | - 0f60387: feat: add key attestations 8 | 9 | ### Patch Changes 10 | 11 | - Updated dependencies [0f60387] 12 | - Updated dependencies [3f6d360] 13 | - @openid4vc/oauth2@0.2.0 14 | - @openid4vc/utils@0.2.0 15 | 16 | ## 0.1.4 17 | 18 | ### Patch Changes 19 | 20 | - Updated dependencies [12a517a] 21 | - @openid4vc/oauth2@0.1.4 22 | - @openid4vc/utils@0.1.4 23 | 24 | ## 0.1.3 25 | 26 | ### Patch Changes 27 | 28 | - d4b9279: chore: create github release 29 | - Updated dependencies [d4b9279] 30 | - @openid4vc/oauth2@0.1.3 31 | - @openid4vc/utils@0.1.3 32 | 33 | ## 0.1.2 34 | 35 | ### Patch Changes 36 | 37 | - 1de27e5: chore: correct formatting for publishing 38 | - Updated dependencies [1de27e5] 39 | - @openid4vc/oauth2@0.1.2 40 | - @openid4vc/utils@0.1.2 41 | 42 | ## 0.1.1 43 | 44 | ### Patch Changes 45 | 46 | - 6434781: docs: add readme 47 | - Updated dependencies [6434781] 48 | - @openid4vc/oauth2@0.1.1 49 | - @openid4vc/utils@0.1.1 50 | 51 | ## 0.1.0 52 | 53 | ### Minor Changes 54 | 55 | - 71326c8: feat: initial release 56 | 57 | ### Patch Changes 58 | 59 | - 3bcbd08: export additional types 60 | - Updated dependencies [71326c8] 61 | - @openid4vc/oauth2@0.1.0 62 | - @openid4vc/utils@0.1.0 63 | -------------------------------------------------------------------------------- /packages/openid4vci/README.md: -------------------------------------------------------------------------------- 1 |

OpenID for Verifiable Credentials - OpenID4VCI

2 | 3 |

4 | 5 | 6 | 7 |

8 | 9 | Check out the documentation in the [Github repository](https://github.com/openwallet-foundation-labs/oid4vc-ts). 10 | -------------------------------------------------------------------------------- /packages/openid4vci/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@openid4vc/openid4vci", 3 | "version": "0.2.0", 4 | "files": ["dist"], 5 | "license": "Apache-2.0", 6 | "exports": "./src/index.ts", 7 | "homepage": "https://github.com/openwallet-foundation-labs/oid4vc-ts/tree/main/packages/openid4vci", 8 | "repository": { 9 | "type": "git", 10 | "url": "https://github.com/openwallet-foundation-labs/oid4vc-ts", 11 | "directory": "packages/openid4vci" 12 | }, 13 | "publishConfig": { 14 | "main": "./dist/index.js", 15 | "module": "./dist/index.mjs", 16 | "types": "./dist/index.d.ts", 17 | "exports": { 18 | ".": { 19 | "import": "./dist/index.mjs", 20 | "require": "./dist/index.js", 21 | "types": "./dist/index.d.ts" 22 | }, 23 | "./package.json": "./package.json" 24 | } 25 | }, 26 | "scripts": { 27 | "build": "tsup src/index.ts --format cjs,esm --dts --clean --sourcemap" 28 | }, 29 | "dependencies": { 30 | "@openid4vc/oauth2": "workspace:*", 31 | "@openid4vc/utils": "workspace:*", 32 | "zod": "catalog:" 33 | }, 34 | "devDependencies": { 35 | "jose": "catalog:" 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /packages/openid4vci/src/Openid4vciWalletProvider.ts: -------------------------------------------------------------------------------- 1 | import { 2 | type CallbackContext, 3 | type CreateClientAttestationJwtOptions, 4 | createClientAttestationJwt, 5 | } from '@openid4vc/oauth2' 6 | import { type CreateKeyAttestationJwtOptions, createKeyAttestationJwt } from './key-attestation/key-attestation' 7 | 8 | export interface Openid4vciWalletProviderOptions { 9 | /** 10 | * Callbacks required for the openid4vc issuer 11 | */ 12 | callbacks: Pick 13 | } 14 | 15 | export class Openid4vciWalletProvider { 16 | public constructor(private options: Openid4vciWalletProviderOptions) {} 17 | 18 | public async createWalletAttestationJwt( 19 | options: Omit & { walletName?: string; walletLink?: string } 20 | ) { 21 | const additionalPayload = options.additionalPayload 22 | ? { 23 | wallet_name: options.walletName, 24 | wallet_link: options.walletLink, 25 | ...options.additionalPayload, 26 | } 27 | : { 28 | wallet_name: options.walletName, 29 | wallet_link: options.walletLink, 30 | } 31 | 32 | return await createClientAttestationJwt({ 33 | ...options, 34 | callbacks: this.options.callbacks, 35 | additionalPayload, 36 | }) 37 | } 38 | 39 | public async createKeyAttestationJwt(options: Omit) { 40 | return await createKeyAttestationJwt({ 41 | callbacks: this.options.callbacks, 42 | ...options, 43 | }) 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /packages/openid4vci/src/credential-request/credential-request-configurations.ts: -------------------------------------------------------------------------------- 1 | import { arrayEqualsIgnoreOrder } from '@openid4vc/utils' 2 | import { extractKnownCredentialConfigurationSupportedFormats } from '../metadata/credential-issuer/credential-issuer-metadata' 3 | import type { 4 | CredentialConfigurationsSupported, 5 | CredentialConfigurationsSupportedWithFormats, 6 | } from '../metadata/credential-issuer/z-credential-issuer-metadata' 7 | import type { CredentialRequestFormatSpecific } from './z-credential-request' 8 | 9 | export interface GetCredentialConfigurationsMatchingRequestFormatOptions { 10 | requestFormat: CredentialRequestFormatSpecific 11 | credentialConfigurations: CredentialConfigurationsSupported 12 | } 13 | 14 | export function getCredentialConfigurationsMatchingRequestFormat({ 15 | requestFormat, 16 | credentialConfigurations, 17 | }: GetCredentialConfigurationsMatchingRequestFormatOptions): CredentialConfigurationsSupportedWithFormats { 18 | // credential request format will only contain known formats 19 | const knownCredentialConfigurations = extractKnownCredentialConfigurationSupportedFormats(credentialConfigurations) 20 | 21 | return Object.fromEntries( 22 | Object.entries(knownCredentialConfigurations).filter(([, credentialConfiguration]) => { 23 | if (credentialConfiguration.format !== requestFormat.format) return false 24 | 25 | const r = requestFormat 26 | const c = credentialConfiguration 27 | 28 | if ((c.format === 'ldp_vc' || c.format === 'jwt_vc_json-ld') && r.format === c.format) { 29 | return ( 30 | arrayEqualsIgnoreOrder(r.credential_definition.type, c.credential_definition.type) && 31 | arrayEqualsIgnoreOrder(r.credential_definition['@context'], c.credential_definition['@context']) 32 | ) 33 | } 34 | 35 | if (c.format === 'jwt_vc_json' && r.format === c.format) { 36 | return arrayEqualsIgnoreOrder(r.credential_definition.type, c.credential_definition.type) 37 | } 38 | 39 | if (c.format === 'vc+sd-jwt' && r.format === c.format) { 40 | return r.vct === c.vct 41 | } 42 | 43 | if (c.format === 'mso_mdoc' && r.format === c.format) { 44 | return r.doctype === c.doctype 45 | } 46 | 47 | return false 48 | }) 49 | ) 50 | } 51 | -------------------------------------------------------------------------------- /packages/openid4vci/src/credential-request/credential-response.ts: -------------------------------------------------------------------------------- 1 | import { parseWithErrorHandling } from '@openid4vc/utils' 2 | import type { ParseCredentialRequestReturn } from './parse-credential-request' 3 | import { type CredentialResponse, zCredentialResponse } from './z-credential-response' 4 | 5 | export interface CreateCredentialResponseOptions { 6 | credentialRequest: ParseCredentialRequestReturn 7 | 8 | credential?: CredentialResponse['credential'] 9 | credentials?: CredentialResponse['credentials'] 10 | 11 | cNonce?: string 12 | cNonceExpiresInSeconds?: number 13 | 14 | notificationId?: string 15 | 16 | /** 17 | * Additional payload to include in the credential response 18 | */ 19 | additionalPayload?: Record 20 | } 21 | 22 | export function createCredentialResponse(options: CreateCredentialResponseOptions) { 23 | const credentialResponse = parseWithErrorHandling(zCredentialResponse, { 24 | c_nonce: options.cNonce, 25 | c_nonce_expires_in: options.cNonceExpiresInSeconds, 26 | credential: options.credential, 27 | credentials: options.credentials, 28 | notification_id: options.notificationId, 29 | 30 | // NOTE `format` is removed in draft 13. For now if a format was requested 31 | // we just always return it in the response as well. 32 | format: options.credentialRequest.format?.format, 33 | ...options.additionalPayload, 34 | } satisfies CredentialResponse) 35 | 36 | return credentialResponse 37 | } 38 | -------------------------------------------------------------------------------- /packages/openid4vci/src/credential-request/z-credential-request-common.ts: -------------------------------------------------------------------------------- 1 | import { zJwk } from '@openid4vc/oauth2' 2 | import type { InferOutputUnion, Simplify } from '@openid4vc/utils' 3 | import z from 'zod' 4 | import { 5 | zAttestationProofTypeIdentifier, 6 | zCredentialRequestProofAttestation, 7 | zCredentialRequestProofJwt, 8 | zJwtProofTypeIdentifier, 9 | } from '../formats/proof-type' 10 | 11 | const zCredentialRequestProofCommon = z 12 | .object({ 13 | proof_type: z.string(), 14 | }) 15 | .passthrough() 16 | 17 | export const allCredentialRequestProofs = [zCredentialRequestProofJwt, zCredentialRequestProofAttestation] as const 18 | 19 | export const zCredentialRequestProof = z.union([ 20 | zCredentialRequestProofCommon, 21 | z.discriminatedUnion('proof_type', allCredentialRequestProofs), 22 | ]) 23 | 24 | const zCredentialRequestProofsCommon = z.record(z.string(), z.array(z.unknown())) 25 | export const zCredentialRequestProofs = z.object({ 26 | [zJwtProofTypeIdentifier.value]: z.optional(z.array(zCredentialRequestProofJwt.shape.jwt)), 27 | [zAttestationProofTypeIdentifier.value]: z.optional(z.array(zCredentialRequestProofAttestation.shape.attestation)), 28 | }) 29 | 30 | type CredentialRequestProofCommon = z.infer 31 | export type CredentialRequestProofFormatSpecific = InferOutputUnion 32 | export type CredentialRequestProofWithFormats = Simplify< 33 | CredentialRequestProofCommon & CredentialRequestProofFormatSpecific 34 | > 35 | export type CredentialRequestProof = z.infer 36 | 37 | export type CredentialRequestProofsCommon = z.infer 38 | export type CredentialRequestProofsFormatSpecific = z.infer 39 | export type CredentialRequestProofsWithFormat = CredentialRequestProofsCommon & CredentialRequestProofsFormatSpecific 40 | export type CredentialRequestProofs = z.infer 41 | 42 | export const zCredentialRequestCommon = z 43 | .object({ 44 | proof: zCredentialRequestProof.optional(), 45 | proofs: z.optional( 46 | z 47 | .intersection(zCredentialRequestProofsCommon, zCredentialRequestProofs) 48 | .refine((proofs) => Object.values(proofs).length === 1, { 49 | message: `The 'proofs' object in a credential request should contain exactly one attribute`, 50 | }) 51 | ), 52 | 53 | credential_response_encryption: z 54 | .object({ 55 | jwk: zJwk, 56 | alg: z.string(), 57 | enc: z.string(), 58 | }) 59 | .passthrough() 60 | .optional(), 61 | }) 62 | .passthrough() 63 | // It's not allowed to provide both proof and proofs 64 | .refine(({ proof, proofs }) => !(proof !== undefined && proofs !== undefined), { 65 | message: `Both 'proof' and 'proofs' are defined. Only one is allowed`, 66 | }) 67 | -------------------------------------------------------------------------------- /packages/openid4vci/src/credential-request/z-credential-response.ts: -------------------------------------------------------------------------------- 1 | import z from 'zod' 2 | import { zOauth2ErrorResponse } from '../../../oauth2/src/common/z-oauth2-error' 3 | 4 | const zCredentialEncoding = z.union([z.string(), z.record(z.string(), z.any())]) 5 | 6 | export const zCredentialResponse = z 7 | .object({ 8 | credential: z.optional(zCredentialEncoding), 9 | credentials: z.optional(z.array(zCredentialEncoding)), 10 | 11 | transaction_id: z.string().optional(), 12 | 13 | c_nonce: z.string().optional(), 14 | c_nonce_expires_in: z.number().int().optional(), 15 | 16 | notification_id: z.string().optional(), 17 | }) 18 | .passthrough() 19 | .refine( 20 | (value) => { 21 | const { credential, credentials, transaction_id } = value 22 | return [credential, credentials, transaction_id].filter((i) => i !== undefined).length === 1 23 | }, 24 | { 25 | message: `Exactly one of 'credential', 'credentials', or 'transaction_id' MUST be defined.`, 26 | } 27 | ) 28 | 29 | export type CredentialResponse = z.infer 30 | 31 | export const zCredentialErrorResponse = z 32 | .object({ 33 | ...zOauth2ErrorResponse.shape, 34 | 35 | c_nonce: z.string().optional(), 36 | c_nonce_expires_in: z.number().int().optional(), 37 | }) 38 | .passthrough() 39 | 40 | export type CredentialErrorResponse = z.infer 41 | -------------------------------------------------------------------------------- /packages/openid4vci/src/error/Openid4vciError.ts: -------------------------------------------------------------------------------- 1 | export interface Openid4vciErrorOptions { 2 | cause?: unknown 3 | } 4 | 5 | export class Openid4vciError extends Error { 6 | public readonly cause?: unknown 7 | 8 | public constructor(message?: string, options?: Openid4vciErrorOptions) { 9 | const errorMessage = message ?? 'Unknown error occured.' 10 | const causeMessage = 11 | options?.cause instanceof Error ? ` ${options.cause.message}` : options?.cause ? ` ${options?.cause}` : '' 12 | 13 | super(`${errorMessage}${causeMessage}`) 14 | this.cause = options?.cause 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /packages/openid4vci/src/error/Openid4vciRetrieveCredentialsError.ts: -------------------------------------------------------------------------------- 1 | import type { RetrieveCredentialsResponseNotOk } from '../credential-request/retrieve-credentials' 2 | import { Openid4vciError } from './Openid4vciError' 3 | 4 | export class Openid4vciRetrieveCredentialsError extends Openid4vciError { 5 | public constructor( 6 | message: string, 7 | public response: RetrieveCredentialsResponseNotOk, 8 | responseText: string 9 | ) { 10 | super( 11 | `${message}\n${JSON.stringify(response.credentialResponseResult?.data ?? response.credentialErrorResponseResult?.data ?? responseText, null, 2)}` 12 | ) 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /packages/openid4vci/src/error/Openid4vciSendNotificationError.ts: -------------------------------------------------------------------------------- 1 | import type { SendNotificationResponseNotOk } from '../notification/notification' 2 | import { Openid4vciError } from './Openid4vciError' 3 | 4 | export class Openid4vciSendNotificationError extends Openid4vciError { 5 | public constructor( 6 | message: string, 7 | public response: SendNotificationResponseNotOk 8 | ) { 9 | super(message) 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /packages/openid4vci/src/formats/credential/index.ts: -------------------------------------------------------------------------------- 1 | import type { MsoMdocFormatIdentifier } from './mso-mdoc/z-mso-mdoc' 2 | import type { SdJwtDcFormatIdentifier } from './sd-jwt-dc/z-sd-jwt-dc' 3 | import type { SdJwtVcFormatIdentifier } from './sd-jwt-vc/z-sd-jwt-vc' 4 | import type { JwtVcJsonFormatIdentifier } from './w3c-vc/z-w3c-jwt-vc-json' 5 | import type { JwtVcJsonLdFormatIdentifier } from './w3c-vc/z-w3c-jwt-vc-json-ld' 6 | import type { LdpVcFormatIdentifier } from './w3c-vc/z-w3c-ldp-vc' 7 | 8 | // mso_mdoc 9 | export { 10 | type MsoMdocFormatIdentifier, 11 | zMsoMdocCredentialIssuerMetadata, 12 | zMsoMdocCredentialIssuerMetadataDraft14, 13 | zMsoMdocCredentialRequestFormatDraft14, 14 | zMsoMdocFormatIdentifier, 15 | } from './mso-mdoc/z-mso-mdoc' 16 | 17 | // vc+sd-jwt 18 | export { 19 | type SdJwtVcFormatIdentifier, 20 | zSdJwtVcCredentialIssuerMetadataDraft14, 21 | zSdJwtVcCredentialRequestFormatDraft14, 22 | zSdJwtVcFormatIdentifier, 23 | } from './sd-jwt-vc/z-sd-jwt-vc' 24 | 25 | // dc+sd-jwt 26 | export { 27 | type SdJwtDcFormatIdentifier, 28 | zSdJwtDcCredentialIssuerMetadata, 29 | zSdJwtDcFormatIdentifier, 30 | } from './sd-jwt-dc/z-sd-jwt-dc' 31 | 32 | // ldp_vc 33 | export { 34 | type LdpVcFormatIdentifier, 35 | zLdpVcCredentialIssuerMetadata, 36 | zLdpVcCredentialIssuerMetadataDraft14, 37 | zLdpVcCredentialIssuerMetadataDraft11, 38 | zLdpVcCredentialIssuerMetadataDraft11To14, 39 | zLdpVcCredentialIssuerMetadataDraft14To11, 40 | zLdpVcCredentialRequestFormatDraft14, 41 | zLdpVcCredentialRequestDraft14To11, 42 | zLdpVcFormatIdentifier, 43 | } from './w3c-vc/z-w3c-ldp-vc' 44 | 45 | // jwt_vc_json-ld 46 | export { 47 | type JwtVcJsonLdFormatIdentifier, 48 | zJwtVcJsonLdCredentialIssuerMetadata, 49 | zJwtVcJsonLdCredentialIssuerMetadataDraft14, 50 | zJwtVcJsonLdCredentialIssuerMetadataDraft11, 51 | zJwtVcJsonLdCredentialIssuerMetadataDraft11To14, 52 | zJwtVcJsonLdCredentialIssuerMetadataDraft14To11, 53 | zJwtVcJsonLdCredentialRequestFormatDraft14, 54 | zJwtVcJsonLdCredentialRequestDraft14To11, 55 | zJwtVcJsonLdFormatIdentifier, 56 | } from './w3c-vc/z-w3c-jwt-vc-json-ld' 57 | 58 | // jwt_vc_json 59 | export { 60 | type JwtVcJsonFormatIdentifier, 61 | zJwtVcJsonCredentialIssuerMetadata, 62 | zJwtVcJsonCredentialIssuerMetadataDraft14, 63 | zJwtVcJsonCredentialIssuerMetadataDraft11, 64 | zJwtVcJsonCredentialIssuerMetadataDraft11To14, 65 | zJwtVcJsonCredentialIssuerMetadataDraft14To11, 66 | zJwtVcJsonCredentialRequestDraft14To11, 67 | zJwtVcJsonCredentialRequestFormatDraft14, 68 | zJwtVcJsonFormatIdentifier, 69 | } from './w3c-vc/z-w3c-jwt-vc-json' 70 | 71 | export type CredentialFormatIdentifier = 72 | | MsoMdocFormatIdentifier 73 | | SdJwtVcFormatIdentifier 74 | | SdJwtDcFormatIdentifier 75 | | LdpVcFormatIdentifier 76 | | JwtVcJsonLdFormatIdentifier 77 | | JwtVcJsonFormatIdentifier 78 | -------------------------------------------------------------------------------- /packages/openid4vci/src/formats/credential/mso-mdoc/z-mso-mdoc.ts: -------------------------------------------------------------------------------- 1 | import z from 'zod' 2 | import { 3 | zCredentialConfigurationSupportedClaimsDraft14, 4 | zMsoMdocIssuerMetadataClaimsDescription, 5 | } from '../../../metadata/credential-issuer/z-claims-description' 6 | 7 | export const zMsoMdocFormatIdentifier = z.literal('mso_mdoc') 8 | export type MsoMdocFormatIdentifier = z.infer 9 | 10 | export const zMsoMdocCredentialIssuerMetadata = z.object({ 11 | format: zMsoMdocFormatIdentifier, 12 | doctype: z.string(), 13 | claims: z.array(zMsoMdocIssuerMetadataClaimsDescription).optional(), 14 | }) 15 | 16 | export const zMsoMdocCredentialIssuerMetadataDraft14 = z.object({ 17 | format: zMsoMdocFormatIdentifier, 18 | doctype: z.string(), 19 | claims: zCredentialConfigurationSupportedClaimsDraft14.optional(), 20 | order: z.optional(z.array(z.string())), 21 | }) 22 | 23 | export const zMsoMdocCredentialRequestFormatDraft14 = z.object({ 24 | format: zMsoMdocFormatIdentifier, 25 | doctype: z.string(), 26 | // Format based request is removed in Draft 15, so only old claims syntax supported. 27 | claims: z.optional(zCredentialConfigurationSupportedClaimsDraft14), 28 | }) 29 | -------------------------------------------------------------------------------- /packages/openid4vci/src/formats/credential/sd-jwt-dc/__tests__/z-sd-jwt-dc.test.mts: -------------------------------------------------------------------------------- 1 | import { expect, test } from 'vitest' 2 | import { zSdJwtDcCredentialIssuerMetadata, zSdJwtDcFormatIdentifier } from '../z-sd-jwt-dc.js' 3 | 4 | test('should parse sd-jwt-dc format identifier', () => { 5 | expect(zSdJwtDcFormatIdentifier.safeParse('dc+sd-jwt')).toStrictEqual({ 6 | data: 'dc+sd-jwt', 7 | success: true, 8 | }) 9 | 10 | expect(zSdJwtDcFormatIdentifier.safeParse('dc+sd-jwt2')).toStrictEqual({ 11 | error: expect.any(Error), 12 | success: false, 13 | }) 14 | }) 15 | 16 | test('should parse sd-jwt-dc credential issuer metadata', () => { 17 | expect( 18 | zSdJwtDcCredentialIssuerMetadata.safeParse({ 19 | format: 'dc+sd-jwt', 20 | scope: 'SD_JWT_DC_example_in_OpenID4VCI', 21 | cryptographic_binding_methods_supported: ['jwk'], 22 | credential_signing_alg_values_supported: ['ES256'], 23 | display: [ 24 | { 25 | name: 'IdentityCredential', 26 | logo: { 27 | uri: 'https://university.example.edu/public/logo.png', 28 | alt_text: 'a square logo of a university', 29 | }, 30 | locale: 'en-US', 31 | background_color: '#12107c', 32 | text_color: '#FFFFFF', 33 | }, 34 | ], 35 | proof_types_supported: { 36 | jwt: { 37 | proof_signing_alg_values_supported: ['ES256'], 38 | }, 39 | }, 40 | vct: 'SD_JWT_DC_example_in_OpenID4VCI', 41 | claims: [ 42 | { 43 | path: ['given_name'], 44 | display: [ 45 | { 46 | name: 'Given Name', 47 | locale: 'en-US', 48 | }, 49 | { 50 | name: 'Vorname', 51 | locale: 'de-DE', 52 | }, 53 | ], 54 | }, 55 | { 56 | path: ['family_name'], 57 | display: [ 58 | { 59 | name: 'Surname', 60 | locale: 'en-US', 61 | }, 62 | { 63 | name: 'Nachname', 64 | locale: 'de-DE', 65 | }, 66 | ], 67 | }, 68 | { 69 | path: ['email'], 70 | }, 71 | ], 72 | }) 73 | ).toStrictEqual({ 74 | data: expect.objectContaining({}), 75 | success: true, 76 | }) 77 | }) 78 | -------------------------------------------------------------------------------- /packages/openid4vci/src/formats/credential/sd-jwt-dc/z-sd-jwt-dc.ts: -------------------------------------------------------------------------------- 1 | import z from 'zod' 2 | import { zIssuerMetadataClaimsDescription } from '../../../metadata/credential-issuer/z-claims-description' 3 | 4 | export const zSdJwtDcFormatIdentifier = z.literal('dc+sd-jwt') 5 | export type SdJwtDcFormatIdentifier = z.infer 6 | 7 | export const zSdJwtDcCredentialIssuerMetadata = z.object({ 8 | vct: z.string(), 9 | format: zSdJwtDcFormatIdentifier, 10 | claims: z.array(zIssuerMetadataClaimsDescription).optional(), 11 | }) 12 | -------------------------------------------------------------------------------- /packages/openid4vci/src/formats/credential/sd-jwt-vc/__tests__/z-sd-jwt-vc.test.mts: -------------------------------------------------------------------------------- 1 | import { expect, test } from 'vitest' 2 | import { zSdJwtVcCredentialIssuerMetadataDraft14, zSdJwtVcFormatIdentifier } from '../z-sd-jwt-vc.js' 3 | 4 | test('should parse sd-jwt-vc format identifier', () => { 5 | expect(zSdJwtVcFormatIdentifier.safeParse('vc+sd-jwt')).toStrictEqual({ 6 | data: 'vc+sd-jwt', 7 | success: true, 8 | }) 9 | 10 | expect(zSdJwtVcFormatIdentifier.safeParse('vc+sd-jwt2')).toStrictEqual({ 11 | error: expect.any(Error), 12 | success: false, 13 | }) 14 | }) 15 | 16 | test('should parse sd-jwt-vc credential issuer metadata', () => { 17 | expect( 18 | zSdJwtVcCredentialIssuerMetadataDraft14.safeParse({ 19 | format: 'vc+sd-jwt', 20 | scope: 'SD_JWT_VC_example_in_OpenID4VCI', 21 | cryptographic_binding_methods_supported: ['jwk'], 22 | credential_signing_alg_values_supported: ['ES256'], 23 | display: [ 24 | { 25 | name: 'IdentityCredential', 26 | logo: { 27 | uri: 'https://university.example.edu/public/logo.png', 28 | alt_text: 'a square logo of a university', 29 | }, 30 | locale: 'en-US', 31 | background_color: '#12107c', 32 | text_color: '#FFFFFF', 33 | }, 34 | ], 35 | proof_types_supported: { 36 | jwt: { 37 | proof_signing_alg_values_supported: ['ES256'], 38 | }, 39 | }, 40 | vct: 'SD_JWT_VC_example_in_OpenID4VCI', 41 | claims: { 42 | given_name: { 43 | display: [ 44 | { 45 | name: 'Given Name', 46 | locale: 'en-US', 47 | }, 48 | { 49 | name: 'Vorname', 50 | locale: 'de-DE', 51 | }, 52 | ], 53 | }, 54 | family_name: { 55 | display: [ 56 | { 57 | name: 'Surname', 58 | locale: 'en-US', 59 | }, 60 | { 61 | name: 'Nachname', 62 | locale: 'de-DE', 63 | }, 64 | ], 65 | }, 66 | email: {}, 67 | phone_number: {}, 68 | address: { 69 | street_address: {}, 70 | locality: {}, 71 | region: {}, 72 | country: {}, 73 | }, 74 | birthdate: {}, 75 | is_over_18: {}, 76 | is_over_21: {}, 77 | is_over_65: {}, 78 | }, 79 | }) 80 | ).toStrictEqual({ 81 | data: expect.objectContaining({}), 82 | success: true, 83 | }) 84 | }) 85 | -------------------------------------------------------------------------------- /packages/openid4vci/src/formats/credential/sd-jwt-vc/z-sd-jwt-vc.ts: -------------------------------------------------------------------------------- 1 | import z from 'zod' 2 | import { zCredentialConfigurationSupportedClaimsDraft14 } from '../../../metadata/credential-issuer/z-claims-description' 3 | 4 | export const zSdJwtVcFormatIdentifier = z.literal('vc+sd-jwt') 5 | export type SdJwtVcFormatIdentifier = z.infer 6 | 7 | export const zSdJwtVcCredentialIssuerMetadataDraft14 = z.object({ 8 | vct: z.string(), 9 | format: zSdJwtVcFormatIdentifier, 10 | claims: z.optional(zCredentialConfigurationSupportedClaimsDraft14), 11 | order: z.optional(z.array(z.string())), 12 | }) 13 | 14 | export const zSdJwtVcCredentialRequestFormatDraft14 = z.object({ 15 | format: zSdJwtVcFormatIdentifier, 16 | vct: z.string(), 17 | claims: z.optional(zCredentialConfigurationSupportedClaimsDraft14), 18 | }) 19 | -------------------------------------------------------------------------------- /packages/openid4vci/src/formats/credential/w3c-vc/z-w3c-vc-common.ts: -------------------------------------------------------------------------------- 1 | import z from 'zod' 2 | 3 | const zCredentialSubjectLeafTypeDraft14 = z 4 | .object({ 5 | mandatory: z.boolean().optional(), 6 | value_type: z.string().optional(), 7 | display: z 8 | .array( 9 | z 10 | .object({ 11 | name: z.string().optional(), 12 | locale: z.string().optional(), 13 | }) 14 | .passthrough() 15 | ) 16 | .optional(), 17 | }) 18 | .passthrough() 19 | 20 | const zClaimValueSchemaDraft14 = z.union([ 21 | z.array(z.any()), 22 | z.record(z.string(), z.any()), 23 | zCredentialSubjectLeafTypeDraft14, 24 | ]) 25 | 26 | export const zW3cVcCredentialSubjectDraft14 = z.record(z.string(), zClaimValueSchemaDraft14) 27 | 28 | export const zW3cVcJsonLdCredentialDefinition = z 29 | .object({ 30 | '@context': z.array(z.string()), 31 | type: z.array(z.string()), 32 | }) 33 | .passthrough() 34 | 35 | export const zW3cVcJsonLdCredentialDefinitionDraft14 = zW3cVcJsonLdCredentialDefinition.extend({ 36 | credentialSubject: zW3cVcCredentialSubjectDraft14.optional(), 37 | }) 38 | -------------------------------------------------------------------------------- /packages/openid4vci/src/formats/proof-type/attestation/attestation-proof-type.ts: -------------------------------------------------------------------------------- 1 | import { 2 | type CreateKeyAttestationJwtOptions, 3 | type VerifyKeyAttestationJwtOptions, 4 | createKeyAttestationJwt, 5 | verifyKeyAttestationJwt, 6 | } from '../../../key-attestation/key-attestation' 7 | 8 | export interface CreateCredentialRequestAttestationProofOptions extends Omit { 9 | /** 10 | * Nonce to use in the attestation. Should be derived from the c_nonce 11 | * 12 | * Required because the attestation is created for 'attestation' proof types 13 | */ 14 | nonce: string 15 | 16 | /** 17 | * The date when the key attestation will expire. 18 | */ 19 | expiresAt: Date 20 | } 21 | 22 | export async function createCredentialRequestAttestationProof( 23 | options: CreateCredentialRequestAttestationProofOptions 24 | ): Promise { 25 | return createKeyAttestationJwt({ 26 | ...options, 27 | use: 'proof_type.attestation', 28 | }) 29 | } 30 | 31 | export interface VerifyCredentialRequestAttestationProofOptions extends Omit {} 32 | export async function verifyCredentialRequestAttestationProof(options: VerifyCredentialRequestAttestationProofOptions) { 33 | const verificationResult = await verifyKeyAttestationJwt({ 34 | ...options, 35 | use: 'proof_type.attestation', 36 | }) 37 | 38 | return verificationResult 39 | } 40 | -------------------------------------------------------------------------------- /packages/openid4vci/src/formats/proof-type/attestation/z-attestation-proof-type.ts: -------------------------------------------------------------------------------- 1 | import { zCompactJwt } from '@openid4vc/oauth2' 2 | import z from 'zod' 3 | import { 4 | type KeyAttestationJwtHeader, 5 | zKeyAttestationJwtHeader, 6 | zKeyAttestationJwtPayloadForUse, 7 | } from '../../../key-attestation/z-key-attestation' 8 | 9 | export const zAttestationProofTypeIdentifier = z.literal('attestation') 10 | export const attestationProofTypeIdentifier = zAttestationProofTypeIdentifier.value 11 | export type AttestationProofTypeIdentifier = z.infer 12 | 13 | export const zCredentialRequestProofAttestation = z.object({ 14 | proof_type: zAttestationProofTypeIdentifier, 15 | attestation: zCompactJwt, 16 | }) 17 | 18 | export const zCredentialRequestAttestationProofTypeHeader = zKeyAttestationJwtHeader 19 | export type CredentialRequestAttestationProofTypeHeader = KeyAttestationJwtHeader 20 | 21 | export const zCredentialRequestAttestationProofTypePayload = zKeyAttestationJwtPayloadForUse('proof_type.attestation') 22 | export type CredentialRequestAttestationProofTypePayload = z.infer 23 | -------------------------------------------------------------------------------- /packages/openid4vci/src/formats/proof-type/index.ts: -------------------------------------------------------------------------------- 1 | import type { AttestationProofTypeIdentifier } from './attestation/z-attestation-proof-type' 2 | import type { JwtProofTypeIdentifier } from './jwt/z-jwt-proof-type' 3 | 4 | // jwt 5 | export { 6 | type JwtProofTypeIdentifier, 7 | zCredentialRequestProofJwt, 8 | zJwtProofTypeIdentifier, 9 | } from './jwt/z-jwt-proof-type' 10 | 11 | // attestation 12 | export { 13 | type AttestationProofTypeIdentifier, 14 | zCredentialRequestProofAttestation, 15 | zAttestationProofTypeIdentifier, 16 | } from './attestation/z-attestation-proof-type' 17 | 18 | export type ProofTypeIdentifier = JwtProofTypeIdentifier | AttestationProofTypeIdentifier 19 | -------------------------------------------------------------------------------- /packages/openid4vci/src/formats/proof-type/jwt/z-jwt-proof-type.ts: -------------------------------------------------------------------------------- 1 | import { zCompactJwt, zJwtHeader, zJwtPayload } from '@openid4vc/oauth2' 2 | import { zHttpsUrl, zInteger } from '@openid4vc/utils' 3 | import z from 'zod' 4 | 5 | export const zJwtProofTypeIdentifier = z.literal('jwt') 6 | export const jwtProofTypeIdentifier = zJwtProofTypeIdentifier.value 7 | export type JwtProofTypeIdentifier = z.infer 8 | 9 | export const zCredentialRequestProofJwt = z.object({ 10 | proof_type: zJwtProofTypeIdentifier, 11 | jwt: zCompactJwt, 12 | }) 13 | 14 | export const zCredentialRequestJwtProofTypeHeader = zJwtHeader 15 | .merge( 16 | z.object({ 17 | key_attestation: z.optional(zCompactJwt), 18 | typ: z.literal('openid4vci-proof+jwt'), 19 | }) 20 | ) 21 | .passthrough() 22 | .refine(({ kid, jwk }) => jwk === undefined || kid === undefined, { 23 | message: `Both 'jwk' and 'kid' are defined. Only one is allowed`, 24 | }) 25 | .refine(({ trust_chain, kid }) => !trust_chain || !kid, { 26 | message: `When 'trust_chain' is provided, 'kid' is required`, 27 | }) 28 | 29 | export type CredentialRequestJwtProofTypeHeader = z.infer 30 | 31 | export const zCredentialRequestJwtProofTypePayload = z 32 | .object({ 33 | ...zJwtPayload.shape, 34 | aud: zHttpsUrl, 35 | iat: zInteger, 36 | }) 37 | .passthrough() 38 | 39 | export type CredentialRequestJwtProofTypePayload = z.infer 40 | -------------------------------------------------------------------------------- /packages/openid4vci/src/key-attestation/z-key-attestation.ts: -------------------------------------------------------------------------------- 1 | import { zJwk, zJwtHeader, zJwtPayload } from '@openid4vc/oauth2' 2 | import { zInteger } from '@openid4vc/utils' 3 | import z from 'zod' 4 | 5 | export type KeyAttestationJwtUse = 'proof_type.jwt' | 'proof_type.attestation' 6 | 7 | export const zKeyAttestationJwtHeader = z 8 | .object({ 9 | ...zJwtHeader.shape, 10 | typ: z 11 | // Draft 15 12 | .literal('keyattestation+jwt') 13 | .or( 14 | // Draft 16 15 | z.literal('key-attestation+jwt') 16 | ), 17 | }) 18 | .passthrough() 19 | .refine(({ kid, jwk }) => jwk === undefined || kid === undefined, { 20 | message: `Both 'jwk' and 'kid' are defined. Only one is allowed`, 21 | }) 22 | .refine(({ trust_chain, kid }) => !trust_chain || !kid, { 23 | message: `When 'trust_chain' is provided, 'kid' is required`, 24 | }) 25 | 26 | export type KeyAttestationJwtHeader = z.infer 27 | 28 | export const zIso18045 = z.enum(['iso_18045_high', 'iso_18045_moderate', 'iso_18045_enhanced-basic', 'iso_18045_basic']) 29 | 30 | export type Iso18045 = z.infer 31 | export const zIso18045OrStringArray = z.array(z.union([zIso18045, z.string()])) 32 | 33 | export const zKeyAttestationJwtPayload = z 34 | .object({ 35 | ...zJwtPayload.shape, 36 | iat: zInteger, 37 | 38 | attested_keys: z.array(zJwk), 39 | key_storage: z.optional(zIso18045OrStringArray), 40 | user_authentication: z.optional(zIso18045OrStringArray), 41 | certification: z.optional(z.string().url()), 42 | }) 43 | .passthrough() 44 | 45 | export const zKeyAttestationJwtPayloadForUse = (use?: Use) => 46 | z 47 | .object({ 48 | ...zKeyAttestationJwtPayload.shape, 49 | 50 | // REQUIRED when used as proof_type.attesation directly 51 | nonce: 52 | use === 'proof_type.attestation' 53 | ? z.string({ 54 | message: `Nonce must be defined when key attestation is used as 'proof_type.attestation' directly`, 55 | }) 56 | : z.optional(z.string()), 57 | 58 | // REQUIRED when used within header of proof_type.jwt 59 | exp: use === 'proof_type.jwt' ? zInteger : z.optional(zInteger), 60 | }) 61 | .passthrough() 62 | 63 | export type KeyAttestationJwtPayload = z.infer 64 | -------------------------------------------------------------------------------- /packages/openid4vci/src/metadata/credential-issuer/__tests__/z-credential-configurations.test.mts: -------------------------------------------------------------------------------- 1 | import { describe, test } from 'vitest' 2 | import type { CredentialConfigurationSupportedWithFormats } from '../../../index.js' 3 | 4 | describe('Credential Configuration Types', () => { 5 | // This is a type infer test, no actual code test 6 | test('should narrow types for format specific credential configurations', () => { 7 | const credentialConfiguration = { 8 | format: 'vc+sd-jwt', 9 | vct: 'hello', 10 | } as CredentialConfigurationSupportedWithFormats 11 | 12 | if (credentialConfiguration.format === 'vc+sd-jwt') { 13 | const vct: string = credentialConfiguration.vct 14 | } 15 | 16 | if (credentialConfiguration.format === 'jwt_vc_json-ld') { 17 | const context: string[] = credentialConfiguration.credential_definition['@context'] 18 | } 19 | 20 | if (credentialConfiguration.format === 'ldp_vc') { 21 | const context: string[] = credentialConfiguration.credential_definition['@context'] 22 | } 23 | 24 | if (credentialConfiguration.format === 'jwt_vc_json') { 25 | const type: string[] = credentialConfiguration.credential_definition.type 26 | } 27 | 28 | if (credentialConfiguration.format === 'mso_mdoc') { 29 | const doctype: string = credentialConfiguration.doctype 30 | } 31 | }) 32 | }) 33 | -------------------------------------------------------------------------------- /packages/openid4vci/src/metadata/credential-issuer/z-claims-description.ts: -------------------------------------------------------------------------------- 1 | import z from 'zod' 2 | 3 | // Used up to draft 14 4 | export const zCredentialConfigurationSupportedClaimsDraft14 = z 5 | .object({ 6 | mandatory: z.boolean().optional(), 7 | value_type: z.string().optional(), 8 | display: z 9 | .object({ 10 | name: z.string().optional(), 11 | locale: z.string().optional(), 12 | }) 13 | .passthrough() 14 | .optional(), 15 | }) 16 | .passthrough() 17 | 18 | const zClaimsDescriptionPath = z.array(z.union([z.string(), z.number().int().nonnegative(), z.null()])).nonempty() 19 | export type ClaimsDescriptionPath = z.infer 20 | 21 | const zMsoMdocClaimsDescriptionPath = z.tuple([z.string(), z.string()], { 22 | message: 23 | 'mso_mdoc claims description path MUST be an array with exactly two string elements, pointing to the namespace and element identifier within an mdoc credential', 24 | }) 25 | export type MsoMdocClaimsDescriptionPath = z.infer 26 | 27 | export const zIssuerMetadataClaimsDescription = z 28 | .object({ 29 | path: zClaimsDescriptionPath, 30 | mandatory: z.boolean().optional(), 31 | display: z 32 | .array( 33 | z 34 | .object({ 35 | name: z.string().optional(), 36 | locale: z.string().optional(), 37 | }) 38 | .passthrough() 39 | ) 40 | .optional(), 41 | }) 42 | .passthrough() 43 | export type IssuerMetadataClaimsDescription = z.infer 44 | 45 | export const zMsoMdocIssuerMetadataClaimsDescription = zIssuerMetadataClaimsDescription.extend({ 46 | path: zMsoMdocClaimsDescriptionPath, 47 | }) 48 | export type MsoMdocIssuerMetadataClaimsDescription = z.infer 49 | -------------------------------------------------------------------------------- /packages/openid4vci/src/metadata/credential-issuer/z-credential-configuration-supported-common.ts: -------------------------------------------------------------------------------- 1 | import z from 'zod' 2 | import { zIso18045OrStringArray } from '../../key-attestation/z-key-attestation' 3 | 4 | export const zCredentialConfigurationSupportedCommon = z 5 | .object({ 6 | format: z.string(), 7 | scope: z.string().optional(), 8 | cryptographic_binding_methods_supported: z.array(z.string()).optional(), 9 | credential_signing_alg_values_supported: z.array(z.string()).optional(), 10 | proof_types_supported: z 11 | .record( 12 | z.union([z.literal('jwt'), z.literal('attestation'), z.string()]), 13 | z.object({ 14 | proof_signing_alg_values_supported: z.array(z.string()), 15 | key_attestations_required: z 16 | .object({ 17 | key_storage: zIso18045OrStringArray.optional(), 18 | user_authentication: zIso18045OrStringArray.optional(), 19 | }) 20 | .passthrough() 21 | .optional(), 22 | }) 23 | ) 24 | .optional(), 25 | display: z 26 | .array( 27 | z 28 | .object({ 29 | name: z.string(), 30 | locale: z.string().optional(), 31 | logo: z 32 | .object({ 33 | // FIXME: make required again, but need to support draft 11 first 34 | uri: z.string().optional(), 35 | alt_text: z.string().optional(), 36 | }) 37 | .passthrough() 38 | .optional(), 39 | description: z.string().optional(), 40 | background_color: z.string().optional(), 41 | background_image: z 42 | .object({ 43 | // TODO: should be required, but paradym's metadata is wrong here. 44 | uri: z.string().optional(), 45 | }) 46 | .passthrough() 47 | .optional(), 48 | text_color: z.string().optional(), 49 | }) 50 | .passthrough() 51 | ) 52 | .optional(), 53 | }) 54 | .passthrough() 55 | -------------------------------------------------------------------------------- /packages/openid4vci/src/nonce/nonce-request.ts: -------------------------------------------------------------------------------- 1 | import { InvalidFetchResponseError } from '@openid4vc/oauth2' 2 | import { ContentType, type Fetch, ValidationError, createZodFetcher, parseWithErrorHandling } from '@openid4vc/utils' 3 | import { Openid4vciError } from '../error/Openid4vciError' 4 | import type { IssuerMetadataResult } from '../metadata/fetch-issuer-metadata' 5 | import { type NonceResponse, zNonceResponse } from './z-nonce' 6 | 7 | export interface RequestNonceOptions { 8 | issuerMetadata: IssuerMetadataResult 9 | 10 | /** 11 | * Custom fetch implementation to use 12 | */ 13 | fetch?: Fetch 14 | } 15 | 16 | /** 17 | * Request a nonce from the `nonce_endpoint` 18 | * 19 | * @throws Openid4vciError - if no `nonce_endpoint` is configured in the issuer metadata 20 | * @thrwos InvalidFetchResponseError - if the nonce endpoint did not return a succesfull response 21 | * @throws ValidationError - if validating the nonce response failed 22 | */ 23 | export async function requestNonce(options: RequestNonceOptions): Promise { 24 | const fetchWithZod = createZodFetcher(options?.fetch) 25 | const nonceEndpoint = options.issuerMetadata.credentialIssuer.nonce_endpoint 26 | 27 | if (!nonceEndpoint) { 28 | throw new Openid4vciError( 29 | `Credential issuer '${options.issuerMetadata.credentialIssuer.credential_issuer}' does not have a nonce endpoint.` 30 | ) 31 | } 32 | 33 | const { response, result } = await fetchWithZod(zNonceResponse, ContentType.Json, nonceEndpoint, { 34 | method: 'POST', 35 | }) 36 | 37 | if (!response.ok || !result) { 38 | throw new InvalidFetchResponseError( 39 | `Requesting nonce from '${nonceEndpoint}' resulted in an unsuccesfull response with status '${response.status}'`, 40 | await response.clone().text(), 41 | response 42 | ) 43 | } 44 | 45 | if (!result.success) { 46 | throw new ValidationError('Error parsing nonce response', result.error) 47 | } 48 | 49 | return result.data 50 | } 51 | 52 | export interface CreateNonceResponseOptions { 53 | cNonce: string 54 | cNonceExpiresIn?: number 55 | 56 | /** 57 | * Additional payload to include in the nonce response. 58 | * 59 | * Will be applied after default params to allow extension so be cautious 60 | */ 61 | additionalPayload?: Record 62 | } 63 | 64 | export function createNonceResponse(options: CreateNonceResponseOptions) { 65 | return parseWithErrorHandling(zNonceResponse, { 66 | c_nonce: options.cNonce, 67 | c_nonce_expires_in: options.cNonceExpiresIn, 68 | ...options.additionalPayload, 69 | } satisfies NonceResponse) 70 | } 71 | -------------------------------------------------------------------------------- /packages/openid4vci/src/nonce/z-nonce.ts: -------------------------------------------------------------------------------- 1 | import { zInteger } from '@openid4vc/utils' 2 | import z from 'zod' 3 | 4 | export const zNonceResponse = z 5 | .object({ 6 | c_nonce: z.string(), 7 | c_nonce_expires_in: z.optional(zInteger), 8 | }) 9 | .passthrough() 10 | export type NonceResponse = z.infer 11 | -------------------------------------------------------------------------------- /packages/openid4vci/src/notification/z-notification.ts: -------------------------------------------------------------------------------- 1 | import z from 'zod' 2 | 3 | const zNotificationEvent = z.enum(['credential_accepted', 'credential_failure', 'credential_deleted']) 4 | export type NotificationEvent = z.infer 5 | 6 | export const zNotificationRequest = z 7 | .object({ 8 | notification_id: z.string(), 9 | event: zNotificationEvent, 10 | event_description: z.optional(z.string()), 11 | }) 12 | .passthrough() 13 | 14 | export type NotificationRequest = z.infer 15 | 16 | export const zNotificationErrorResponse = z 17 | .object({ 18 | error: z.enum(['invalid_notification_id', 'invalid_notification_request']), 19 | }) 20 | .passthrough() 21 | export type NotificationErrorResponse = z.infer 22 | -------------------------------------------------------------------------------- /packages/openid4vci/src/version.ts: -------------------------------------------------------------------------------- 1 | export enum Openid4vciDraftVersion { 2 | Draft15 = 'Draft15', 3 | Draft14 = 'Draft14', 4 | Draft11 = 'Draft11', 5 | } 6 | -------------------------------------------------------------------------------- /packages/openid4vci/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "build" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /packages/openid4vp/Readme.md: -------------------------------------------------------------------------------- 1 |

OpenID for Verifiable Presentations - OpenID4VP

2 | 3 |

4 | 5 | 6 | 7 |

8 | 9 | Check out the documentation in the [Github repository](https://github.com/openwallet-foundation-labs/oid4vc-ts). 10 | -------------------------------------------------------------------------------- /packages/openid4vp/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@openid4vc/openid4vp", 3 | "version": "0.2.0", 4 | "files": ["dist"], 5 | "license": "Apache-2.0", 6 | "exports": "./src/index.ts", 7 | "homepage": "https://github.com/openwallet-foundation-labs/oid4vc-ts/tree/main/packages/openid4vp", 8 | "repository": { 9 | "type": "git", 10 | "url": "https://github.com/openwallet-foundation-labs/oid4vc-ts", 11 | "directory": "packages/openid4vp" 12 | }, 13 | "publishConfig": { 14 | "main": "./dist/index.js", 15 | "module": "./dist/index.mjs", 16 | "types": "./dist/index.d.ts", 17 | "exports": { 18 | ".": { 19 | "import": "./dist/index.mjs", 20 | "require": "./dist/index.js", 21 | "types": "./dist/index.d.ts" 22 | }, 23 | "./package.json": "./package.json" 24 | } 25 | }, 26 | "scripts": { 27 | "build": "tsup src/index.ts --format cjs,esm --dts --clean --sourcemap" 28 | }, 29 | "dependencies": { 30 | "@openid4vc/oauth2": "workspace:*", 31 | "@openid4vc/utils": "workspace:*", 32 | "zod": "catalog:" 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /packages/openid4vp/src/Openid4vpClient.ts: -------------------------------------------------------------------------------- 1 | import type { CallbackContext } from '@openid4vc/oauth2' 2 | import {} from './authorization-request/create-authorization-request' 3 | import { parseOpenid4vpAuthorizationRequest } from './authorization-request/parse-authorization-request-params' 4 | import type { ParseOpenid4vpAuthorizationRequestOptions } from './authorization-request/parse-authorization-request-params' 5 | import { 6 | type ResolveOpenid4vpAuthorizationRequestOptions, 7 | resolveOpenid4vpAuthorizationRequest, 8 | } from './authorization-request/resolve-authorization-request' 9 | import { 10 | type CreateOpenid4vpAuthorizationResponseOptions, 11 | createOpenid4vpAuthorizationResponse, 12 | } from './authorization-response/create-authorization-response' 13 | import { 14 | type SubmitOpenid4vpAuthorizationResponseOptions, 15 | submitOpenid4vpAuthorizationResponse, 16 | } from './authorization-response/submit-authorization-response' 17 | 18 | export interface Openid4vpClientOptions { 19 | /** 20 | * Callbacks required for the openid4vp client 21 | */ 22 | callbacks: Omit 23 | } 24 | 25 | export class Openid4vpClient { 26 | public constructor(private options: Openid4vpClientOptions) {} 27 | 28 | public parseOpenid4vpAuthorizationRequest(options: ParseOpenid4vpAuthorizationRequestOptions) { 29 | return parseOpenid4vpAuthorizationRequest(options) 30 | } 31 | 32 | public async resolveOpenId4vpAuthorizationRequest( 33 | options: Omit 34 | ) { 35 | return resolveOpenid4vpAuthorizationRequest({ ...options, callbacks: this.options.callbacks }) 36 | } 37 | 38 | public async createOpenid4vpAuthorizationResponse( 39 | options: Omit 40 | ) { 41 | return createOpenid4vpAuthorizationResponse({ ...options, callbacks: this.options.callbacks }) 42 | } 43 | 44 | public async submitOpenid4vpAuthorizationResponse( 45 | options: Omit 46 | ) { 47 | return submitOpenid4vpAuthorizationResponse({ ...options, callbacks: this.options.callbacks }) 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /packages/openid4vp/src/Openid4vpVerifier.ts: -------------------------------------------------------------------------------- 1 | import type { CallbackContext } from '@openid4vc/oauth2' 2 | import { 3 | type CreateOpenid4vpAuthorizationRequestOptions, 4 | createOpenid4vpAuthorizationRequest, 5 | } from './authorization-request/create-authorization-request' 6 | import { 7 | type ParseOpenid4vpAuthorizationRequestOptions, 8 | parseOpenid4vpAuthorizationRequest, 9 | } from './authorization-request/parse-authorization-request-params' 10 | import { 11 | type ParseOpenid4vpAuthorizationResponseOptions, 12 | parseOpenid4vpAuthorizationResponse, 13 | } from './authorization-response/parse-authorization-response' 14 | import { 15 | type ValidateOpenid4vpAuthorizationResponseOptions, 16 | validateOpenid4vpAuthorizationResponsePayload, 17 | } from './authorization-response/validate-authorization-response' 18 | import type { ParseTransactionDataOptions } from './transaction-data/parse-transaction-data' 19 | import { parseTransactionData } from './transaction-data/parse-transaction-data' 20 | import { type VerifyTransactionDataOptions, verifyTransactionData } from './transaction-data/verify-transaction-data' 21 | import { parseDcqlVpToken, parsePexVpToken } from './vp-token/parse-vp-token' 22 | 23 | export interface Openid4vpVerifierOptions { 24 | /** 25 | * Callbacks required for the openid4vp verifier 26 | */ 27 | callbacks: Omit 28 | } 29 | 30 | export class Openid4vpVerifier { 31 | public constructor(private options: Openid4vpVerifierOptions) {} 32 | 33 | public async createOpenId4vpAuthorizationRequest( 34 | options: Omit 35 | ) { 36 | return createOpenid4vpAuthorizationRequest({ ...options, callbacks: this.options.callbacks }) 37 | } 38 | 39 | public parseOpenid4vpAuthorizationRequestPayload(options: ParseOpenid4vpAuthorizationRequestOptions) { 40 | return parseOpenid4vpAuthorizationRequest(options) 41 | } 42 | 43 | public parseOpenid4vpAuthorizationResponse(options: ParseOpenid4vpAuthorizationResponseOptions) { 44 | return parseOpenid4vpAuthorizationResponse(options) 45 | } 46 | 47 | public validateOpenid4vpAuthorizationResponsePayload(options: ValidateOpenid4vpAuthorizationResponseOptions) { 48 | return validateOpenid4vpAuthorizationResponsePayload(options) 49 | } 50 | 51 | public parsePexVpToken(vpToken: unknown) { 52 | return parsePexVpToken(vpToken) 53 | } 54 | 55 | public parseDcqlVpToken(vpToken: unknown) { 56 | return parseDcqlVpToken(vpToken) 57 | } 58 | 59 | public parseTransactionData(options: ParseTransactionDataOptions) { 60 | return parseTransactionData(options) 61 | } 62 | 63 | public verifyTransactionData(options: Omit) { 64 | return verifyTransactionData({ 65 | ...options, 66 | callbacks: this.options.callbacks, 67 | }) 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /packages/openid4vp/src/authorization-request/__tests__/parse-authorization-request-params.test.mts: -------------------------------------------------------------------------------- 1 | import { describe, expect, test } from 'vitest' 2 | import { parseOpenid4vpAuthorizationRequest } from '../parse-authorization-request-params.js' 3 | 4 | describe('Parse Authorization Request Params', () => { 5 | test('parse authorization request uri and transforms string JSON fields to JSON', () => { 6 | expect( 7 | parseOpenid4vpAuthorizationRequest({ 8 | authorizationRequest: `openid4vp://?client_id=test&presentation_definition=${encodeURIComponent(JSON.stringify({ id: 'something' }))}&dcql_query=${encodeURIComponent(JSON.stringify({ id: 'something' }))}&client_metadata=${encodeURIComponent(JSON.stringify({ my: 'metadata' }))}&transaction_data=${encodeURIComponent(JSON.stringify(['something']))}&verifier_attestations=${encodeURIComponent(JSON.stringify([{ format: 'custom', data: 'none', credential_ids: ['my-id'] }]))}`, 9 | }) 10 | ).toEqual({ 11 | type: 'openid4vp', 12 | provided: 'uri', 13 | params: { 14 | client_id: 'test', 15 | presentation_definition: { id: 'something' }, 16 | client_metadata: { my: 'metadata' }, 17 | dcql_query: { id: 'something' }, 18 | transaction_data: ['something'], 19 | verifier_attestations: [ 20 | { 21 | format: 'custom', 22 | data: 'none', 23 | credential_ids: ['my-id'], 24 | }, 25 | ], 26 | }, 27 | }) 28 | }) 29 | }) 30 | -------------------------------------------------------------------------------- /packages/openid4vp/src/authorization-request/parse-authorization-request-params.ts: -------------------------------------------------------------------------------- 1 | import { decodeJwt } from '@openid4vc/oauth2' 2 | import { parseWithErrorHandling } from '@openid4vc/utils' 3 | import z from 'zod' 4 | import { 5 | type JarAuthorizationRequest, 6 | isJarAuthorizationRequest, 7 | zJarAuthorizationRequest, 8 | } from '../jar/z-jar-authorization-request' 9 | import { 10 | type Openid4vpAuthorizationRequest, 11 | zOpenid4vpAuthorizationRequest, 12 | zOpenid4vpAuthorizationRequestFromUriParams, 13 | } from './z-authorization-request' 14 | import { 15 | type Openid4vpAuthorizationRequestDcApi, 16 | isOpenid4vpAuthorizationRequestDcApi, 17 | zOpenid4vpAuthorizationRequestDcApi, 18 | } from './z-authorization-request-dc-api' 19 | 20 | export interface ParsedJarRequest { 21 | type: 'jar' 22 | provided: 'uri' | 'jwt' | 'params' 23 | params: JarAuthorizationRequest 24 | } 25 | 26 | export interface ParsedOpenid4vpAuthorizationRequest { 27 | type: 'openid4vp' 28 | provided: 'uri' | 'jwt' | 'params' 29 | params: Openid4vpAuthorizationRequest 30 | } 31 | 32 | export interface ParsedOpenid4vpDcApiAuthorizationRequest { 33 | type: 'openid4vp_dc_api' 34 | provided: 'uri' | 'jwt' | 'params' 35 | params: Openid4vpAuthorizationRequestDcApi 36 | } 37 | 38 | export interface ParseOpenid4vpAuthorizationRequestOptions { 39 | authorizationRequest: string | Record 40 | } 41 | 42 | export function parseOpenid4vpAuthorizationRequest( 43 | options: ParseOpenid4vpAuthorizationRequestOptions 44 | ): ParsedOpenid4vpAuthorizationRequest | ParsedJarRequest | ParsedOpenid4vpDcApiAuthorizationRequest { 45 | const { authorizationRequest } = options 46 | let provided: 'uri' | 'jwt' | 'params' = 'params' 47 | 48 | let params: Record 49 | if (typeof authorizationRequest === 'string') { 50 | // JWT will never contain : 51 | if (authorizationRequest.includes(':')) { 52 | params = parseWithErrorHandling( 53 | zOpenid4vpAuthorizationRequestFromUriParams, 54 | authorizationRequest, 55 | 'Unable to parse openid4vp authorization request uri to a valid object' 56 | ) 57 | provided = 'uri' 58 | } else { 59 | const decoded = decodeJwt({ jwt: authorizationRequest }) 60 | params = decoded.payload 61 | provided = 'jwt' 62 | } 63 | } else { 64 | params = authorizationRequest 65 | } 66 | 67 | const parsedRequest = parseWithErrorHandling( 68 | z.union([zOpenid4vpAuthorizationRequest, zJarAuthorizationRequest, zOpenid4vpAuthorizationRequestDcApi]), 69 | params 70 | ) 71 | 72 | if (isJarAuthorizationRequest(parsedRequest)) { 73 | return { 74 | type: 'jar', 75 | provided, 76 | params: parsedRequest, 77 | } 78 | } 79 | 80 | if (isOpenid4vpAuthorizationRequestDcApi(parsedRequest)) { 81 | return { 82 | type: 'openid4vp_dc_api', 83 | provided, 84 | params: parsedRequest, 85 | } 86 | } 87 | 88 | return { 89 | type: 'openid4vp', 90 | provided, 91 | params: parsedRequest, 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /packages/openid4vp/src/authorization-request/validate-authorization-request-dc-api.ts: -------------------------------------------------------------------------------- 1 | import { Oauth2ErrorCodes, Oauth2ServerErrorResponseError } from '@openid4vc/oauth2' 2 | import type { Openid4vpAuthorizationRequestDcApi } from './z-authorization-request-dc-api' 3 | 4 | export interface ValidateOpenid4vpAuthorizationRequestDcApiPayloadOptions { 5 | params: Openid4vpAuthorizationRequestDcApi 6 | isJarRequest: boolean 7 | disableOriginValidation?: boolean 8 | origin?: string 9 | } 10 | 11 | /** 12 | * Validate the OpenId4Vp Authorization Request parameters for the dc_api response mode 13 | */ 14 | export const validateOpenid4vpAuthorizationRequestDcApiPayload = ( 15 | options: ValidateOpenid4vpAuthorizationRequestDcApiPayloadOptions 16 | ) => { 17 | const { params, isJarRequest, disableOriginValidation, origin } = options 18 | 19 | if (isJarRequest && !params.expected_origins) { 20 | throw new Oauth2ServerErrorResponseError({ 21 | error: Oauth2ErrorCodes.InvalidRequest, 22 | error_description: `The 'expected_origins' parameter MUST be present when using the dc_api response mode in combinaction with jar.`, 23 | }) 24 | } 25 | 26 | if ([params.presentation_definition, params.dcql_query].filter(Boolean).length !== 1) { 27 | throw new Oauth2ServerErrorResponseError({ 28 | error: Oauth2ErrorCodes.InvalidRequest, 29 | error_description: 30 | 'Exactly one of the following parameters MUST be present in the Authorization Request: dcql_query or presentation_definition', 31 | }) 32 | } 33 | 34 | if (params.expected_origins && !disableOriginValidation) { 35 | if (!origin) { 36 | throw new Oauth2ServerErrorResponseError({ 37 | error: Oauth2ErrorCodes.InvalidRequest, 38 | error_description: `Failed to validate the 'origin' of the authorization request. The 'origin' was not provided.`, 39 | }) 40 | } 41 | 42 | if (params.expected_origins && !params.expected_origins.includes(origin)) { 43 | throw new Oauth2ServerErrorResponseError({ 44 | error: Oauth2ErrorCodes.InvalidRequest, 45 | error_description: `The 'expected_origins' parameter MUST include the origin of the authorization request. Current: ${params.expected_origins.join(', ')}`, 46 | }) 47 | } 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /packages/openid4vp/src/authorization-request/z-authorization-request-dc-api.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod' 2 | import type { JarAuthorizationRequest } from '../jar/z-jar-authorization-request' 3 | import { type Openid4vpAuthorizationRequest, zOpenid4vpAuthorizationRequest } from './z-authorization-request' 4 | 5 | const zOpenid4vpResponseModeDcApi = z.enum(['dc_api', 'dc_api.jwt', 'w3c_dc_api.jwt', 'w3c_dc_api']) 6 | export const zOpenid4vpAuthorizationRequestDcApi = zOpenid4vpAuthorizationRequest 7 | .pick({ 8 | response_type: true, 9 | nonce: true, 10 | presentation_definition: true, 11 | client_metadata: true, 12 | transaction_data: true, 13 | dcql_query: true, 14 | trust_chain: true, 15 | state: true, 16 | verifier_attestations: true, 17 | }) 18 | .extend({ 19 | client_id: z.optional(z.string()), 20 | expected_origins: z.array(z.string()).optional(), 21 | response_mode: zOpenid4vpResponseModeDcApi, 22 | 23 | // Not allowed with dc_api, but added to make working with interfaces easier 24 | client_id_scheme: z.never().optional(), 25 | scope: z.never().optional(), 26 | 27 | // TODO: should we disallow any properties specifically, such as redirect_uri and response_uri? 28 | }) 29 | 30 | export type Openid4vpAuthorizationRequestDcApi = z.infer 31 | 32 | export function isOpenid4vpResponseModeDcApi( 33 | responseMode: unknown 34 | ): responseMode is Openid4vpAuthorizationRequestDcApi['response_mode'] { 35 | return ( 36 | responseMode !== undefined && 37 | zOpenid4vpResponseModeDcApi.options.includes(responseMode as Openid4vpAuthorizationRequestDcApi['response_mode']) 38 | ) 39 | } 40 | 41 | export function isOpenid4vpAuthorizationRequestDcApi( 42 | request: Openid4vpAuthorizationRequest | Openid4vpAuthorizationRequestDcApi | JarAuthorizationRequest 43 | ): request is Openid4vpAuthorizationRequestDcApi { 44 | return isOpenid4vpResponseModeDcApi(request.response_mode) 45 | } 46 | -------------------------------------------------------------------------------- /packages/openid4vp/src/authorization-request/z-authorization-request.ts: -------------------------------------------------------------------------------- 1 | import { URL, zHttpsUrl, zStringToJson } from '@openid4vc/utils' 2 | import { z } from 'zod' 3 | import { zClientMetadata } from '../models/z-client-metadata' 4 | import { zVerifierAttestations } from '../models/z-verifier-attestations' 5 | 6 | export const zOpenid4vpAuthorizationRequest = z 7 | .object({ 8 | response_type: z.literal('vp_token'), 9 | client_id: z.string(), 10 | redirect_uri: zHttpsUrl.optional(), 11 | response_uri: zHttpsUrl.optional(), 12 | request_uri: zHttpsUrl.optional(), 13 | request_uri_method: z.optional(z.string()), 14 | response_mode: z.enum(['direct_post', 'direct_post.jwt']).optional(), 15 | nonce: z.string(), 16 | wallet_nonce: z.string().optional(), 17 | scope: z.string().optional(), 18 | presentation_definition: z 19 | .record(z.any()) 20 | // for backwards compat 21 | .or(zStringToJson) 22 | .optional(), 23 | presentation_definition_uri: zHttpsUrl.optional(), 24 | dcql_query: z 25 | .record(z.any()) 26 | // for backwards compat 27 | .or(zStringToJson) 28 | .optional(), 29 | client_metadata: zClientMetadata.optional(), 30 | client_metadata_uri: zHttpsUrl.optional(), 31 | state: z.string().optional(), 32 | transaction_data: z.array(z.string().base64url()).optional(), 33 | trust_chain: z.array(z.string()).nonempty().optional(), 34 | client_id_scheme: z 35 | .enum([ 36 | 'pre-registered', 37 | 'redirect_uri', 38 | 'entity_id', 39 | 'did', 40 | 'verifier_attestation', 41 | 'x509_san_dns', 42 | 'x509_san_uri', 43 | ]) 44 | .optional(), 45 | verifier_attestations: zVerifierAttestations.optional(), 46 | }) 47 | .passthrough() 48 | 49 | // Helps with parsing from an URI to a valid authorization request object 50 | export const zOpenid4vpAuthorizationRequestFromUriParams = z 51 | .string() 52 | .url() 53 | .transform((url) => Object.fromEntries(new URL(url).searchParams)) 54 | .pipe( 55 | z 56 | .object({ 57 | presentation_definition: zStringToJson.optional(), 58 | client_metadata: zStringToJson.optional(), 59 | dcql_query: zStringToJson.optional(), 60 | transaction_data: zStringToJson.optional(), 61 | verifier_attestations: zStringToJson.optional(), 62 | }) 63 | .passthrough() 64 | ) 65 | 66 | export type Openid4vpAuthorizationRequest = z.infer 67 | -------------------------------------------------------------------------------- /packages/openid4vp/src/authorization-response/parse-authorization-response-payload.ts: -------------------------------------------------------------------------------- 1 | import { parseWithErrorHandling } from '@openid4vc/utils' 2 | import { zOpenid4vpAuthorizationResponse } from './z-authorization-response' 3 | 4 | export function parseOpenid4VpAuthorizationResponsePayload(payload: Record) { 5 | return parseWithErrorHandling( 6 | zOpenid4vpAuthorizationResponse, 7 | payload, 8 | 'Failed to parse openid4vp authorization response.' 9 | ) 10 | } 11 | -------------------------------------------------------------------------------- /packages/openid4vp/src/authorization-response/parse-jarm-authorization-response.ts: -------------------------------------------------------------------------------- 1 | import { type CallbackContext, Oauth2Error, decodeJwtHeader, zCompactJwe, zCompactJwt } from '@openid4vc/oauth2' 2 | import { parseWithErrorHandling } from '@openid4vc/utils' 3 | import z from 'zod' 4 | import type { Openid4vpAuthorizationRequest } from '../authorization-request/z-authorization-request' 5 | import type { Openid4vpAuthorizationRequestDcApi } from '../authorization-request/z-authorization-request-dc-api' 6 | import { verifyJarmAuthorizationResponse } from '../jarm/jarm-authorization-response/verify-jarm-authorization-response' 7 | import { zJarmHeader } from '../jarm/jarm-authorization-response/z-jarm-authorization-response' 8 | import { isJarmResponseMode } from '../jarm/jarm-response-mode' 9 | import type { ParsedOpenid4vpAuthorizationResponse } from './parse-authorization-response' 10 | import { parseOpenid4VpAuthorizationResponsePayload } from './parse-authorization-response-payload' 11 | import { validateOpenid4vpAuthorizationResponsePayload } from './validate-authorization-response' 12 | 13 | export interface ParseJarmAuthorizationResponseOptions { 14 | jarmResponseJwt: string 15 | authorizationRequestPayload: Openid4vpAuthorizationRequest | Openid4vpAuthorizationRequestDcApi 16 | callbacks: Pick 17 | 18 | expectedClientId: string 19 | } 20 | 21 | export async function parseJarmAuthorizationResponse( 22 | options: ParseJarmAuthorizationResponseOptions 23 | ): Promise { 24 | const { jarmResponseJwt, callbacks, authorizationRequestPayload, expectedClientId } = options 25 | 26 | const jarmAuthorizationResponseJwt = parseWithErrorHandling( 27 | z.union([zCompactJwt, zCompactJwe]), 28 | jarmResponseJwt, 29 | 'Invalid jarm authorization response jwt.' 30 | ) 31 | 32 | const verifiedJarmResponse = await verifyJarmAuthorizationResponse({ 33 | jarmAuthorizationResponseJwt, 34 | callbacks, 35 | expectedClientId, 36 | authorizationRequestPayload, 37 | }) 38 | 39 | const { header: jarmHeader } = decodeJwtHeader({ 40 | jwt: jarmAuthorizationResponseJwt, 41 | headerSchema: zJarmHeader, 42 | }) 43 | 44 | const authorizationResponsePayload = parseOpenid4VpAuthorizationResponsePayload( 45 | verifiedJarmResponse.jarmAuthorizationResponse 46 | ) 47 | const validateOpenId4vpResponse = validateOpenid4vpAuthorizationResponsePayload({ 48 | authorizationRequestPayload: authorizationRequestPayload, 49 | authorizationResponsePayload: authorizationResponsePayload, 50 | }) 51 | 52 | if (!authorizationRequestPayload.response_mode || !isJarmResponseMode(authorizationRequestPayload.response_mode)) { 53 | throw new Oauth2Error( 54 | `Invalid response mode for jarm response. Response mode: '${authorizationRequestPayload.response_mode ?? 'fragment'}'` 55 | ) 56 | } 57 | 58 | return { 59 | ...validateOpenId4vpResponse, 60 | jarm: { ...verifiedJarmResponse, jarmHeader }, 61 | 62 | expectedNonce: authorizationRequestPayload.nonce, 63 | authorizationResponsePayload, 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /packages/openid4vp/src/authorization-response/submit-authorization-response.ts: -------------------------------------------------------------------------------- 1 | import { type CallbackContext, Oauth2Error } from '@openid4vc/oauth2' 2 | import { ContentType, createFetcher } from '@openid4vc/utils' 3 | import { objectToQueryParams } from '@openid4vc/utils' 4 | import type { Openid4vpAuthorizationRequest } from '../authorization-request/z-authorization-request' 5 | import { jarmAuthorizationResponseSend } from '../jarm/jarm-authorizatino-response-send' 6 | import type { Openid4vpAuthorizationResponse } from './z-authorization-response' 7 | 8 | export interface SubmitOpenid4vpAuthorizationResponseOptions { 9 | authorizationRequestPayload: Pick 10 | authorizationResponsePayload: Openid4vpAuthorizationResponse 11 | jarm?: { responseJwt: string } 12 | callbacks: Pick 13 | } 14 | 15 | export async function submitOpenid4vpAuthorizationResponse(options: SubmitOpenid4vpAuthorizationResponseOptions) { 16 | const { authorizationRequestPayload, authorizationResponsePayload, jarm, callbacks } = options 17 | const url = authorizationRequestPayload.response_uri 18 | 19 | if (jarm) { 20 | return jarmAuthorizationResponseSend({ 21 | authorizationRequestPayload, 22 | jarmAuthorizationResponseJwt: jarm.responseJwt, 23 | callbacks, 24 | }) 25 | } 26 | 27 | if (!url) { 28 | throw new Oauth2Error( 29 | 'Failed to submit OpenId4Vp Authorization Response. No redirect_uri or response_uri provided.' 30 | ) 31 | } 32 | 33 | const fetch = createFetcher(callbacks.fetch) 34 | const encodedResponse = objectToQueryParams(authorizationResponsePayload) 35 | const submissionResponse = await fetch(url, { 36 | method: 'POST', 37 | body: encodedResponse.toString(), 38 | headers: { 39 | 'Content-Type': ContentType.XWwwFormUrlencoded, 40 | }, 41 | }) 42 | 43 | return { 44 | responseMode: 'direct_post', 45 | response: submissionResponse, 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /packages/openid4vp/src/authorization-response/validate-authorization-response-result.ts: -------------------------------------------------------------------------------- 1 | import type { PexPresentationSubmission } from '../models/z-pex' 2 | import type { VpTokenDcql, VpTokenPexEntry } from '../vp-token/z-vp-token' 3 | 4 | export interface ValidateOpenid4VpPexAuthorizationResponseResult { 5 | type: 'pex' 6 | 7 | pex: { 8 | presentationSubmission: PexPresentationSubmission 9 | presentations: [VpTokenPexEntry, ...VpTokenPexEntry[]] 10 | } & ( 11 | | { scope: string; presentationDefinition?: never } 12 | | { scope?: never; presentationDefinition: Record | string } 13 | ) 14 | } 15 | 16 | export interface ValidateOpenid4VpDcqlAuthorizationResponseResult { 17 | type: 'dcql' 18 | dcql: { 19 | presentations: VpTokenDcql 20 | } & ({ scope: string; query?: never } | { scope?: never; query: unknown }) 21 | } 22 | 23 | export type ValidateOpenid4VpAuthorizationResponseResult = 24 | | ValidateOpenid4VpPexAuthorizationResponseResult 25 | | ValidateOpenid4VpDcqlAuthorizationResponseResult 26 | -------------------------------------------------------------------------------- /packages/openid4vp/src/authorization-response/validate-authorization-response.ts: -------------------------------------------------------------------------------- 1 | import { Oauth2Error } from '@openid4vc/oauth2' 2 | import type { Openid4vpAuthorizationRequest } from '../authorization-request/z-authorization-request' 3 | import type { Openid4vpAuthorizationRequestDcApi } from '../authorization-request/z-authorization-request-dc-api' 4 | import { parseDcqlVpToken, parsePexVpToken } from '../vp-token/parse-vp-token' 5 | import type { ValidateOpenid4VpAuthorizationResponseResult } from './validate-authorization-response-result' 6 | import type { Openid4vpAuthorizationResponse } from './z-authorization-response' 7 | 8 | export interface ValidateOpenid4vpAuthorizationResponseOptions { 9 | authorizationRequestPayload: Openid4vpAuthorizationRequest | Openid4vpAuthorizationRequestDcApi 10 | authorizationResponsePayload: Openid4vpAuthorizationResponse 11 | } 12 | 13 | /** 14 | * The following steps need to be performed outside of this library 15 | * - verifying the presentations 16 | * - validating the presentations against the presentation definition 17 | * - checking the revocation status of the presentations 18 | * - checking the nonce of the presentations matches the nonce of the request (for mdoc's) 19 | */ 20 | export function validateOpenid4vpAuthorizationResponsePayload( 21 | options: ValidateOpenid4vpAuthorizationResponseOptions 22 | ): ValidateOpenid4VpAuthorizationResponseResult { 23 | const { authorizationRequestPayload, authorizationResponsePayload } = options 24 | 25 | if (authorizationRequestPayload.state && authorizationRequestPayload.state !== authorizationResponsePayload.state) { 26 | throw new Oauth2Error('OpenId4Vp Authorization Response state mismatch.') 27 | } 28 | 29 | // TODO: implement id_token handling 30 | if (authorizationResponsePayload.id_token) { 31 | throw new Oauth2Error('OpenId4Vp Authorization Response id_token is not supported.') 32 | } 33 | 34 | if (authorizationResponsePayload.presentation_submission) { 35 | if (!authorizationRequestPayload.presentation_definition) { 36 | throw new Oauth2Error('OpenId4Vp Authorization Request is missing the required presentation_definition.') 37 | } 38 | 39 | return { 40 | type: 'pex', 41 | pex: authorizationRequestPayload.scope 42 | ? { 43 | scope: authorizationRequestPayload.scope, 44 | presentationSubmission: authorizationResponsePayload.presentation_submission, 45 | presentations: parsePexVpToken(authorizationResponsePayload.vp_token), 46 | } 47 | : { 48 | presentationDefinition: authorizationRequestPayload.presentation_definition, 49 | presentationSubmission: authorizationResponsePayload.presentation_submission, 50 | presentations: parsePexVpToken(authorizationResponsePayload.vp_token), 51 | }, 52 | } 53 | } 54 | 55 | if (authorizationRequestPayload.dcql_query) { 56 | const presentations = parseDcqlVpToken(authorizationResponsePayload.vp_token) 57 | 58 | return { 59 | type: 'dcql', 60 | dcql: authorizationRequestPayload.scope 61 | ? { 62 | scope: authorizationRequestPayload.scope, 63 | presentations, 64 | } 65 | : { 66 | query: authorizationRequestPayload.dcql_query, 67 | presentations, 68 | }, 69 | } 70 | } 71 | 72 | throw new Oauth2Error( 73 | 'Invalid OpenId4Vp Authorization Response. Response neither contains a presentation_submission nor request contains a dcql_query.' 74 | ) 75 | } 76 | -------------------------------------------------------------------------------- /packages/openid4vp/src/authorization-response/z-authorization-response.ts: -------------------------------------------------------------------------------- 1 | import { zStringToJson } from '@openid4vc/utils' 2 | import { z } from 'zod' 3 | import { zPexPresentationSubmission } from '../models/z-pex' 4 | import { zVpToken } from '../vp-token/z-vp-token' 5 | 6 | export const zOpenid4vpAuthorizationResponse = z 7 | .object({ 8 | state: z.string().optional(), 9 | id_token: z.string().optional(), 10 | vp_token: zVpToken, 11 | presentation_submission: zPexPresentationSubmission.or(zStringToJson).optional(), 12 | refresh_token: z.string().optional(), 13 | token_type: z.string().optional(), 14 | access_token: z.string().optional(), 15 | expires_in: z.number().optional(), 16 | }) 17 | .passthrough() 18 | export type Openid4vpAuthorizationResponse = z.infer 19 | -------------------------------------------------------------------------------- /packages/openid4vp/src/client-identifier-scheme/validate-verifier-attestation-jwt.ts: -------------------------------------------------------------------------------- 1 | import { 2 | type CallbackContext, 3 | type Jwk, 4 | type JwtSigner, 5 | Oauth2Error, 6 | decodeJwt, 7 | verifyJwt, 8 | zJwk, 9 | zJwtHeader, 10 | } from '@openid4vc/oauth2' 11 | import { jwtSignerFromJwt } from '@openid4vc/oauth2' 12 | import z from 'zod' 13 | 14 | export interface VerifyAttestationOptions { 15 | attestedJwt: string 16 | callbacks: Pick 17 | expectedAttestationJwk: Jwk 18 | } 19 | 20 | export async function verifyAttestation(options: VerifyAttestationOptions) { 21 | const { callbacks, expectedAttestationJwk, attestedJwt } = options 22 | if (!expectedAttestationJwk.alg) { 23 | throw new Oauth2Error('Invalid verifier attestation missing required alg property') 24 | } 25 | const jwtSigner: JwtSigner = { 26 | method: 'jwk', 27 | alg: expectedAttestationJwk.alg, 28 | publicJwk: expectedAttestationJwk, 29 | } 30 | 31 | const { header, payload } = decodeJwt({ jwt: attestedJwt }) 32 | const verificationResult = await callbacks.verifyJwt(jwtSigner, { 33 | header, 34 | payload, 35 | compact: attestedJwt, 36 | }) 37 | 38 | if (!verificationResult.verified) { 39 | throw new Oauth2Error('Invalid verifier attestation jwt. Signature verification failed.') 40 | } 41 | 42 | return verificationResult 43 | } 44 | 45 | export interface VerifyAttestationJwtOptions { 46 | attestationJwt: string 47 | clientId: string 48 | clockSkewSec?: number 49 | callbacks: Pick 50 | } 51 | export async function verifyAttestationJWT(options: { 52 | attestationJwt: string 53 | clientId: string 54 | clockSkewSec?: number 55 | callbacks: Pick 56 | }) { 57 | const errors = [] 58 | 59 | const { header, payload } = decodeJwt({ 60 | jwt: options.attestationJwt, 61 | headerSchema: z.object({ ...zJwtHeader.shape, typ: z.literal('verifier-attestation+jwt') }), 62 | }) 63 | 64 | const jwtSigner = jwtSignerFromJwt({ header, payload }) 65 | const { signer } = await verifyJwt({ 66 | header, 67 | payload, 68 | compact: options.attestationJwt, 69 | signer: jwtSigner, 70 | verifyJwtCallback: options.callbacks.verifyJwt, 71 | now: new Date(), 72 | expectedSubject: options.clientId, 73 | allowedSkewInSeconds: options.clockSkewSec || 300, 74 | requiredClaims: ['iss', 'sub', 'exp', 'cnf'], 75 | }) 76 | 77 | // 4. Verify cnf claim structure 78 | if (payload.cnf) { 79 | if (!payload.cnf.jwk) { 80 | errors.push('cnf claim must contain a jwk') 81 | } else { 82 | // Verify JWK has required properties for a public key 83 | const jwk = payload.cnf.jwk 84 | if (!jwk.kty) { 85 | errors.push('JWK missing required kty property') 86 | } 87 | } 88 | } 89 | 90 | const isValid = errors.length === 0 91 | if (isValid) { 92 | return { 93 | isValid: true, 94 | signer, 95 | verifierPublicKey: zJwk.parse(payload.cnf?.jwk), 96 | } as const 97 | } 98 | 99 | return { 100 | isValid: false, 101 | errors: errors, 102 | verifierPublicKey: payload.cnf?.jwk, 103 | } as const 104 | } 105 | -------------------------------------------------------------------------------- /packages/openid4vp/src/client-identifier-scheme/z-client-id-scheme.ts: -------------------------------------------------------------------------------- 1 | import { getGlobalConfig } from '@openid4vc/utils' 2 | import { z } from 'zod' 3 | 4 | export const zClientIdScheme = z.enum([ 5 | 'pre-registered', 6 | 'redirect_uri', 7 | 'https', 8 | 'verifier_attestation', 9 | 'did', 10 | 'x509_san_dns', 11 | 'x509_san_uri', 12 | 'web-origin', 13 | ]) 14 | 15 | export type ClientIdScheme = z.infer 16 | 17 | export const zClientIdToClientIdScheme = z.union( 18 | [ 19 | z 20 | .string({ message: 'client_id MUST be a string' }) 21 | .includes(':') 22 | .transform((clientId) => { 23 | const clientIdScheme = clientId.split(':')[0] 24 | return clientIdScheme === 'http' && getGlobalConfig().allowInsecureUrls ? 'https' : clientIdScheme 25 | }) 26 | .pipe(zClientIdScheme.exclude(['pre-registered'])), 27 | z 28 | .string() 29 | .refine((clientId) => clientId.includes(':') === false) 30 | .transform(() => 'pre-registered' as const), 31 | ], 32 | { 33 | message: `client_id must either start with a known prefix followed by ':' or contain no ':'. Known prefixes are ${zClientIdScheme.exclude(['pre-registered']).options.join(', ')}`, 34 | } 35 | ) 36 | 37 | export const zLegacyClientIdScheme = z.enum([ 38 | 'pre-registered', 39 | 'redirect_uri', 40 | 'entity_id', 41 | 'did', 42 | 'verifier_attestation', 43 | 'x509_san_dns', 44 | 'x509_san_uri', 45 | ]) 46 | 47 | export type LegacyClientIdScheme = z.infer 48 | 49 | export const zLegacyClientIdSchemeToClientIdScheme = zLegacyClientIdScheme 50 | .optional() 51 | .default('pre-registered') 52 | .transform((clientIdScheme) => (clientIdScheme === 'entity_id' ? 'https' : clientIdScheme)) 53 | -------------------------------------------------------------------------------- /packages/openid4vp/src/fetch-client-metadata.ts: -------------------------------------------------------------------------------- 1 | import { Oauth2ErrorCodes, Oauth2ServerErrorResponseError } from '@openid4vc/oauth2' 2 | import { ContentType, type Fetch, createZodFetcher } from '@openid4vc/utils' 3 | import { type ClientMetadata, zClientMetadata } from './models/z-client-metadata' 4 | 5 | export async function fetchClientMetadata(options: { 6 | clientMetadataUri: string 7 | fetch?: Fetch 8 | }): Promise { 9 | const { fetch, clientMetadataUri } = options 10 | const fetcher = createZodFetcher(fetch) 11 | 12 | const { result, response } = await fetcher(zClientMetadata, ContentType.Json, clientMetadataUri, { 13 | method: 'GET', 14 | headers: { 15 | Accept: ContentType.Json, 16 | }, 17 | }) 18 | 19 | if (!response.ok) { 20 | throw new Oauth2ServerErrorResponseError({ 21 | error_description: `Fetching client metadata from '${clientMetadataUri}' failed with status code '${response.status}'.`, 22 | error: Oauth2ErrorCodes.InvalidRequestUri, 23 | }) 24 | } 25 | 26 | if (!result || !result.success) { 27 | throw new Oauth2ServerErrorResponseError({ 28 | error_description: `Parsing client metadata from '${clientMetadataUri}' failed.`, 29 | error: Oauth2ErrorCodes.InvalidRequestObject, 30 | }) 31 | } 32 | 33 | return result.data 34 | } 35 | -------------------------------------------------------------------------------- /packages/openid4vp/src/jar/create-jar-authorization-request.ts: -------------------------------------------------------------------------------- 1 | import { 2 | type CallbackContext, 3 | type JweEncryptor, 4 | type Jwk, 5 | type JwtPayload, 6 | type JwtSigner, 7 | jwtHeaderFromJwtSigner, 8 | } from '@openid4vc/oauth2' 9 | import { addSecondsToDate, dateToSeconds } from '@openid4vc/utils' 10 | import type { JarAuthorizationRequest } from './z-jar-authorization-request' 11 | 12 | export interface CreateJarAuthorizationRequestOptions { 13 | authorizationRequestPayload: JwtPayload & { client_id?: string } 14 | requestUri?: string 15 | 16 | jwtSigner: JwtSigner 17 | jweEncryptor?: JweEncryptor 18 | 19 | callbacks: Pick 20 | 21 | /** 22 | * Number of seconds after which the signed authorization request will expire 23 | */ 24 | expiresInSeconds: number 25 | 26 | /** 27 | * Date that should be used as now. If not provided current date will be used. 28 | */ 29 | now?: Date 30 | 31 | additionalJwtPayload?: Record 32 | } 33 | 34 | /** 35 | * Creates a JAR (JWT Authorization Request) request object. 36 | * 37 | * @param options - The input parameters 38 | * @param options.authorizationRequestPayload - The authorization request parameters 39 | * @param options.jwtSigner - The JWT signer 40 | * @param options.jweEncryptor - The JWE encryptor (optional) if provided, the request object will be encrypted 41 | * @param options.requestUri - The request URI (optional) if provided, the request object needs to be fetched from the URI 42 | * @param options.callbacks - The callback context 43 | * @returns the requestParams, signerJwk, encryptionJwk, and requestObjectJwt 44 | */ 45 | export async function createJarAuthorizationRequest(options: CreateJarAuthorizationRequestOptions) { 46 | const { jwtSigner, jweEncryptor, authorizationRequestPayload, requestUri, callbacks } = options 47 | 48 | let authorizationRequestJwt: string | undefined 49 | let encryptionJwk: Jwk | undefined 50 | 51 | const now = options.now ?? new Date() 52 | 53 | const { jwt, signerJwk } = await callbacks.signJwt(jwtSigner, { 54 | header: { ...jwtHeaderFromJwtSigner(jwtSigner), typ: 'oauth-authz-req+jwt' }, 55 | payload: { 56 | iat: dateToSeconds(now), 57 | exp: dateToSeconds(addSecondsToDate(now, options.expiresInSeconds)), 58 | ...options.additionalJwtPayload, 59 | ...authorizationRequestPayload, 60 | }, 61 | }) 62 | authorizationRequestJwt = jwt 63 | 64 | if (jweEncryptor) { 65 | const encryptionResult = await callbacks.encryptJwe(jweEncryptor, authorizationRequestJwt) 66 | authorizationRequestJwt = encryptionResult.jwe 67 | encryptionJwk = encryptionResult.encryptionJwk 68 | } 69 | 70 | const client_id = authorizationRequestPayload.client_id 71 | const jarAuthorizationRequest: JarAuthorizationRequest = requestUri 72 | ? { client_id, request_uri: requestUri } 73 | : { client_id, request: authorizationRequestJwt } 74 | 75 | return { jarAuthorizationRequest, signerJwk, encryptionJwk, authorizationRequestJwt } 76 | } 77 | -------------------------------------------------------------------------------- /packages/openid4vp/src/jar/handle-jar-request/validate-jar-request-against-session.ts: -------------------------------------------------------------------------------- 1 | import { Oauth2Error } from '@openid4vc/oauth2' 2 | 3 | export type JarMetadata = { 4 | ProtectedBy: 'signature' | 'signature_encryption' 5 | SendBy: 'value' | 'reference' 6 | } 7 | 8 | export interface ValidateJarRequestAgainstSessionOptions { 9 | jarMetadata: JarMetadata 10 | jarSessionMetadata: JarMetadata 11 | } 12 | 13 | /** 14 | * Validates a JAR (JWT Authorization Request) request by comparing the provided (actual) metadata 15 | * with the jar session metadata. 16 | * 17 | * @param input - The input object containing the session ID and the JAR metadata. 18 | * @param input.jarMeta - The actual (received) JAR request metadata. 19 | * @param input.jarSessionMeta - The session metadata. 20 | * 21 | * @returns A promise that resolves to the session metadata if validation is successful. 22 | * 23 | * @throws {JarInvalidRequestObjectError} If the `protected_by` or `send_by` values in the JAR metadata 24 | * do not match the corresponding values in the session metadata. 25 | */ 26 | export async function validateJarRequestAgainstSession(options: ValidateJarRequestAgainstSessionOptions) { 27 | const { jarMetadata, jarSessionMetadata } = options 28 | 29 | if (jarSessionMetadata.ProtectedBy !== jarMetadata.ProtectedBy) { 30 | throw new Oauth2Error(`The protected_by value does not match the session's protected_by value.`) 31 | } 32 | 33 | if (jarSessionMetadata.SendBy !== jarMetadata.SendBy) { 34 | throw new Oauth2Error(`The send_by value does not match the session's send_by value.`) 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /packages/openid4vp/src/jar/jar-request-object/fetch-jar-request-object.ts: -------------------------------------------------------------------------------- 1 | import { Oauth2ErrorCodes, Oauth2ServerErrorResponseError } from '@openid4vc/oauth2' 2 | import { ContentType, type Fetch, createFetcher, objectToQueryParams } from '@openid4vc/utils' 3 | import type { ClientIdScheme } from '../../client-identifier-scheme/z-client-id-scheme' 4 | import type { WalletMetadata } from '../../models/z-wallet-metadata' 5 | 6 | /** 7 | * Fetch a request object and parse the response. 8 | * If you want to fetch the request object without providing wallet_metadata or wallet_nonce as defined in jar you can use the `fetchJarRequestObject` function. 9 | * 10 | * Returns validated request object if successful response 11 | * Throws error otherwise 12 | * 13 | * @throws {ValidationError} if successful response but validation of response failed 14 | * @throws {InvalidFetchResponseError} if no successful or 404 response 15 | * @throws {Error} if parsing json from response fails 16 | */ 17 | export async function fetchJarRequestObject(options: { 18 | requestUri: string 19 | clientIdentifierScheme?: ClientIdScheme 20 | method: 'get' | 'post' 21 | wallet: { 22 | metadata?: WalletMetadata 23 | nonce?: string 24 | } 25 | fetch?: Fetch 26 | }): Promise { 27 | const { requestUri, clientIdentifierScheme, method, wallet, fetch } = options 28 | 29 | let requestBody = wallet.metadata ? { wallet_metadata: wallet.metadata, wallet_nonce: wallet.nonce } : undefined 30 | if ( 31 | requestBody?.wallet_metadata?.request_object_signing_alg_values_supported && 32 | clientIdentifierScheme === 'redirect_uri' 33 | ) { 34 | // This value indicates that the Client Identifier (without the prefix redirect_uri:) is the Verifier's Redirect URI (or Response URI when Response Mode direct_post is used). The Authorization Request MUST NOT be signed. 35 | const { request_object_signing_alg_values_supported, ...rest } = requestBody.wallet_metadata 36 | requestBody = { ...requestBody, wallet_metadata: { ...rest } } 37 | } 38 | 39 | const response = await createFetcher(fetch)(requestUri, { 40 | method, 41 | body: method === 'post' ? objectToQueryParams(wallet.metadata ?? {}) : undefined, 42 | headers: { 43 | Accept: `${ContentType.OAuthAuthorizationRequestJwt}, ${ContentType.Jwt};q=0.9, text/plain`, 44 | 'Content-Type': ContentType.XWwwFormUrlencoded, 45 | }, 46 | }).catch(() => { 47 | throw new Oauth2ServerErrorResponseError({ 48 | error_description: `Fetching request_object from request_uri '${requestUri}' failed`, 49 | error: Oauth2ErrorCodes.InvalidRequestUri, 50 | }) 51 | }) 52 | 53 | if (!response.ok) { 54 | throw new Oauth2ServerErrorResponseError({ 55 | error_description: `Fetching request_object from request_uri '${requestUri}' failed with status code '${response.status}'.`, 56 | error: Oauth2ErrorCodes.InvalidRequestUri, 57 | }) 58 | } 59 | 60 | return await response.text() 61 | } 62 | -------------------------------------------------------------------------------- /packages/openid4vp/src/jar/jar-request-object/z-jar-request-object.ts: -------------------------------------------------------------------------------- 1 | import { zJwtPayload } from '@openid4vc/oauth2' 2 | import { z } from 'zod' 3 | 4 | export const zJarRequestObjectPayload = z 5 | .object({ 6 | ...zJwtPayload.shape, 7 | client_id: z.string(), 8 | }) 9 | .passthrough() 10 | export type JarRequestObjectPayload = z.infer 11 | -------------------------------------------------------------------------------- /packages/openid4vp/src/jar/z-jar-authorization-request.ts: -------------------------------------------------------------------------------- 1 | import { Oauth2ServerErrorResponseError } from '@openid4vc/oauth2' 2 | import { zHttpsUrl } from '@openid4vc/utils' 3 | import { z } from 'zod' 4 | import type { Openid4vpAuthorizationRequest } from '../authorization-request/z-authorization-request' 5 | import type { Openid4vpAuthorizationRequestDcApi } from '../authorization-request/z-authorization-request-dc-api' 6 | 7 | export const zJarAuthorizationRequest = z 8 | .object({ 9 | request: z.optional(z.string()), 10 | request_uri: z.optional(zHttpsUrl), 11 | request_uri_method: z.optional(z.string()), 12 | client_id: z.optional(z.string()), 13 | }) 14 | .passthrough() 15 | export type JarAuthorizationRequest = z.infer 16 | 17 | export function validateJarRequestParams(options: { jarRequestParams: JarAuthorizationRequest }) { 18 | const { jarRequestParams } = options 19 | 20 | if (jarRequestParams.request && jarRequestParams.request_uri) { 21 | throw new Oauth2ServerErrorResponseError({ 22 | error: 'invalid_request_object', 23 | error_description: 'request and request_uri cannot both be present in a JAR request', 24 | }) 25 | } 26 | 27 | if (!jarRequestParams.request && !jarRequestParams.request_uri) { 28 | throw new Oauth2ServerErrorResponseError({ 29 | error: 'invalid_request_object', 30 | error_description: 'request or request_uri must be present', 31 | }) 32 | } 33 | 34 | return jarRequestParams as JarAuthorizationRequest & 35 | ({ request_uri: string; request?: never } | { request: string; request_uri?: never }) 36 | } 37 | 38 | export function isJarAuthorizationRequest( 39 | request: Openid4vpAuthorizationRequest | JarAuthorizationRequest | Openid4vpAuthorizationRequestDcApi 40 | ): request is JarAuthorizationRequest { 41 | return 'request' in request || 'request_uri' in request 42 | } 43 | -------------------------------------------------------------------------------- /packages/openid4vp/src/jar/z-jar-authorization-server-metadata.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod' 2 | 3 | export const zJarAuthorizationServerMetadata = z.object({ 4 | request_object_signing_alg_values_supported: z.optional(z.array(z.string())), 5 | request_object_encryption_alg_values_supported: z.optional(z.array(z.string())), 6 | request_object_encryption_enc_values_supported: z.optional(z.array(z.string())), 7 | }) 8 | export type JarAuthorizationServerMetadata = z.infer 9 | -------------------------------------------------------------------------------- /packages/openid4vp/src/jar/z-jar-client-metadata.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod' 2 | 3 | export const zJarDynamicClientRegistrationMetadata = z.object({ 4 | request_object_signing_alg: z.optional(z.string()), 5 | request_object_encryption_alg: z.optional(z.string()), 6 | request_object_encryption_enc: z.optional(z.string()), 7 | }) 8 | export type JarDynamicClientRegistrationMetadata = z.infer 9 | -------------------------------------------------------------------------------- /packages/openid4vp/src/jarm/jarm-authorizatino-response-send.ts: -------------------------------------------------------------------------------- 1 | import { type CallbackContext, Oauth2Error } from '@openid4vc/oauth2' 2 | import { ContentType, URL, createFetcher } from '@openid4vc/utils' 3 | 4 | interface JarmAuthorizationResponseSendOptions { 5 | authorizationRequestPayload: { 6 | response_uri?: string 7 | redirect_uri?: string 8 | } 9 | jarmAuthorizationResponseJwt: string 10 | callbacks: Pick 11 | } 12 | 13 | export const jarmAuthorizationResponseSend = (options: JarmAuthorizationResponseSendOptions) => { 14 | const { authorizationRequestPayload, jarmAuthorizationResponseJwt, callbacks } = options 15 | 16 | const responseEndpoint = authorizationRequestPayload.response_uri ?? authorizationRequestPayload.redirect_uri 17 | if (!responseEndpoint) { 18 | throw new Oauth2Error(`Either 'response_uri' or 'redirect_uri' MUST be present in the authorization request`) 19 | } 20 | 21 | const responseEndpointUrl = new URL(responseEndpoint) 22 | return handleDirectPostJwt(responseEndpointUrl, jarmAuthorizationResponseJwt, callbacks) 23 | } 24 | 25 | async function handleDirectPostJwt( 26 | responseEndpoint: URL, 27 | responseJwt: string, 28 | callbacks: Pick 29 | ) { 30 | const response = await createFetcher(callbacks.fetch)(responseEndpoint, { 31 | method: 'POST', 32 | headers: { 'Content-Type': ContentType.XWwwFormUrlencoded }, 33 | body: `response=${responseJwt}`, 34 | }) 35 | 36 | return { 37 | responseMode: 'direct_post.jwt', 38 | response, 39 | } as const 40 | } 41 | -------------------------------------------------------------------------------- /packages/openid4vp/src/jarm/jarm-authorization-response-create.ts: -------------------------------------------------------------------------------- 1 | import { 2 | type CallbackContext, 3 | type JweEncryptor, 4 | type JwtSigner, 5 | Oauth2Error, 6 | jwtHeaderFromJwtSigner, 7 | } from '@openid4vc/oauth2' 8 | import type { 9 | JarmAuthorizationResponse, 10 | JarmAuthorizationResponseEncryptedOnly, 11 | } from './jarm-authorization-response/z-jarm-authorization-response' 12 | 13 | export interface CreateJarmAuthorizationResponseOptions { 14 | jarmAuthorizationResponse: JarmAuthorizationResponse | JarmAuthorizationResponseEncryptedOnly 15 | jwtSigner?: JwtSigner 16 | jweEncryptor?: JweEncryptor 17 | callbacks: Pick 18 | } 19 | 20 | export async function createJarmAuthorizationResponse(options: CreateJarmAuthorizationResponseOptions) { 21 | const { jarmAuthorizationResponse, jweEncryptor, jwtSigner, callbacks } = options 22 | if (!jwtSigner && jweEncryptor) { 23 | const { jwe } = await callbacks.encryptJwe(jweEncryptor, JSON.stringify(jarmAuthorizationResponse)) 24 | return { jarmAuthorizationResponseJwt: jwe } 25 | } 26 | 27 | if (jwtSigner && !jweEncryptor) { 28 | const signed = await callbacks.signJwt(jwtSigner, { 29 | header: jwtHeaderFromJwtSigner(jwtSigner), 30 | payload: jarmAuthorizationResponse, 31 | }) 32 | return { jarmAuthorizationResponseJwt: signed.jwt } 33 | } 34 | 35 | if (!jwtSigner || !jweEncryptor) { 36 | throw new Oauth2Error('JWT signer and/or encryptor are required to create a JARM auth response.') 37 | } 38 | const signed = await callbacks.signJwt(jwtSigner, { 39 | header: jwtHeaderFromJwtSigner(jwtSigner), 40 | payload: jarmAuthorizationResponse, 41 | }) 42 | 43 | const encrypted = await callbacks.encryptJwe(jweEncryptor, signed.jwt) 44 | 45 | return { jarmAuthorizationResponseJwt: encrypted.jwe } 46 | } 47 | -------------------------------------------------------------------------------- /packages/openid4vp/src/jarm/jarm-authorization-response/jarm-validate-authorization-response.ts: -------------------------------------------------------------------------------- 1 | import { Oauth2Error } from '@openid4vc/oauth2' 2 | import { dateToSeconds } from '@openid4vc/utils' 3 | import { 4 | type JarmAuthorizationResponse, 5 | type JarmAuthorizationResponseEncryptedOnly, 6 | zJarmAuthorizationResponse, 7 | } from './z-jarm-authorization-response' 8 | 9 | export const jarmAuthorizationResponseValidate = (options: { 10 | expectedClientId: string 11 | authorizationResponse: JarmAuthorizationResponse | JarmAuthorizationResponseEncryptedOnly 12 | }) => { 13 | const { expectedClientId, authorizationResponse } = options 14 | 15 | // The traditional Jarm Validation Methods do not account for the encrypted response. 16 | if (!zJarmAuthorizationResponse.safeParse(authorizationResponse).success) { 17 | return 18 | } 19 | 20 | // 3. The client obtains the aud element from the JWT and checks whether it matches the client id the client used to identify itself in the corresponding authorization request. If the check fails, the client MUST abort processing and refuse the response. 21 | if (expectedClientId !== authorizationResponse.aud) { 22 | throw new Oauth2Error( 23 | `Invalid 'aud' claim in JARM authorization response. Expected '${ 24 | expectedClientId 25 | }' received '${JSON.stringify(authorizationResponse.aud)}'.` 26 | ) 27 | } 28 | 29 | // 4. The client checks the JWT's exp element to determine if the JWT is still valid. If the check fails, the client MUST abort processing and refuse the response. 30 | // 120 seconds clock skew 31 | if (authorizationResponse.exp !== undefined && authorizationResponse.exp < dateToSeconds()) { 32 | throw new Oauth2Error('Jarm auth response is expired.') 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /packages/openid4vp/src/jarm/jarm-authorization-response/z-jarm-authorization-response.ts: -------------------------------------------------------------------------------- 1 | import { zJwtHeader, zJwtPayload } from '@openid4vc/oauth2' 2 | import { z } from 'zod' 3 | 4 | export const zJarmHeader = z.object({ ...zJwtHeader.shape, apu: z.string().optional(), apv: z.string().optional() }) 5 | export type JarmHeader = z.infer 6 | 7 | export const zJarmAuthorizationResponse = z 8 | .object({ 9 | /** 10 | * iss: The issuer URL of the authorization server that created the response 11 | * aud: The client_id of the client the response is intended for 12 | * exp: The expiration time of the JWT. A maximum JWT lifetime of 10 minutes is RECOMMENDED. 13 | */ 14 | ...zJwtPayload.shape, 15 | ...zJwtPayload.pick({ iss: true, aud: true, exp: true }).required().shape, 16 | state: z.optional(z.string()), 17 | }) 18 | .passthrough() 19 | 20 | export type JarmAuthorizationResponse = z.infer 21 | 22 | export const zJarmAuthorizationResponseEncryptedOnly = z 23 | .object({ 24 | ...zJwtPayload.shape, 25 | state: z.optional(z.string()), 26 | }) 27 | .passthrough() 28 | export type JarmAuthorizationResponseEncryptedOnly = z.infer 29 | -------------------------------------------------------------------------------- /packages/openid4vp/src/jarm/jarm-extract-jwks.ts: -------------------------------------------------------------------------------- 1 | import type { JwkSet } from '@openid4vc/oauth2' 2 | import { type JarmClientMetadata, zJarmClientMetadataParsed } from './metadata/z-jarm-client-metadata' 3 | 4 | export function extractJwksFromClientMetadata(clientMetadata: JarmClientMetadata & { jwks: JwkSet }) { 5 | const parsed = zJarmClientMetadataParsed.parse(clientMetadata) 6 | 7 | const encryptionAlg = parsed.client_metadata.authorization_encrypted_response_enc 8 | const signingAlg = parsed.client_metadata.authorization_signed_response_alg 9 | 10 | const encJwk = 11 | clientMetadata.jwks.keys.find((key) => key.use === 'enc' && key.alg === encryptionAlg) ?? 12 | clientMetadata.jwks.keys.find((key) => key.use === 'enc') ?? 13 | // fallback, take first key. HAIP does not specify requirement on enc 14 | clientMetadata.jwks.keys?.[0] 15 | 16 | const sigJwk = 17 | clientMetadata.jwks.keys.find((key) => key.use === 'sig' && key.alg === signingAlg) ?? 18 | clientMetadata.jwks.keys.find((key) => key.use === 'sig') ?? 19 | // falback, take first key 20 | clientMetadata.jwks.keys?.[0] 21 | 22 | return { encJwk, sigJwk } 23 | } 24 | -------------------------------------------------------------------------------- /packages/openid4vp/src/jarm/jarm-response-mode.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod' 2 | 3 | export const jarmResponseMode = [ 4 | 'jwt', 5 | 'query.jwt', 6 | 'fragment.jwt', 7 | 'form_post.jwt', 8 | 'direct_post.jwt', 9 | 'dc_api.jwt', 10 | ] as const 11 | export const zJarmResponseMode = z.enum(jarmResponseMode) 12 | 13 | export type JarmResponseMode = (typeof jarmResponseMode)[number] 14 | 15 | export const isJarmResponseMode = (responseMode: string): responseMode is JarmResponseMode => { 16 | return jarmResponseMode.includes(responseMode as JarmResponseMode) 17 | } 18 | -------------------------------------------------------------------------------- /packages/openid4vp/src/jarm/metadata/jarm-assert-metadata-supported.ts: -------------------------------------------------------------------------------- 1 | import { Oauth2Error } from '@openid4vc/oauth2' 2 | import type { JarmServerMetadata } from './z-jarm-authorization-server-metadata' 3 | import { type JarmClientMetadata, zJarmClientMetadataParsed } from './z-jarm-client-metadata' 4 | 5 | interface AssertValueSupported { 6 | supported: T[] 7 | actual: T 8 | errorMessage: string 9 | } 10 | 11 | function assertValueSupported(options: AssertValueSupported): T { 12 | const { errorMessage, supported, actual } = options 13 | const intersection = supported.find((value) => value === actual) 14 | 15 | if (!intersection) { 16 | throw new Oauth2Error(errorMessage) 17 | } 18 | 19 | return intersection 20 | } 21 | 22 | export function jarmAssertMetadataSupported(options: { 23 | clientMetadata: JarmClientMetadata 24 | serverMetadata: JarmServerMetadata 25 | }) { 26 | const { clientMetadata, serverMetadata } = options 27 | const parsedClientMetadata = zJarmClientMetadataParsed.parse(clientMetadata) 28 | 29 | if (parsedClientMetadata.type === 'sign_encrypt' || parsedClientMetadata.type === 'encrypt') { 30 | if (serverMetadata.authorization_encryption_alg_values_supported) { 31 | assertValueSupported({ 32 | supported: serverMetadata.authorization_encryption_alg_values_supported, 33 | actual: parsedClientMetadata.client_metadata.authorization_encrypted_response_alg, 34 | errorMessage: 'Invalid authorization_encryption_alg', 35 | }) 36 | } 37 | 38 | if (serverMetadata.authorization_encryption_enc_values_supported) { 39 | assertValueSupported({ 40 | supported: serverMetadata.authorization_encryption_enc_values_supported, 41 | actual: parsedClientMetadata.client_metadata.authorization_encrypted_response_enc, 42 | errorMessage: 'Invalid authorization_encryption_enc', 43 | }) 44 | } 45 | } 46 | 47 | if ( 48 | serverMetadata.authorization_signing_alg_values_supported && 49 | (parsedClientMetadata.type === 'sign' || parsedClientMetadata.type === 'sign_encrypt') 50 | ) { 51 | assertValueSupported({ 52 | supported: serverMetadata.authorization_signing_alg_values_supported, 53 | actual: parsedClientMetadata.client_metadata.authorization_signed_response_alg, 54 | errorMessage: 'Invalid authorization_signed_response_alg', 55 | }) 56 | } 57 | 58 | return parsedClientMetadata 59 | } 60 | -------------------------------------------------------------------------------- /packages/openid4vp/src/jarm/metadata/z-jarm-authorization-server-metadata.ts: -------------------------------------------------------------------------------- 1 | import { zAlgValueNotNone } from '@openid4vc/oauth2' 2 | import { z } from 'zod' 3 | 4 | export const zJarmServerMetadata = z.object({ 5 | authorization_signing_alg_values_supported: z.array(zAlgValueNotNone), 6 | authorization_encryption_alg_values_supported: z.array(zAlgValueNotNone), 7 | authorization_encryption_enc_values_supported: z.array(z.string()), 8 | }) 9 | 10 | export type JarmServerMetadata = z.infer 11 | -------------------------------------------------------------------------------- /packages/openid4vp/src/jarm/parse-jarm-authorization-response-direct-post-jwt.ts: -------------------------------------------------------------------------------- 1 | import { Oauth2Error, zCompactJwe, zCompactJwt } from '@openid4vc/oauth2' 2 | import { ContentType, URLSearchParams } from '@openid4vc/utils' 3 | import z from 'zod' 4 | 5 | export async function parseJarmAuthorizationResponseDirectPostJwt(request: Request) { 6 | const contentType = request.headers.get('content-type') 7 | 8 | if (!contentType) { 9 | throw new Oauth2Error('Content type is missing in jarm-request.') 10 | } 11 | 12 | if (!contentType.includes(ContentType.XWwwFormUrlencoded)) { 13 | throw new Oauth2Error( 14 | `Received invalid JARM auth request. Expected content-type application/x-www-form-urlencoded. Current: ${contentType}` 15 | ) 16 | } 17 | 18 | const formData = await request.clone().text() 19 | const urlSearchParams = new URLSearchParams(formData) 20 | const requestData = Object.fromEntries(urlSearchParams) 21 | 22 | if (!requestData.response) { 23 | throw new Oauth2Error(`Received invalid JARM request data. The 'response' JWT/JWT value is missing.`) 24 | } 25 | 26 | const isJweOrJws = z.union([zCompactJwt, zCompactJwe]).safeParse(requestData.response) 27 | if (isJweOrJws.success) { 28 | return { jarmAuthorizationResponseJwt: requestData.response } 29 | } 30 | 31 | throw new Oauth2Error('Received invalid JARM auth response. Expected JWE or JWT.', { 32 | cause: requestData, 33 | }) 34 | } 35 | -------------------------------------------------------------------------------- /packages/openid4vp/src/models/z-client-metadata.ts: -------------------------------------------------------------------------------- 1 | import { zJwkSet } from '@openid4vc/oauth2' 2 | import { zHttpsUrl } from '@openid4vc/utils' 3 | import { z } from 'zod' 4 | import { zJarmClientMetadata } from '../jarm/metadata/z-jarm-client-metadata' 5 | import { zVpFormatsSupported } from './z-vp-formats-supported' 6 | 7 | // Authoritative data the Wallet is able to obtain about the Client from other sources, 8 | // for example those from an OpenID Federation Entity Statement, take precedence over the values passed in client_metadata. 9 | export const zClientMetadata = z 10 | .object({ 11 | // Up until draft 22 12 | jwks_uri: z.string().url().optional(), 13 | jwks: z.optional(zJwkSet), 14 | 15 | vp_formats: z.optional(zVpFormatsSupported), 16 | ...zJarmClientMetadata.shape, 17 | logo_uri: zHttpsUrl.optional(), 18 | client_name: z.string().optional(), 19 | }) 20 | .passthrough() 21 | export type ClientMetadata = z.infer 22 | -------------------------------------------------------------------------------- /packages/openid4vp/src/models/z-credential-formats.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod' 2 | export const zCredentialFormat = z.enum(['jwt_vc_json', 'ldp_vc', 'ac_vc', 'mso_mdoc', 'dc+sd-jwt', 'vc+sd-jwt']) 3 | export type CredentialFormat = z.infer 4 | -------------------------------------------------------------------------------- /packages/openid4vp/src/models/z-pex.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod' 2 | 3 | export const zPexPresentationDefinition = z.record(z.any()) 4 | export const zPexPresentationSubmission = z.record(z.any()) 5 | 6 | export type PexPresentationDefinition = z.infer 7 | export type PexPresentationSubmission = z.infer 8 | -------------------------------------------------------------------------------- /packages/openid4vp/src/models/z-proof-formats.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod' 2 | export const zProofFormat = z.enum(['jwt_vp_json', 'ldc_vp', 'ac_vp', 'dc+sd-jwt', 'vc+sd-jwt', 'mso_mdoc']) 3 | export type ProofFormat = z.infer 4 | -------------------------------------------------------------------------------- /packages/openid4vp/src/models/z-verifier-attestations.ts: -------------------------------------------------------------------------------- 1 | import z from 'zod' 2 | 3 | const zVerifierAttestation = z.object({ 4 | format: z.string(), 5 | data: z.record(z.unknown()).or(z.string()), 6 | credential_ids: z.array(z.string()).optional(), 7 | }) 8 | 9 | export const zVerifierAttestations = z.array(zVerifierAttestation) 10 | 11 | export type VerifierAttestation = z.infer 12 | export type VerifierAttestations = z.infer 13 | -------------------------------------------------------------------------------- /packages/openid4vp/src/models/z-vp-formats-supported.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod' 2 | export const zVpFormatsSupported = z.record( 3 | z.string(), 4 | z 5 | .object({ 6 | alg_values_supported: z.optional(z.array(z.string())), 7 | }) 8 | .passthrough() 9 | ) 10 | 11 | export type VpFormatsSupported = z.infer 12 | -------------------------------------------------------------------------------- /packages/openid4vp/src/models/z-wallet-metadata.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod' 2 | import { zClientIdScheme } from '../client-identifier-scheme/z-client-id-scheme' 3 | import { zVpFormatsSupported } from './z-vp-formats-supported' 4 | 5 | export const zWalletMetadata = z.object({ 6 | presentation_definition_uri_supported: z.optional(z.boolean()), 7 | vp_formats_supported: zVpFormatsSupported, 8 | client_id_schemes_supported: z.optional(z.array(zClientIdScheme)), 9 | request_object_signing_alg_values_supported: z.optional(z.array(z.string())), 10 | authorization_encryption_alg_values_supported: z.optional(z.array(z.string())), 11 | authorization_encryption_enc_values_supported: z.optional(z.array(z.string())), 12 | }) 13 | 14 | export type WalletMetadata = z.infer 15 | -------------------------------------------------------------------------------- /packages/openid4vp/src/transaction-data/parse-transaction-data.ts: -------------------------------------------------------------------------------- 1 | import { Oauth2ErrorCodes, Oauth2ServerErrorResponseError } from '@openid4vc/oauth2' 2 | import { decodeBase64, encodeToUtf8String, parseIfJson } from '@openid4vc/utils' 3 | import { type TransactionDataEntry, zTransactionData } from './z-transaction-data' 4 | 5 | export interface ParseTransactionDataOptions { 6 | transactionData: string[] 7 | } 8 | 9 | export interface ParsedTransactionDataEntry { 10 | transactionData: TransactionDataEntry 11 | transactionDataIndex: number 12 | encoded: string 13 | } 14 | 15 | export function parseTransactionData(options: ParseTransactionDataOptions): ParsedTransactionDataEntry[] { 16 | const { transactionData } = options 17 | 18 | const decoded = transactionData.map((tdEntry) => parseIfJson(encodeToUtf8String(decodeBase64(tdEntry)))) 19 | 20 | const parsedResult = zTransactionData.safeParse(decoded) 21 | if (!parsedResult.success) { 22 | throw new Oauth2ServerErrorResponseError({ 23 | error: Oauth2ErrorCodes.InvalidTransactionData, 24 | error_description: 'Failed to parse transaction data.', 25 | }) 26 | } 27 | 28 | return parsedResult.data.map((decoded, index) => ({ 29 | transactionData: decoded, 30 | encoded: transactionData[index], 31 | transactionDataIndex: index, 32 | })) 33 | } 34 | -------------------------------------------------------------------------------- /packages/openid4vp/src/transaction-data/z-transaction-data.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod' 2 | 3 | export const zTransactionEntry = z 4 | .object({ 5 | type: z.string(), 6 | credential_ids: z.array(z.string()).nonempty(), 7 | transaction_data_hashes_alg: z.array(z.string()).optional(), 8 | }) 9 | .passthrough() 10 | export type TransactionDataEntry = z.infer 11 | 12 | export const zTransactionData = z.array(zTransactionEntry) 13 | export type TransactionData = z.infer 14 | -------------------------------------------------------------------------------- /packages/openid4vp/src/vp-token/parse-vp-token.ts: -------------------------------------------------------------------------------- 1 | import { parseIfJson, parseWithErrorHandling } from '@openid4vc/utils' 2 | import { type VpTokenDcql, type VpTokenPexEntry, zVpTokenDcql, zVpTokenPex } from './z-vp-token' 3 | 4 | export function parsePexVpToken(vpToken: unknown): [VpTokenPexEntry, ...VpTokenPexEntry[]] { 5 | const parsedVpToken = parseWithErrorHandling( 6 | zVpTokenPex, 7 | parseIfJson(vpToken), 8 | 'Could not parse presentation exchange vp_token. Expected a string or an array of strings' 9 | ) 10 | 11 | return Array.isArray(parsedVpToken) ? (parsedVpToken as [VpTokenPexEntry, ...VpTokenPexEntry[]]) : [parsedVpToken] 12 | } 13 | 14 | export function parseDcqlVpToken(vpToken: unknown): VpTokenDcql { 15 | return parseWithErrorHandling( 16 | zVpTokenDcql, 17 | parseIfJson(vpToken), 18 | 'Could not parse dcql vp_token. Expected an object where the values are encoded presentations' 19 | ) 20 | } 21 | -------------------------------------------------------------------------------- /packages/openid4vp/src/vp-token/z-vp-token.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod' 2 | 3 | const zVpTokenPexEntry = z.union([z.string(), z.record(z.any())], { 4 | message: 'pex vp_token entry must be a string or object', 5 | }) 6 | 7 | export const zVpTokenPex = z.union( 8 | [zVpTokenPexEntry, z.array(zVpTokenPexEntry).nonempty('Must have at least entry in vp_token array')], 9 | { 10 | message: 'pex vp_token must be a string, object or array of strings and objects', 11 | } 12 | ) 13 | export type VpTokenPex = z.infer 14 | export type VpTokenPexEntry = z.infer 15 | 16 | export const zVpTokenDcql = z.record(z.union([z.string(), z.record(z.any())]), { 17 | message: 18 | 'dcql vp_token must be an object with keys referencing the dcql credential query id, and values the encoded (string or object) presentation', 19 | }) 20 | export type VpTokenDcql = z.infer 21 | 22 | export const zVpToken = zVpTokenDcql.or(zVpTokenPex) 23 | export type VpToken = z.infer 24 | -------------------------------------------------------------------------------- /packages/openid4vp/tests/version-inference.test.mts: -------------------------------------------------------------------------------- 1 | import { describe, expect, test } from 'vitest' 2 | import { parseAuthorizationRequestVersion } from '../src/version.js' 3 | 4 | describe('Version inference test', () => { 5 | test('w3c_dc_api is only available in v22 and below', () => { 6 | const version = parseAuthorizationRequestVersion({ 7 | response_mode: 'w3c_dc_api', 8 | nonce: 'nonce', 9 | response_type: 'vp_token', 10 | }) 11 | 12 | expect(version).toBe(22) 13 | }) 14 | 15 | test('dc_api is only available from v23', () => { 16 | const version = parseAuthorizationRequestVersion({ 17 | response_mode: 'dc_api', 18 | nonce: 'nonce', 19 | response_type: 'vp_token', 20 | }) 21 | 22 | expect(version).toBe(24) 23 | }) 24 | 25 | test('client_metadata_uri requires version below 21', () => { 26 | const version = parseAuthorizationRequestVersion({ 27 | response_mode: 'direct_post', 28 | client_id: 'client_id', 29 | nonce: 'nonce', 30 | response_type: 'vp_token', 31 | client_metadata_uri: 'https://example.com', 32 | }) 33 | 34 | expect(version).toBe(20) 35 | }) 36 | }) 37 | -------------------------------------------------------------------------------- /packages/openid4vp/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "build" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /packages/utils/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # @openid4vc/utils 2 | 3 | ## 0.2.0 4 | 5 | ## 0.1.4 6 | 7 | ## 0.1.3 8 | 9 | ### Patch Changes 10 | 11 | - d4b9279: chore: create github release 12 | 13 | ## 0.1.2 14 | 15 | ### Patch Changes 16 | 17 | - 1de27e5: chore: correct formatting for publishing 18 | 19 | ## 0.1.1 20 | 21 | ### Patch Changes 22 | 23 | - 6434781: docs: add readme 24 | 25 | ## 0.1.0 26 | 27 | ### Minor Changes 28 | 29 | - 71326c8: feat: initial release 30 | -------------------------------------------------------------------------------- /packages/utils/README.md: -------------------------------------------------------------------------------- 1 |

OpenID for Verifiable Credentials - OAuth2 Utils

2 | 3 |

4 | 5 | 6 | 7 |

8 | 9 | Check out the documentation in the [Github repository](https://github.com/openwallet-foundation-labs/oid4vc-ts). 10 | -------------------------------------------------------------------------------- /packages/utils/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@openid4vc/utils", 3 | "version": "0.2.0", 4 | "files": ["dist"], 5 | "license": "Apache-2.0", 6 | "exports": "./src/index.ts", 7 | "homepage": "https://github.com/openwallet-foundation-labs/oid4vc-ts/tree/main/packages/utils", 8 | "repository": { 9 | "type": "git", 10 | "url": "https://github.com/openwallet-foundation-labs/oid4vc-ts", 11 | "directory": "packages/utils" 12 | }, 13 | "publishConfig": { 14 | "main": "./dist/index.js", 15 | "module": "./dist/index.mjs", 16 | "types": "./dist/index.d.ts", 17 | "exports": { 18 | ".": { 19 | "import": "./dist/index.mjs", 20 | "require": "./dist/index.js", 21 | "types": "./dist/index.d.ts" 22 | }, 23 | "./package.json": "./package.json" 24 | } 25 | }, 26 | "scripts": { 27 | "build": "tsup src/index.ts --format cjs,esm --dts --clean --sourcemap" 28 | }, 29 | "dependencies": { 30 | "buffer": "catalog:", 31 | "zod": "catalog:" 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /packages/utils/src/__tests__/validation-error.test.mts: -------------------------------------------------------------------------------- 1 | import { describe, expect, test } from 'vitest' 2 | import z from 'zod' 3 | import { ValidationError } from '../error/ValidationError.js' 4 | 5 | describe('Validation error', () => { 6 | test('basic formatting', () => { 7 | const schema = z.object({ 8 | name: z.string(), 9 | age: z.number(), 10 | object: z.object({ 11 | foo: z.object({ 12 | bar: z.discriminatedUnion('type', [ 13 | z.object({ type: z.literal('a'), a: z.string() }), 14 | z.object({ type: z.literal('b'), b: z.number() }), 15 | ]), 16 | }), 17 | }), 18 | }) 19 | 20 | const result = schema.safeParse({ 21 | object: { foo: { bar: { type: 'z', a: 123 } } }, 22 | }) 23 | 24 | const error = new ValidationError('Validation failed', result.error) 25 | expect(error.message).toMatchInlineSnapshot(` 26 | "Validation failed 27 | - Required at "name" 28 | - Required at "age" 29 | - Invalid discriminator value. Expected 'a' | 'b' at "object.foo.bar.type"" 30 | `) 31 | }) 32 | 33 | test('should be able to get original zod error instance', () => { 34 | const schema = z.object({ name: z.string() }) 35 | const result = schema.safeParse({ name: 123 }) 36 | 37 | const error = new ValidationError('Validation failed', result.error) 38 | expect(error.zodError).toBe(result.error) 39 | }) 40 | }) 41 | -------------------------------------------------------------------------------- /packages/utils/src/__tests__/www-authenticate.test.mts: -------------------------------------------------------------------------------- 1 | import { describe, expect, test } from 'vitest' 2 | import { encodeWwwAuthenticateHeader, parseWwwAuthenticateHeader } from '../www-authenticate.js' 3 | 4 | describe('WWW-Authenticate Header', () => { 5 | test('Correctly parses single scheme', () => { 6 | expect( 7 | parseWwwAuthenticateHeader('Custom foo=bar,foo=fuzz,buzz="quoted \\"value!\\"", Bearer="true", name, age') 8 | ).toEqual([ 9 | { 10 | payload: { 11 | Bearer: 'true', 12 | buzz: 'quoted "value!"', 13 | foo: ['bar', 'fuzz'], 14 | name: null, 15 | age: null, 16 | }, 17 | scheme: 'Custom', 18 | }, 19 | ]) 20 | }) 21 | 22 | test('Correctly parses multiple schemes with well-known scheme names', () => { 23 | expect( 24 | parseWwwAuthenticateHeader( 25 | 'Custom foo=bar,foo=fuzz,buzz="quoted \\"value!\\"", Bearer="true", name, age, Bearer, Basic name="Timo", DPoP name="Timo", DPoP name="again", Bearer' 26 | ) 27 | ).toEqual([ 28 | { 29 | payload: { 30 | Bearer: 'true', 31 | buzz: 'quoted "value!"', 32 | foo: ['bar', 'fuzz'], 33 | name: null, 34 | age: null, 35 | }, 36 | scheme: 'Custom', 37 | }, 38 | { 39 | payload: {}, 40 | scheme: 'Bearer', 41 | }, 42 | { 43 | payload: { 44 | name: 'Timo', 45 | }, 46 | scheme: 'Basic', 47 | }, 48 | { 49 | payload: { 50 | name: 'Timo', 51 | }, 52 | scheme: 'DPoP', 53 | }, 54 | { 55 | payload: { 56 | name: 'again', 57 | }, 58 | scheme: 'DPoP', 59 | }, 60 | { 61 | payload: {}, 62 | scheme: 'Bearer', 63 | }, 64 | ]) 65 | }) 66 | 67 | test('Correctly encodes multiple schemes', () => { 68 | expect( 69 | encodeWwwAuthenticateHeader([ 70 | { 71 | payload: { 72 | Bearer: 'true', 73 | buzz: 'quoted "value!"', 74 | foo: ['bar', 'fuzz'], 75 | name: null, 76 | age: null, 77 | }, 78 | scheme: 'Custom', 79 | }, 80 | { 81 | payload: {}, 82 | scheme: 'Bearer', 83 | }, 84 | { 85 | payload: { 86 | name: 'Timo', 87 | }, 88 | scheme: 'Basic', 89 | }, 90 | { 91 | payload: { 92 | name: 'Timo', 93 | }, 94 | scheme: 'DPoP', 95 | }, 96 | { 97 | payload: { 98 | name: 'again', 99 | }, 100 | scheme: 'DPoP', 101 | }, 102 | { 103 | payload: {}, 104 | scheme: 'Bearer', 105 | }, 106 | ]) 107 | ).toEqual( 108 | 'Custom Bearer="true", buzz="quoted \\"value!\\"", foo="bar", foo="fuzz", name, age, Bearer, Basic name="Timo", DPoP name="Timo", DPoP name="again", Bearer' 109 | ) 110 | }) 111 | }) 112 | -------------------------------------------------------------------------------- /packages/utils/src/array.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Only primitive types allowed 3 | * Must have not duplicate entries (will always return false in this case) 4 | */ 5 | export function arrayEqualsIgnoreOrder( 6 | a: Array, 7 | b: Array 8 | ): boolean { 9 | if (new Set(a).size !== new Set(b).size) return false 10 | if (a.length !== b.length) return false 11 | 12 | return a.every((k) => b.includes(k)) 13 | } 14 | -------------------------------------------------------------------------------- /packages/utils/src/config.ts: -------------------------------------------------------------------------------- 1 | export interface Oid4vcTsConfig { 2 | /** 3 | * Whether to allow insecure http urls. 4 | * 5 | * @default false 6 | */ 7 | allowInsecureUrls: boolean 8 | } 9 | 10 | let GLOBAL_CONFIG: Oid4vcTsConfig = { 11 | allowInsecureUrls: false, 12 | } 13 | 14 | export function setGlobalConfig(config: Oid4vcTsConfig) { 15 | GLOBAL_CONFIG = config 16 | } 17 | 18 | export function getGlobalConfig(): Oid4vcTsConfig { 19 | return GLOBAL_CONFIG 20 | } 21 | -------------------------------------------------------------------------------- /packages/utils/src/content-type.ts: -------------------------------------------------------------------------------- 1 | import type { FetchResponse } from './globals' 2 | 3 | export enum ContentType { 4 | XWwwFormUrlencoded = 'application/x-www-form-urlencoded', 5 | Json = 'application/json', 6 | JwkSet = 'application/jwk-set+json', 7 | OAuthAuthorizationRequestJwt = 'application/oauth-authz-req+jwt', 8 | Jwt = 'application/jwt', 9 | Html = 'text/html', 10 | } 11 | 12 | export function isContentType(contentType: ContentType, value: string) { 13 | return value.toLowerCase().trim().split(';')[0] === contentType 14 | } 15 | 16 | export function isResponseContentType(contentType: ContentType, response: FetchResponse) { 17 | const header = response.headers.get('Content-Type') 18 | if (!header) return false 19 | return isContentType(contentType, header) 20 | } 21 | -------------------------------------------------------------------------------- /packages/utils/src/date.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Get the time in seconds since epoch for a date. 3 | * If date is not provided the current time will be used. 4 | */ 5 | export function dateToSeconds(date?: Date) { 6 | const milliseconds = date?.getTime() ?? Date.now() 7 | 8 | return Math.floor(milliseconds / 1000) 9 | } 10 | 11 | export function addSecondsToDate(date: Date, seconds: number) { 12 | return new Date(date.getTime() + seconds * 1000) 13 | } 14 | -------------------------------------------------------------------------------- /packages/utils/src/encoding.ts: -------------------------------------------------------------------------------- 1 | // biome-ignore lint/style/useNodejsImportProtocol: also imported in other environments 2 | import { Buffer } from 'buffer' 3 | 4 | export function decodeUtf8String(string: string): Uint8Array { 5 | return new Uint8Array(Buffer.from(string, 'utf-8')) 6 | } 7 | 8 | export function encodeToUtf8String(data: Uint8Array) { 9 | return Buffer.from(data).toString('utf-8') 10 | } 11 | 12 | /** 13 | * Also supports base64 url 14 | */ 15 | export function decodeBase64(base64: string): Uint8Array { 16 | return new Uint8Array(Buffer.from(base64, 'base64')) 17 | } 18 | 19 | export function encodeToBase64(data: Uint8Array | string) { 20 | // To make ts happy. Somehow Uint8Array or string is no bueno 21 | if (typeof data === 'string') { 22 | return Buffer.from(data).toString('base64') 23 | } 24 | 25 | return Buffer.from(data).toString('base64') 26 | } 27 | 28 | export function encodeToBase64Url(data: Uint8Array | string) { 29 | return base64ToBase64Url(encodeToBase64(data)) 30 | } 31 | 32 | /** 33 | * The 'buffer' npm library does not support base64url. 34 | */ 35 | function base64ToBase64Url(base64: string) { 36 | return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '') 37 | } 38 | -------------------------------------------------------------------------------- /packages/utils/src/error/FetchError.ts: -------------------------------------------------------------------------------- 1 | export class FetchError extends Error { 2 | public readonly cause?: Error 3 | 4 | public constructor(message: string, { cause }: { cause?: Error } = {}) { 5 | super(`${message}\nCause: ${cause?.message}`) 6 | this.cause = cause 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /packages/utils/src/error/InvalidFetchResponseError.ts: -------------------------------------------------------------------------------- 1 | import { ContentType, isResponseContentType } from '../content-type' 2 | import type { FetchResponse } from '../globals' 3 | 4 | export class InvalidFetchResponseError extends Error { 5 | public readonly response: FetchResponse 6 | 7 | public constructor( 8 | message: string, 9 | public readonly textResponse: string, 10 | response: FetchResponse 11 | ) { 12 | // We don't want to put html content in pages. For other content we take the first 1000 characters 13 | // to prevent ridiculously long errors. 14 | const textResponseMessage = isResponseContentType(ContentType.Html, response) 15 | ? undefined 16 | : textResponse.substring(0, 1000) 17 | 18 | super(textResponseMessage ? `${message}\n${textResponseMessage}` : message) 19 | 20 | this.response = response.clone() 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /packages/utils/src/error/JsonParseError.ts: -------------------------------------------------------------------------------- 1 | export class JsonParseError extends Error { 2 | public constructor(message: string, jsonString: string) { 3 | super(`${message}\n${jsonString}`) 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /packages/utils/src/error/ValidationError.ts: -------------------------------------------------------------------------------- 1 | import type { ZodError, z } from 'zod' 2 | import { formatZodError } from '../zod-error' 3 | 4 | export class ValidationError extends Error { 5 | public zodError: ZodError | undefined 6 | 7 | constructor(message: string, zodError?: z.ZodError) { 8 | super(message) 9 | 10 | const formattedError = formatZodError(zodError) 11 | this.message = `${message}\n${formattedError}` 12 | 13 | Object.defineProperty(this, 'zodError', { 14 | value: zodError, 15 | writable: false, 16 | enumerable: false, 17 | }) 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /packages/utils/src/fetcher.ts: -------------------------------------------------------------------------------- 1 | import type z from 'zod' 2 | import { ContentType, isResponseContentType } from './content-type' 3 | import { FetchError } from './error/FetchError' 4 | import { InvalidFetchResponseError } from './error/InvalidFetchResponseError' 5 | import { type Fetch, URLSearchParams } from './globals' 6 | 7 | /** 8 | * A type utility which represents the function returned 9 | * from createZodFetcher 10 | */ 11 | export type ZodFetcher = ( 12 | schema: Schema, 13 | expectedContentType: ContentType, 14 | ...args: Parameters 15 | ) => Promise<{ response: Awaited>; result?: z.SafeParseReturnType> }> 16 | 17 | /** 18 | * The default fetcher used by createZodFetcher when no 19 | * fetcher is provided. 20 | */ 21 | // biome-ignore lint/style/noRestrictedGlobals: this is the only place where we use the global 22 | const defaultFetcher = fetch 23 | 24 | export function createFetcher(fetcher = defaultFetcher): Fetch { 25 | return (input, init, ...args) => { 26 | return fetcher( 27 | input, 28 | init 29 | ? { 30 | ...init, 31 | // React Native does not seem to handle the toString(). This is hard to catch when running 32 | // tests in Node.JS where this does work correctly. so we handle it here. 33 | body: init.body instanceof URLSearchParams ? init.body.toString() : init.body, 34 | } 35 | : undefined, 36 | ...args 37 | ).catch((error) => { 38 | throw new FetchError(`Unknown error occurred during fetch to '${input}'`, { cause: error }) 39 | }) 40 | } 41 | } 42 | 43 | /** 44 | * Creates a `fetchWithZod` function that takes in a schema of 45 | * the expected response, and the arguments to the fetcher 46 | * you provided. 47 | * 48 | * @example 49 | * 50 | * const fetchWithZod = createZodFetcher((url) => { 51 | * return fetch(url).then((res) => res.json()); 52 | * }); 53 | * 54 | * const response = await fetchWithZod( 55 | * z.object({ 56 | * hello: z.string(), 57 | * }), 58 | * "https://example.com", 59 | * ); 60 | */ 61 | export function createZodFetcher(fetcher?: Fetch): ZodFetcher { 62 | return async (schema, expectedContentType, ...args) => { 63 | const response = await createFetcher(fetcher)(...args) 64 | 65 | if (response.ok && !isResponseContentType(expectedContentType, response)) { 66 | throw new InvalidFetchResponseError( 67 | `Expected response to match content type '${expectedContentType}', but received '${response.headers.get('Content-Type')}'`, 68 | await response.clone().text(), 69 | response 70 | ) 71 | } 72 | 73 | if (expectedContentType === ContentType.OAuthAuthorizationRequestJwt) { 74 | return { 75 | response, 76 | result: response.ok ? schema.safeParse(await response.text()) : undefined, 77 | } 78 | } 79 | 80 | return { 81 | response, 82 | result: response.ok ? schema.safeParse(await response.json()) : undefined, 83 | } 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /packages/utils/src/globals.ts: -------------------------------------------------------------------------------- 1 | // Theses types are provided by the platform (so @types/node, @types/react-native, DOM) 2 | 3 | // biome-ignore lint/style/noRestrictedGlobals: 4 | const _URL = URL 5 | 6 | // biome-ignore lint/style/noRestrictedGlobals: 7 | const _URLSearchParams = URLSearchParams 8 | 9 | // biome-ignore lint/style/noRestrictedGlobals: 10 | export type Fetch = typeof fetch 11 | // biome-ignore lint/style/noRestrictedGlobals: 12 | export type FetchResponse = Response 13 | // biome-ignore lint/style/noRestrictedGlobals: 14 | const _Headers = Headers as typeof globalThis.Headers 15 | export type FetchHeaders = globalThis.Headers 16 | export type FetchRequestInit = RequestInit 17 | 18 | export { _URLSearchParams as URLSearchParams, _URL as URL, _Headers as Headers } 19 | -------------------------------------------------------------------------------- /packages/utils/src/index.ts: -------------------------------------------------------------------------------- 1 | export { 2 | Headers, 3 | URL, 4 | URLSearchParams, 5 | type Fetch, 6 | type FetchHeaders, 7 | type FetchRequestInit, 8 | type FetchResponse, 9 | } from './globals' 10 | 11 | export { InvalidFetchResponseError } from './error/InvalidFetchResponseError' 12 | export { JsonParseError } from './error/JsonParseError' 13 | export { ValidationError } from './error/ValidationError' 14 | 15 | export { arrayEqualsIgnoreOrder } from './array' 16 | export { getGlobalConfig, setGlobalConfig, type Oid4vcTsConfig } from './config' 17 | export { ContentType, isContentType, isResponseContentType } from './content-type' 18 | export { addSecondsToDate, dateToSeconds } from './date' 19 | export { 20 | decodeBase64, 21 | decodeUtf8String, 22 | encodeToBase64, 23 | encodeToBase64Url, 24 | encodeToUtf8String, 25 | } from './encoding' 26 | export { mergeDeep } from './object' 27 | export { 28 | parseWithErrorHandling, 29 | stringToJsonWithErrorHandling, 30 | parseIfJson, 31 | type BaseSchema, 32 | type InferOutputUnion, 33 | } from './parse' 34 | export { joinUriParts } from './path' 35 | export type { Optional, OrPromise, Simplify, StringWithAutoCompletion } from './type' 36 | export { getQueryParams, objectToQueryParams } from './url' 37 | export { type ZodFetcher, createZodFetcher, createFetcher } from './fetcher' 38 | export { 39 | type HttpMethod, 40 | zHttpMethod, 41 | zHttpsUrl, 42 | zInteger, 43 | zIs, 44 | zStringToJson, 45 | } from './validation' 46 | export { formatZodError } from './zod-error' 47 | export { 48 | encodeWwwAuthenticateHeader, 49 | parseWwwAuthenticateHeader, 50 | type WwwAuthenticateHeaderChallenge, 51 | } from './www-authenticate' 52 | 53 | export { isObject } from './object' 54 | -------------------------------------------------------------------------------- /packages/utils/src/object.ts: -------------------------------------------------------------------------------- 1 | export function isObject(item: unknown): item is Record { 2 | return item != null && typeof item === 'object' && !Array.isArray(item) 3 | } 4 | 5 | /** 6 | * Deep merge two objects. 7 | * @param target 8 | * @param ...sources 9 | */ 10 | export function mergeDeep(target: unknown, ...sources: Array): unknown { 11 | if (!sources.length) return target 12 | const source = sources.shift() 13 | 14 | if (isObject(target) && isObject(source)) { 15 | for (const key in source) { 16 | if (isObject(source[key])) { 17 | if (!target[key]) Object.assign(target, { [key]: {} }) 18 | mergeDeep(target[key], source[key]) 19 | } else { 20 | Object.assign(target, { [key]: source[key] }) 21 | } 22 | } 23 | } 24 | 25 | return mergeDeep(target, ...sources) 26 | } 27 | -------------------------------------------------------------------------------- /packages/utils/src/parse.ts: -------------------------------------------------------------------------------- 1 | import type z from 'zod' 2 | import { JsonParseError } from './error/JsonParseError' 3 | import { ValidationError } from './error/ValidationError' 4 | 5 | export type BaseSchema = z.ZodTypeAny 6 | // biome-ignore lint/suspicious/noExplicitAny: 7 | export type InferOutputUnion = { 8 | [K in keyof T]: z.infer 9 | }[number] 10 | 11 | export function stringToJsonWithErrorHandling(string: string, errorMessage?: string) { 12 | try { 13 | return JSON.parse(string) 14 | } catch (error) { 15 | throw new JsonParseError(errorMessage ?? 'Unable to parse string to JSON.', string) 16 | } 17 | } 18 | 19 | export function parseIfJson(data: T): T | Record { 20 | if (typeof data !== 'string') { 21 | return data 22 | } 23 | 24 | try { 25 | // Try to parse the string as JSON 26 | return JSON.parse(data) 27 | } catch (error) {} 28 | 29 | return data 30 | } 31 | 32 | export function parseWithErrorHandling( 33 | schema: Schema, 34 | data: unknown, 35 | customErrorMessage?: string 36 | ): z.infer { 37 | const parseResult = schema.safeParse(data) 38 | 39 | if (!parseResult.success) { 40 | throw new ValidationError( 41 | customErrorMessage ?? `Error validating schema with data ${JSON.stringify(data)}`, 42 | parseResult.error 43 | ) 44 | } 45 | 46 | return parseResult.data 47 | } 48 | -------------------------------------------------------------------------------- /packages/utils/src/path.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Combine multiple uri parts into a single uri taking into account slashes. 3 | * 4 | * @param parts the parts to combine 5 | * @returns the combined url 6 | */ 7 | export function joinUriParts(base: string, parts: string[]) { 8 | if (parts.length === 0) return base 9 | 10 | // take base without trailing / 11 | let combined = base.trim() 12 | combined = base.endsWith('/') ? base.slice(0, base.length - 1) : base 13 | 14 | for (const part of parts) { 15 | // Remove leading and trailing / 16 | let strippedPart = part.trim() 17 | strippedPart = strippedPart.startsWith('/') ? strippedPart.slice(1) : strippedPart 18 | strippedPart = strippedPart.endsWith('/') ? strippedPart.slice(0, strippedPart.length - 1) : strippedPart 19 | 20 | // Don't want to add if empty 21 | if (strippedPart === '') continue 22 | 23 | combined += `/${strippedPart}` 24 | } 25 | 26 | return combined 27 | } 28 | -------------------------------------------------------------------------------- /packages/utils/src/type.ts: -------------------------------------------------------------------------------- 1 | export type Simplify = { [KeyType in keyof T]: T[KeyType] } & {} 2 | export type Optional = Omit & Partial> 3 | export type StringWithAutoCompletion = T | (string & {}) 4 | export type OrPromise = T | Promise 5 | -------------------------------------------------------------------------------- /packages/utils/src/url.ts: -------------------------------------------------------------------------------- 1 | import { URL, URLSearchParams } from './globals' 2 | 3 | export function getQueryParams(url: string) { 4 | const parsedUrl = new URL(url) 5 | const searchParams = new URLSearchParams(parsedUrl.search) 6 | const params: Record = {} 7 | 8 | searchParams.forEach((value, key) => { 9 | params[key] = value 10 | }) 11 | 12 | return params 13 | } 14 | 15 | export function objectToQueryParams(object: Record): InstanceType { 16 | const params = new URLSearchParams() 17 | 18 | for (const [key, value] of Object.entries(object)) { 19 | if (value != null) { 20 | params.append(key, typeof value === 'object' ? JSON.stringify(value) : String(value)) 21 | } 22 | } 23 | 24 | return params 25 | } 26 | -------------------------------------------------------------------------------- /packages/utils/src/validation.ts: -------------------------------------------------------------------------------- 1 | import z from 'zod' 2 | import { getGlobalConfig } from './config' 3 | 4 | export const zHttpsUrl = z 5 | .string() 6 | .url() 7 | .refine( 8 | (url) => { 9 | const { allowInsecureUrls } = getGlobalConfig() 10 | return allowInsecureUrls ? url.startsWith('http://') || url.startsWith('https://') : url.startsWith('https://') 11 | }, 12 | { message: 'url must be an https:// url' } 13 | ) 14 | 15 | export const zInteger = z.number().int() 16 | 17 | export const zHttpMethod = z.enum(['GET', 'POST', 'PUT', 'DELETE', 'HEAD', 'OPTIONS', 'TRACE', 'CONNECT', 'PATCH']) 18 | export type HttpMethod = z.infer 19 | 20 | export const zStringToJson = z.string().transform((string, ctx) => { 21 | try { 22 | return JSON.parse(string) 23 | } catch (error) { 24 | ctx.addIssue({ 25 | code: 'custom', 26 | message: 'Expected a JSON string, but could not parse the string to JSON', 27 | }) 28 | return z.NEVER 29 | } 30 | }) 31 | 32 | export const zIs = (schema: Schema, data: unknown): data is z.infer => 33 | schema.safeParse(data).success 34 | -------------------------------------------------------------------------------- /packages/utils/src/zod-error.ts: -------------------------------------------------------------------------------- 1 | import type z from 'zod' 2 | import { type ZodIssue, ZodIssueCode } from 'zod' 3 | 4 | /** 5 | * Some code comes from `zod-validation-error` package (MIT License) and 6 | * was slightly simplified to fit our needs. 7 | */ 8 | const constants = { 9 | // biome-ignore lint/suspicious/noMisleadingCharacterClass: expected 10 | identifierRegex: /[$_\p{ID_Start}][$\u200c\u200d\p{ID_Continue}]*/u, 11 | unionSeparator: ', or ', 12 | issueSeparator: '\n\t- ', 13 | } 14 | 15 | function escapeQuotes(str: string): string { 16 | return str.replace(/"/g, '\\"') 17 | } 18 | 19 | function joinPath(path: Array): string { 20 | if (path.length === 1) { 21 | return path[0].toString() 22 | } 23 | 24 | return path.reduce((acc, item) => { 25 | // handle numeric indices 26 | if (typeof item === 'number') { 27 | return `${acc}[${item.toString()}]` 28 | } 29 | 30 | // handle quoted values 31 | if (item.includes('"')) { 32 | return `${acc}["${escapeQuotes(item)}"]` 33 | } 34 | 35 | // handle special characters 36 | if (!constants.identifierRegex.test(item)) { 37 | return `${acc}["${item}"]` 38 | } 39 | 40 | // handle normal values 41 | const separator = acc.length === 0 ? '' : '.' 42 | return acc + separator + item 43 | }, '') 44 | } 45 | function getMessageFromZodIssue(issue: ZodIssue): string { 46 | if (issue.code === ZodIssueCode.invalid_union) { 47 | return getMessageFromUnionErrors(issue.unionErrors) 48 | } 49 | 50 | if (issue.code === ZodIssueCode.invalid_arguments) { 51 | return [issue.message, ...issue.argumentsError.issues.map((issue) => getMessageFromZodIssue(issue))].join( 52 | constants.issueSeparator 53 | ) 54 | } 55 | 56 | if (issue.code === ZodIssueCode.invalid_return_type) { 57 | return [issue.message, ...issue.returnTypeError.issues.map((issue) => getMessageFromZodIssue(issue))].join( 58 | constants.issueSeparator 59 | ) 60 | } 61 | 62 | if (issue.path.length !== 0) { 63 | // handle array indices 64 | if (issue.path.length === 1) { 65 | const identifier = issue.path[0] 66 | 67 | if (typeof identifier === 'number') { 68 | return `${issue.message} at index ${identifier}` 69 | } 70 | } 71 | 72 | return `${issue.message} at "${joinPath(issue.path)}"` 73 | } 74 | 75 | return issue.message 76 | } 77 | 78 | function getMessageFromUnionErrors(unionErrors: z.ZodError[]): string { 79 | return unionErrors 80 | .reduce((acc, zodError) => { 81 | const newIssues = zodError.issues.map((issue) => getMessageFromZodIssue(issue)).join(constants.issueSeparator) 82 | 83 | if (!acc.includes(newIssues)) acc.push(newIssues) 84 | 85 | return acc 86 | }, []) 87 | .join(constants.unionSeparator) 88 | } 89 | 90 | export function formatZodError(error?: z.ZodError): string { 91 | if (!error) return '' 92 | 93 | return `\t- ${error?.issues.map((issue) => getMessageFromZodIssue(issue)).join(constants.issueSeparator)}` 94 | } 95 | -------------------------------------------------------------------------------- /packages/utils/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "build", 5 | "lib": ["ES2020", "DOM"] 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | - packages/** 3 | 4 | catalog: 5 | buffer: ^6.0.3 6 | jose: ^6.0.10 7 | zod: ^3.24.2 8 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "Node16", 4 | "target": "ES2020", 5 | "declaration": true, 6 | "sourceMap": true, 7 | "strict": true, 8 | "skipLibCheck": true, 9 | "noEmitOnError": true, 10 | "lib": ["ES2020", "DOM.Iterable", "DOM"], 11 | "types": [], 12 | "esModuleInterop": true, 13 | "allowSyntheticDefaultImports": true 14 | }, 15 | "exclude": ["dist"] 16 | } 17 | -------------------------------------------------------------------------------- /vite.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | test: { 3 | watch: false, 4 | }, 5 | } 6 | --------------------------------------------------------------------------------