From ed65c04e3e9273218c249e9973ddac3286834d16 Mon Sep 17 00:00:00 2001 From: Ilayda Cansin Koc Date: Sun, 7 Jul 2024 19:38:55 +0000 Subject: [PATCH 1/7] run npm install and update the packages Signed-off-by: Ilayda Cansin Koc --- vclogin/package-lock.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/vclogin/package-lock.json b/vclogin/package-lock.json index 2cc38bc..a1facbc 100644 --- a/vclogin/package-lock.json +++ b/vclogin/package-lock.json @@ -1,12 +1,12 @@ { "name": "ssi-to-oidc-bridge", - "version": "0.1.1", + "version": "1.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ssi-to-oidc-bridge", - "version": "0.1.1", + "version": "1.2.0", "dependencies": { "@material-tailwind/react": "^2.0.3", "@ory/hydra-client": "^2.2.0", From 48e9f15798fa72d98a6c908842fba54bbc153674 Mon Sep 17 00:00:00 2001 From: Ilayda Cansin Koc Date: Sun, 7 Jul 2024 19:40:37 +0000 Subject: [PATCH 2/7] add types for input descriptor and presentation definition add functions to get metadata and get token introduce dynamic endpoints for vclogin update generatePresentationDefinition to use dynamic endpoints Signed-off-by: Ilayda Cansin Koc --- vclogin/lib/generatePresentationDefinition.ts | 30 +++-- vclogin/lib/getMetadata.ts | 47 +++++++ vclogin/lib/getToken.ts | 58 ++++++++ .../pages/api/dynamic/clientMetadataById.ts | 31 +++++ .../api/dynamic/createTempAuthorization.ts | 26 ++++ vclogin/pages/api/dynamic/getAuthResponse.ts | 26 ++++ vclogin/pages/api/dynamic/getQRCodeString.ts | 21 +++ .../api/dynamic/presentCredentialById.ts | 126 ++++++++++++++++++ vclogin/types/InputDescriptor.ts | 19 +++ vclogin/types/PresentationDefinition.ts | 21 +++ 10 files changed, 396 insertions(+), 9 deletions(-) create mode 100644 vclogin/lib/getMetadata.ts create mode 100644 vclogin/lib/getToken.ts create mode 100644 vclogin/pages/api/dynamic/clientMetadataById.ts create mode 100644 vclogin/pages/api/dynamic/createTempAuthorization.ts create mode 100644 vclogin/pages/api/dynamic/getAuthResponse.ts create mode 100644 vclogin/pages/api/dynamic/getQRCodeString.ts create mode 100644 vclogin/pages/api/dynamic/presentCredentialById.ts create mode 100644 vclogin/types/InputDescriptor.ts create mode 100644 vclogin/types/PresentationDefinition.ts diff --git a/vclogin/lib/generatePresentationDefinition.ts b/vclogin/lib/generatePresentationDefinition.ts index 2edb7f7..30b737b 100644 --- a/vclogin/lib/generatePresentationDefinition.ts +++ b/vclogin/lib/generatePresentationDefinition.ts @@ -2,9 +2,11 @@ * Copyright 2024 Software Engineering for Business Information Systems (sebis) . * SPDX-License-Identifier: MIT */ - +import { InputDescriptor } from "@/types/InputDescriptor"; +import { PresentationDefinition } from "@/types/PresentationDefinition"; import { LoginPolicy } from "@/types/LoginPolicy"; import { promises as fs } from "fs"; +import { logger } from "@/config/logger"; var inputDescriptorOverride: any = undefined; if (process.env.PEX_DESCRIPTOR_OVERRIDE) { @@ -15,12 +17,16 @@ if (process.env.PEX_DESCRIPTOR_OVERRIDE) { ); } -export const generatePresentationDefinition = (policy: LoginPolicy) => { +export const generatePresentationDefinition = ( + policy: LoginPolicy, + incrAuthInputDescriptor?: InputDescriptor[], +) => { if (policy === undefined) throw Error( "A policy must be specified to generate a presentation definition", ); - var pd: any = { + + var pd: PresentationDefinition = { format: { ldp_vc: { proof_type: [ @@ -40,29 +46,35 @@ export const generatePresentationDefinition = (policy: LoginPolicy) => { }, }, id: crypto.randomUUID(), - name: "SSI-to-OIDC Bridge", + name: "VC Login Service", purpose: "Sign-in", - input_descriptors: [] as any[], + input_descriptors: [] as InputDescriptor[], }; - if (inputDescriptorOverride) { + if (inputDescriptorOverride && !incrAuthInputDescriptor) { pd.input_descriptors = inputDescriptorOverride; return pd; + } else if (incrAuthInputDescriptor) { + pd.input_descriptors = incrAuthInputDescriptor; + logger.debug( + "Using input descriptor override for incremental authorization", + pd, + ); + return pd; } for (let expectation of policy) { if (expectation.patterns.length > 1) { let req = { - name: "Group " + expectation.credentialId, rule: "pick", count: 1, from: "group_" + expectation.credentialId, }; - pd.submission_requirements.push(req); + pd.submission_requirements!.push(req); } for (let pattern of expectation.patterns) { - let descr: any = { + let descr: InputDescriptor = { id: expectation.credentialId, purpose: "Sign-in", name: "Input descriptor for " + expectation.credentialId, diff --git a/vclogin/lib/getMetadata.ts b/vclogin/lib/getMetadata.ts new file mode 100644 index 0000000..6e4445b --- /dev/null +++ b/vclogin/lib/getMetadata.ts @@ -0,0 +1,47 @@ +export const getMetadata = (redirect_uris: string[]) => { + const metadata = { + scopes_supported: ["openid"], + response_types_supported: ["id_token", "vp_token"], + response_modes_supported: ["query"], + subject_types_supported: ["public"], + id_token_signing_alg_values_supported: [ + "ES256", + "ES256k", + "EdDSA", + "RS256", + ], + request_object_signing_alg_values_supported: [ + "ES256", + "ES256K", + "EdDSA", + "RS256", + ], + vp_formats: { + jwt_vp: { + alg_values_supported: ["ES256", "ES256K", "EdDSA", "RS256"], + }, + jwt_vc: { + alg_values_supported: ["ES256", "ES256K", "EdDSA", "RS256"], + }, + }, + subject_syntax_types_supported: [ + "did:key", + "did:ebsi", + "did:tz", + "did:pkh", + "did:key", + "did:ethr", + ], + subject_syntax_types_discriminations: [ + "did:key:jwk_jcs-pub", + "did:ebsi:v1", + ], + subject_trust_frameworks_supported: ["ebsi"], + id_token_types_supported: ["subject_signed_id_token"], + client_name: "VP Login Service", + request_uri_parameter_supported: true, + request_parameter_supported: false, + redirect_uris, + }; + return metadata; +}; diff --git a/vclogin/lib/getToken.ts b/vclogin/lib/getToken.ts new file mode 100644 index 0000000..75993bd --- /dev/null +++ b/vclogin/lib/getToken.ts @@ -0,0 +1,58 @@ +import { PresentationDefinition } from "@/types/PresentationDefinition"; +import { keyToDID, keyToVerificationMethod } from "@spruceid/didkit-wasm-node"; +import * as jose from "jose"; +import { NextApiResponse } from "next/types"; +import { logger } from "@/config/logger"; + +/** + * + * @param challenge + * @param client_metadata_uri + * @param response_uri + * @param presentation_definition + * @param res + */ +export const getToken = async ( + challenge: string, + client_metadata_uri: string, + response_uri: string, + presentation_definition: PresentationDefinition, + res: NextApiResponse, +) => { + const did = await keyToDID("key", process.env.DID_KEY_JWK!); + const verificationMethod = await keyToVerificationMethod( + "key", + process.env.DID_KEY_JWK!, + ); + const payload = { + client_id: did, + client_id_scheme: "did", + client_metadata_uri, + nonce: challenge, + presentation_definition, + response_mode: "direct_post", + response_type: "vp_token", + response_uri, + state: challenge, + }; + const privateKey = await jose.importJWK( + JSON.parse(process.env.DID_KEY_JWK!), + "EdDSA", + ); + const token = await new jose.SignJWT(payload) + .setProtectedHeader({ + alg: "EdDSA", + kid: verificationMethod, + typ: "JWT", + }) + .setIssuedAt() + .setIssuer(did) + .setAudience("https://self-issued.me/v2") // by definition + .setExpirationTime("1 hour") + .sign(privateKey) + .catch((err) => { + logger.error(err); + res.status(500).end(); + }); + return token; +}; diff --git a/vclogin/pages/api/dynamic/clientMetadataById.ts b/vclogin/pages/api/dynamic/clientMetadataById.ts new file mode 100644 index 0000000..579937a --- /dev/null +++ b/vclogin/pages/api/dynamic/clientMetadataById.ts @@ -0,0 +1,31 @@ +/** + * Copyright 2024 Software Engineering for Business Information Systems (sebis) . + * SPDX-License-Identifier: MIT + */ + +import { getMetadata } from "@/lib/getMetadata"; +import type { NextApiRequest, NextApiResponse } from "next"; +import { logger } from "@/config/logger"; + +export default async function handler( + req: NextApiRequest, + res: NextApiResponse, +) { + try { + const { method } = req; + if (method === "GET") { + logger.debug("METADATA BY ID API GET"); + const metadata = getMetadata([ + process.env.NEXT_PUBLIC_INTERNET_URL + + "/api/dynamic/presentCredentialById", + ]); + res.status(200).json(metadata); + } else { + res.status(500).end(); + } + } catch (e) { + res.status(500).end(); + } +} + +export const config = { api: { bodyParser: false } }; diff --git a/vclogin/pages/api/dynamic/createTempAuthorization.ts b/vclogin/pages/api/dynamic/createTempAuthorization.ts new file mode 100644 index 0000000..5da0f1d --- /dev/null +++ b/vclogin/pages/api/dynamic/createTempAuthorization.ts @@ -0,0 +1,26 @@ +import { NextApiRequest, NextApiResponse } from "next"; +import crypto from "crypto"; +import { redisSet } from "@/config/redis"; + +export default async function handler( + req: NextApiRequest, + res: NextApiResponse, +) { + //Get Policy from request body + const { policy, inputDescriptor } = req.body; + + try { + // store policy in redis with uuid as key + const uuid = crypto.randomUUID(); + redisSet(uuid + "_policy", JSON.stringify(policy), 300); + + //check if inputDescriptor is present + if (inputDescriptor) { + //store inputDescriptor in redis with uuid as key + redisSet(uuid + "_inputDescriptor", JSON.stringify(inputDescriptor), 300); + } + return res.status(200).json({ uuid }); + } catch (error) { + return res.status(500).json({ redirect: "/error" }); + } +} diff --git a/vclogin/pages/api/dynamic/getAuthResponse.ts b/vclogin/pages/api/dynamic/getAuthResponse.ts new file mode 100644 index 0000000..855ebe9 --- /dev/null +++ b/vclogin/pages/api/dynamic/getAuthResponse.ts @@ -0,0 +1,26 @@ +import { NextApiRequest, NextApiResponse } from "next"; +import { logger } from "@/config/logger"; +import { redisGet } from "@/config/redis"; + +export default async function handler( + req: NextApiRequest, + res: NextApiResponse, +) { + //read uuid from query params + const uuid = req.query["uuid"]; + logger.debug("uuid: ", uuid); + + // Read auth_res from redis and check if it matches the uuid + + //auth_res kept in redis like auth_res:uuid, read auth_res using uuid + const auth_res = await redisGet(uuid + "_auth-res"); + + if (auth_res) { + //if auth_res found, return it along claims + const claims = await redisGet(uuid + "_claims"); + res.status(200).json({ auth_res, claims }); + } else { + //if auth_res not found, return error + res.status(200).json({ auth_res: "error_not_found" }); + } +} diff --git a/vclogin/pages/api/dynamic/getQRCodeString.ts b/vclogin/pages/api/dynamic/getQRCodeString.ts new file mode 100644 index 0000000..bd38e7f --- /dev/null +++ b/vclogin/pages/api/dynamic/getQRCodeString.ts @@ -0,0 +1,21 @@ +import { NextApiRequest, NextApiResponse } from "next"; + +export default async function handler( + req: NextApiRequest, + res: NextApiResponse, +) { + const { userId, uuid } = req.body; + + //Generate QR Code String from UUID + const qrCodeString = + "openid-vc://?client_id=" + + userId + + "&request_uri=" + + encodeURIComponent( + process.env.EXTERNAL_URL + + "/api/dynamic/presentCredentialById?login_id=" + + uuid, + ); + + return res.status(200).json({ qrCodeString }); +} diff --git a/vclogin/pages/api/dynamic/presentCredentialById.ts b/vclogin/pages/api/dynamic/presentCredentialById.ts new file mode 100644 index 0000000..bfc4beb --- /dev/null +++ b/vclogin/pages/api/dynamic/presentCredentialById.ts @@ -0,0 +1,126 @@ +import { NextApiRequest, NextApiResponse } from "next"; +import { generatePresentationDefinition } from "@/lib/generatePresentationDefinition"; +import { LoginPolicy } from "@/types/LoginPolicy"; +import { extractClaims, isTrustedPresentation } from "@/lib/extractClaims"; +import { verifyAuthenticationPresentation } from "@/lib/verifyPresentation"; +import { getToken } from "@/lib/getToken"; +import { logger } from "@/config/logger"; +import { redisSet, redisGet } from "@/config/redis"; + +const getHandler = async (req: NextApiRequest, res: NextApiResponse) => { + logger.debug("LOGIN API GET BY ID"); + + // Get login_id from query + const uuid = req.query["login_id"]; + + // fetch policy from redis using uuid + const policy = await redisGet(uuid + "_policy"); + + // fetch inputDescriptor from redis using uuid + const inputDescriptor = await redisGet(uuid + "_inputDescriptor"); + logger.debug("inputDescriptor: ", JSON.parse(inputDescriptor!)); + + //if policy is found + if (policy) { + const policyObject = JSON.parse(policy) as LoginPolicy; + + // generate presentation definition using policy + // and inputDescriptor if it exists + const presentation_definition = generatePresentationDefinition( + policyObject, + inputDescriptor ? JSON.parse(inputDescriptor) : undefined, + ); + + const challenge = req.query["login_id"]; + + if (challenge) { + const token = await getToken( + challenge as string, + process.env.EXTERNAL_URL + "/api/dynamic/clientMetadataById", + process.env.EXTERNAL_URL + "/api/dynamic/presentCredentialById", + presentation_definition, + res, + ); + + res + .status(200) + .appendHeader("Content-Type", "application/oauth-authz-req+jwt") + .send(token); + } + } else { + res.status(500).end(); + return; + } +}; + +const postHandler = async (req: NextApiRequest, res: NextApiResponse) => { + logger.debug("LOGIN API POST BY ID"); + + // Parse the JSON string into a JavaScript object + const presentation = JSON.parse(req.body.vp_token); + logger.debug("Presentation: \n", req.body.vp_token); + + const uuid = presentation["proof"]["challenge"]; + const policy = await redisGet(uuid + "_policy"); + + if (policy) { + const policyObject = JSON.parse(policy) as LoginPolicy; + + // Constants for Redis to store the authentication result + const MAX_AGE = 20 * 60; + + // Verify the presentation and the status of the credential + if (await verifyAuthenticationPresentation(presentation)) { + logger.debug("Presentation valid"); + // Evaluate if the VP should be trusted + if (await isTrustedPresentation(presentation, policyObject)) { + logger.debug("Presentation verified"); + + // Get the user claims when the presentation is trusted + const userClaims = await extractClaims(presentation, policyObject); + logger.debug(userClaims); + + // Store the authentication result in Redis + redisSet(uuid + "_auth-res", "success", MAX_AGE); + + // Store the user claims in Redis + redisSet(uuid + "_claims", JSON.stringify(userClaims.tokenId), MAX_AGE); + } else { + logger.debug("Presentation not trusted"); + + redisSet("auth_res:" + uuid, "error_presentation_not_trused", MAX_AGE); + // Wallet gets an error message + res.status(500).end(); + return; + } + } else { + logger.debug("Presentation invalid"); + redisSet("auth_res:" + uuid, "error_invalid_presentation", MAX_AGE); + res.status(500).end(); + return; + } + + // Wallet gets 200 status code + res.status(200).end(); + } +}; + +const handlers: any = { + POST: postHandler, + GET: getHandler, +}; + +export default async function handler( + req: NextApiRequest, + res: NextApiResponse, //todo look for separate handles +) { + try { + const { method } = req; + if (method) { + const execute = handlers[method.toUpperCase()]; + return await execute(req, res); + } + } catch (error) { + res.status(500).end(); + } +} diff --git a/vclogin/types/InputDescriptor.ts b/vclogin/types/InputDescriptor.ts new file mode 100644 index 0000000..1c5b92a --- /dev/null +++ b/vclogin/types/InputDescriptor.ts @@ -0,0 +1,19 @@ +type Fields = { + path: string[]; + filter?: { + type: string; + pattern: string; + }; +}; + +type Constraints = { + fields?: Fields[]; +}; + +export type InputDescriptor = { + id: string; + purpose: string; + name: string; + group?: string[]; + constraints: Constraints; +}; diff --git a/vclogin/types/PresentationDefinition.ts b/vclogin/types/PresentationDefinition.ts new file mode 100644 index 0000000..efda8e9 --- /dev/null +++ b/vclogin/types/PresentationDefinition.ts @@ -0,0 +1,21 @@ +import { InputDescriptor } from "./InputDescriptor"; + +export type PresentationDefinition = { + format: { + ldp_vc: { + proof_type: string[]; + }; + ldp_vp: { + proof_type: string[]; + }; + }; + id: string; + name: string; + purpose: string; + input_descriptors: InputDescriptor[]; + submission_requirements?: { + rule: string; + count: number; + from: string; + }[]; +}; From d99313ee1ea969259a0d7afe225aab6f19faf9ba Mon Sep 17 00:00:00 2001 From: Ilayda Cansin Koc Date: Sun, 7 Jul 2024 19:59:58 +0000 Subject: [PATCH 3/7] add test data for VerifiableID VC from Altme policy Signed-off-by: Ilayda Cansin Koc --- .../pex/descriptorVerifiableIDFromAltme.json | 18 ++++++++++++++++ .../policies/acceptVerifiableIDFromAltme.json | 21 +++++++++++++++++++ 2 files changed, 39 insertions(+) create mode 100644 vclogin/__tests__/testdata/pex/descriptorVerifiableIDFromAltme.json create mode 100644 vclogin/__tests__/testdata/policies/acceptVerifiableIDFromAltme.json diff --git a/vclogin/__tests__/testdata/pex/descriptorVerifiableIDFromAltme.json b/vclogin/__tests__/testdata/pex/descriptorVerifiableIDFromAltme.json new file mode 100644 index 0000000..5c78bc2 --- /dev/null +++ b/vclogin/__tests__/testdata/pex/descriptorVerifiableIDFromAltme.json @@ -0,0 +1,18 @@ +[ + { + "id": "verifiableId", + "name": "Input descriptor for login credential", + "purpose": "Sign-in to MVG", + "constraints": { + "fields": [ + { + "path": ["$.credentialSubject.type"], + "filter": { + "type": "string", + "pattern": "VerifiableId" + } + } + ] + } + } +] diff --git a/vclogin/__tests__/testdata/policies/acceptVerifiableIDFromAltme.json b/vclogin/__tests__/testdata/policies/acceptVerifiableIDFromAltme.json new file mode 100644 index 0000000..9b587b6 --- /dev/null +++ b/vclogin/__tests__/testdata/policies/acceptVerifiableIDFromAltme.json @@ -0,0 +1,21 @@ +[ + { + "credentialId": "1", + "patterns": [ + { + "issuer": "did:web:app.altme.io:issuer", + "claims": [ + { + "claimPath": "$.credentialSubject.dateOfBirth" + }, + { + "claimPath": "$.credentialSubject.firstName" + }, + { + "claimPath": "$.credentialSubject.familyName" + } + ] + } + ] + } +] From d79b424e83a96a32c936557afedbddb8781b6c42 Mon Sep 17 00:00:00 2001 From: Ilayda Cansin Koc Date: Sun, 7 Jul 2024 20:18:44 +0000 Subject: [PATCH 4/7] update test_client.sh to use next auth redirect Signed-off-by: Ilayda Cansin Koc --- test_client.sh | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/test_client.sh b/test_client.sh index 3c472a6..9af1824 100755 --- a/test_client.sh +++ b/test_client.sh @@ -8,14 +8,12 @@ client=$(docker run --rm -it \ --grant-type authorization_code \ --response-type token,code,id_token \ --scope openid \ - --redirect-uri http://localhost:9010/callback \ + --redirect-uri "http://localhost:3000/api/auth/callback/oidc" \ -e http://hydra:4445 \ --format json ) echo $client -client_id=$(echo $client | jq -r '.client_id') - docker run --rm -it \ --network ory-hydra-net \ -p 9010:9010 \ @@ -24,8 +22,8 @@ docker run --rm -it \ --port 9010 \ --client-id $client_id \ --client-secret some-secret \ - --redirect http://localhost:9010/callback \ + --redirect "http://localhost:3000/api/auth/callback/oidc" \ --scope openid \ --auth-url http://localhost:5004/oauth2/auth \ - --token-url http://hydra:4444/oauth2/token \ + --token-url http://localhost:5004/oauth2/token \ -e http://hydra:4444 From 586376dbbceb3adbe321b8f61506d9ec8305bb7e Mon Sep 17 00:00:00 2001 From: Ilayda Cansin Koc Date: Sun, 7 Jul 2024 21:19:06 +0000 Subject: [PATCH 5/7] add middleware.ts to handle unauthenticated requests Signed-off-by: Ilayda Cansin Koc --- vclogin/middleware.ts | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 vclogin/middleware.ts diff --git a/vclogin/middleware.ts b/vclogin/middleware.ts new file mode 100644 index 0000000..31972a7 --- /dev/null +++ b/vclogin/middleware.ts @@ -0,0 +1,20 @@ +import { NextResponse } from "next/server"; +import type { NextRequest } from "next/server"; + +const protectedPaths = [ + "/api/dynamic/createTempAuthorization", + "/api/dynamic/getAuthResponse", + "/api/dynamic/getQRCodeString", +]; + +export function middleware(req: NextRequest) { + const authHeader = req.headers.get("Authorization"); + const path = req.nextUrl.pathname; + const apiKey = authHeader?.split(" ")[1]; + if (protectedPaths.includes(path) && apiKey === process.env.API_KEY) { + return NextResponse.next(); + } else if (protectedPaths.includes(path) && apiKey !== process.env.API_KEY) { + return new Response("Unauthorized", { status: 401 }); + } + return NextResponse.next(); +} From 0e0d7245967bac001bf3b2321da981ca640c5317 Mon Sep 17 00:00:00 2001 From: Ilayda Cansin Koc Date: Sun, 7 Jul 2024 21:20:00 +0000 Subject: [PATCH 6/7] edit extractClaims.ts to handle policy constraints check Signed-off-by: Ilayda Cansin Koc --- vclogin/config/loginPolicy.ts | 27 +++++---- vclogin/lib/extractClaims.ts | 100 ++++++++++++++++++++++++++++------ 2 files changed, 98 insertions(+), 29 deletions(-) diff --git a/vclogin/config/loginPolicy.ts b/vclogin/config/loginPolicy.ts index 6d00e55..81f6c1a 100644 --- a/vclogin/config/loginPolicy.ts +++ b/vclogin/config/loginPolicy.ts @@ -4,18 +4,21 @@ */ import { promises as fs } from "fs"; -import { LoginPolicy } from "@/types/LoginPolicy"; import { logger } from "./logger"; -var configuredPolicy: LoginPolicy | undefined = undefined; -if (process.env.LOGIN_POLICY) { - fs.readFile(process.env.LOGIN_POLICY as string, "utf8").then((file) => { - configuredPolicy = JSON.parse(file); - }); -} else if (process.env.NODE_ENV !== "test") { - logger.error("No login policy set"); -} - -export const getConfiguredLoginPolicy = () => { - return configuredPolicy; +export const getConfiguredLoginPolicy = async () => { + try { + if (process.env.LOGIN_POLICY) { + const file = await fs.readFile( + process.env.LOGIN_POLICY as string, + "utf8", + ); + return JSON.parse(file); + } else if (process.env.NODE_ENV !== "test") { + logger.error("No login policy set"); + } + } catch (error) { + logger.error("Failed to read login policy:", error); + return undefined; + } }; diff --git a/vclogin/lib/extractClaims.ts b/vclogin/lib/extractClaims.ts index 8ef2c35..644ff37 100644 --- a/vclogin/lib/extractClaims.ts +++ b/vclogin/lib/extractClaims.ts @@ -12,8 +12,8 @@ import { import jp from "jsonpath"; import { getConfiguredLoginPolicy } from "@/config/loginPolicy"; -export const isTrustedPresentation = (VP: any, policy?: LoginPolicy) => { - var configuredPolicy = getConfiguredLoginPolicy(); +export const isTrustedPresentation = async (VP: any, policy?: LoginPolicy) => { + var configuredPolicy = await getConfiguredLoginPolicy(); if (!policy && configuredPolicy === undefined) return false; var usedPolicy = policy ? policy : configuredPolicy!; @@ -25,8 +25,8 @@ export const isTrustedPresentation = (VP: any, policy?: LoginPolicy) => { return getConstraintFit(creds, usedPolicy, VP).length > 0; }; -export const extractClaims = (VP: any, policy?: LoginPolicy) => { - var configuredPolicy = getConfiguredLoginPolicy(); +export const extractClaims = async (VP: any, policy?: LoginPolicy) => { + var configuredPolicy = await getConfiguredLoginPolicy(); if (!policy && configuredPolicy === undefined) return false; var usedPolicy = policy ? policy : configuredPolicy!; @@ -36,10 +36,20 @@ export const extractClaims = (VP: any, policy?: LoginPolicy) => { : [VP.verifiableCredential]; const vcClaims = creds.map((vc: any) => extractClaimsFromVC(vc, usedPolicy)); - const claims = vcClaims.reduce( - (acc: any, vc: any) => Object.assign(acc, vc), - {}, - ); + const claims: any = {}; + + vcClaims.forEach((claim: any) => { + // Merge tokenId properties + claims.tokenId = Object.assign({}, claims.tokenId, claim.tokenId); + + // Merge tokenAccess properties + claims.tokenAccess = Object.assign( + {}, + claims.tokenAccess, + claim.tokenAccess, + ); + }); + return claims; }; @@ -145,10 +155,11 @@ const isValidConstraintFit = ( credDict[policy[i].credentialId] = credFit[i]; } + var fittingArr = []; + for (let i = 0; i < policy.length; i++) { const cred = credFit[i]; const expectation = policy[i]; - var oneFittingPattern = false; for (let pattern of expectation.patterns) { if (isCredentialFittingPattern(cred, pattern)) { if (pattern.constraint) { @@ -159,15 +170,20 @@ const isValidConstraintFit = ( VP, ); if (res) { - oneFittingPattern = true; - break; + // if one pattern fits, the credential is fitting + fittingArr.push(true); + } else { + // if one pattern does not fit, the credential is not fitting + fittingArr.push(false); } } } } - if (!oneFittingPattern) { + // if all patterns fit, the credential is fitting + if (!fittingArr.includes(false)) { return true; } + return false; } return false; }; @@ -225,25 +241,28 @@ const evaluateConstraint = ( throw Error("Unknown constraint operator: " + constraint.op); }; -const resolveValue = ( +const resolveSingleNodeValue = ( expression: string, cred: any, - credDict: any, VP: any, ): string => { if (expression.startsWith("$")) { var nodes: any; if (expression.startsWith("$.")) { nodes = jp.nodes(cred, expression); + } else if (expression.startsWith("$1.")) { + nodes = jp.nodes(cred, "$" + expression.slice(2)); } else if (expression.startsWith("$VP.")) { nodes = jp.nodes(VP, "$" + expression.slice(3)); - } else { + } /*else { nodes = jp.nodes( credDict[expression.slice(1).split(".")[0]], expression.slice(1).split(".").slice(1).join("."), ); - } - if (nodes.length > 1 || nodes.length <= 0) { + }*/ + if (nodes === undefined) { + return expression; + } else if (nodes.length > 1 || nodes.length <= 0) { throw Error("JSON Paths in constraints must be single-valued"); } return nodes[0].value; @@ -252,7 +271,45 @@ const resolveValue = ( return expression; }; +const resolveValue = ( + expression: string, + cred: any, + credDict: any, + VP: any, +): string => { + var nodes: any; + if (Object.entries(credDict).length > 0) { + // store object key's value in array to prevent querying wrong key + let keyValues = []; + for (const [key, value] of Object.entries(credDict)) { + keyValues.push(key); + } + + for (const [key, value] of Object.entries(credDict)) { + if (expression.startsWith("$" + key + ".")) { + for (const [key2, value2] of Object.entries(credDict)) { + // check if both keys are in credDict + if (keyValues.includes(key2) && keyValues.includes(key)) { + if (key !== key2) { + nodes = jp.nodes(value2, expression.slice(2 + key.length)); + if (nodes.length <= 1 && nodes.length > 0) { + return nodes[0].value; + } + } + } else { + // if key is not found in credDict + throw Error("Key not found in credDict"); + } + } + } + } + resolveSingleNodeValue(expression, cred, VP); + } + return resolveSingleNodeValue(expression, cred, VP); +}; + const extractClaimsFromVC = (VC: any, policy: LoginPolicy) => { + let reiterateOuterLoop = false; for (let expectation of policy) { for (let pattern of expectation.patterns) { if (pattern.issuer === VC.issuer || pattern.issuer === "*") { @@ -295,6 +352,10 @@ const extractClaimsFromVC = (VC: any, policy: LoginPolicy) => { .reduce((acc: any, vals: any) => Object.assign(acc, vals), {}); } else { if (!newPath) { + if (nodes.length === 0 || nodes === undefined) { + reiterateOuterLoop = true; + break; + } newPath = "$." + nodes[0].path[nodes[0].path.length - 1]; } @@ -308,6 +369,11 @@ const extractClaimsFromVC = (VC: any, policy: LoginPolicy) => { jp.value(claimTarget, newPath, value); } + if (reiterateOuterLoop) { + reiterateOuterLoop = false; + break; + } + return extractedClaims; } } From 3000011e297287cfd6babb94d399a9d8433598e2 Mon Sep 17 00:00:00 2001 From: Ilayda Cansin Koc Date: Thu, 11 Jul 2024 18:58:06 +0200 Subject: [PATCH 7/7] multi email Signed-off-by: Ilayda Cansin Koc --- .../testdata/policies/acceptAnything.json | 2 +- .../policies/acceptAnythingMisconfigured.json | 15 ++ .../policies/acceptAnythingMultiVC.json | 32 ++++ .../policies/acceptEmailFromAltme.json | 2 +- .../policies/acceptEmailFromAltmeConstr.json | 2 +- .../policies/acceptEmployeeFromAnyone.json | 2 +- .../acceptMultiEmailFromAltmeConstr.json | 38 +++++ ...acceptMultiEmailFromAltmeSimpleConstr.json | 33 ++++ .../acceptMultiVCFromAltmeConstr.json | 38 +++++ .../acceptMultiVCFromAltmeMisconfigured.json | 28 ++++ .../acceptMultiVCFromAltmeSimpleConstr.json | 33 ++++ .../presentations/VP_MultiEmailPass.json | 123 +++++++++++++++ .../testdata/presentations/VP_MultiVC.json | 122 +++++++++++++++ .../unit/lib/evaluateLoginPolicy.test.ts | 143 +++++++++++++++--- .../__tests__/unit/lib/extractClaims.test.ts | 101 ++++++++++++- .../unit/lib/verifyPresentation.test.ts | 20 ++- vclogin/lib/extractClaims.ts | 35 +++-- 17 files changed, 711 insertions(+), 58 deletions(-) create mode 100644 vclogin/__tests__/testdata/policies/acceptAnythingMisconfigured.json create mode 100644 vclogin/__tests__/testdata/policies/acceptAnythingMultiVC.json create mode 100644 vclogin/__tests__/testdata/policies/acceptMultiEmailFromAltmeConstr.json create mode 100644 vclogin/__tests__/testdata/policies/acceptMultiEmailFromAltmeSimpleConstr.json create mode 100644 vclogin/__tests__/testdata/policies/acceptMultiVCFromAltmeConstr.json create mode 100644 vclogin/__tests__/testdata/policies/acceptMultiVCFromAltmeMisconfigured.json create mode 100644 vclogin/__tests__/testdata/policies/acceptMultiVCFromAltmeSimpleConstr.json create mode 100644 vclogin/__tests__/testdata/presentations/VP_MultiEmailPass.json create mode 100644 vclogin/__tests__/testdata/presentations/VP_MultiVC.json diff --git a/vclogin/__tests__/testdata/policies/acceptAnything.json b/vclogin/__tests__/testdata/policies/acceptAnything.json index 15d0d37..3aa0ad4 100644 --- a/vclogin/__tests__/testdata/policies/acceptAnything.json +++ b/vclogin/__tests__/testdata/policies/acceptAnything.json @@ -1,6 +1,6 @@ [ { - "credentialId": "credential1", + "credentialId": "1", "patterns": [ { "issuer": "*", diff --git a/vclogin/__tests__/testdata/policies/acceptAnythingMisconfigured.json b/vclogin/__tests__/testdata/policies/acceptAnythingMisconfigured.json new file mode 100644 index 0000000..1ea07d2 --- /dev/null +++ b/vclogin/__tests__/testdata/policies/acceptAnythingMisconfigured.json @@ -0,0 +1,15 @@ +[ + { + "credentialId": "some random string", + "patterns": [ + { + "issuer": "*", + "claims": [ + { + "claimPath": "$.credentialSubject.id" + } + ] + } + ] + } +] diff --git a/vclogin/__tests__/testdata/policies/acceptAnythingMultiVC.json b/vclogin/__tests__/testdata/policies/acceptAnythingMultiVC.json new file mode 100644 index 0000000..9c913ea --- /dev/null +++ b/vclogin/__tests__/testdata/policies/acceptAnythingMultiVC.json @@ -0,0 +1,32 @@ +[ + { + "credentialId": "1", + "patterns": [ + { + "issuer": "*", + "claims": [ + { + "claimPath": "$1.credentialSubject.*", + "newPath": "$.firstCredentialSubject", + "required": false + } + ] + } + ] + }, + { + "credentialId": "2", + "patterns": [ + { + "issuer": "*", + "claims": [ + { + "claimPath": "$.credentialSubject.*", + "newPath": "$.secondCredentialSubject", + "required": false + } + ] + } + ] + } +] diff --git a/vclogin/__tests__/testdata/policies/acceptEmailFromAltme.json b/vclogin/__tests__/testdata/policies/acceptEmailFromAltme.json index 72e3c73..1712abc 100644 --- a/vclogin/__tests__/testdata/policies/acceptEmailFromAltme.json +++ b/vclogin/__tests__/testdata/policies/acceptEmailFromAltme.json @@ -1,6 +1,6 @@ [ { - "credentialId": "one", + "credentialId": "1", "patterns": [ { "issuer": "did:web:app.altme.io:issuer", diff --git a/vclogin/__tests__/testdata/policies/acceptEmailFromAltmeConstr.json b/vclogin/__tests__/testdata/policies/acceptEmailFromAltmeConstr.json index 4f41086..17885d5 100644 --- a/vclogin/__tests__/testdata/policies/acceptEmailFromAltmeConstr.json +++ b/vclogin/__tests__/testdata/policies/acceptEmailFromAltmeConstr.json @@ -1,6 +1,6 @@ [ { - "credentialId": "one", + "credentialId": "1", "patterns": [ { "issuer": "did:web:app.altme.io:issuer", diff --git a/vclogin/__tests__/testdata/policies/acceptEmployeeFromAnyone.json b/vclogin/__tests__/testdata/policies/acceptEmployeeFromAnyone.json index 2c581b8..b2e023a 100644 --- a/vclogin/__tests__/testdata/policies/acceptEmployeeFromAnyone.json +++ b/vclogin/__tests__/testdata/policies/acceptEmployeeFromAnyone.json @@ -1,6 +1,6 @@ [ { - "credentialId": "one", + "credentialId": "1", "patterns": [ { "issuer": "*", diff --git a/vclogin/__tests__/testdata/policies/acceptMultiEmailFromAltmeConstr.json b/vclogin/__tests__/testdata/policies/acceptMultiEmailFromAltmeConstr.json new file mode 100644 index 0000000..0673b68 --- /dev/null +++ b/vclogin/__tests__/testdata/policies/acceptMultiEmailFromAltmeConstr.json @@ -0,0 +1,38 @@ +[ + { + "credentialId": "1", + "patterns": [ + { + "issuer": "did:web:app.altme.io:issuer", + "claims": [ + { + "claimPath": "$.credentialSubject.email" + } + ], + "constraint": { + "op": "equalsDID", + "a": "$VP.proof.verificationMethod", + "b": "$1.credentialSubject.id" + } + } + ] + }, + { + "credentialId": "2", + "patterns": [ + { + "issuer": "did:web:app.altme.io:issuer", + "claims": [ + { + "claimPath": "$.credentialSubject.type" + } + ], + "constraint": { + "op": "equalsDID", + "a": "$2.credentialSubject.id", + "b": "$1.credentialSubject.id" + } + } + ] + } +] diff --git a/vclogin/__tests__/testdata/policies/acceptMultiEmailFromAltmeSimpleConstr.json b/vclogin/__tests__/testdata/policies/acceptMultiEmailFromAltmeSimpleConstr.json new file mode 100644 index 0000000..a1ef319 --- /dev/null +++ b/vclogin/__tests__/testdata/policies/acceptMultiEmailFromAltmeSimpleConstr.json @@ -0,0 +1,33 @@ +[ + { + "credentialId": "1", + "patterns": [ + { + "issuer": "did:web:app.altme.io:issuer", + "claims": [ + { + "claimPath": "$.credentialSubject.email" + } + ], + "constraint": { + "op": "equalsDID", + "a": "$VP.proof.verificationMethod", + "b": "$1.credentialSubject.id" + } + } + ] + }, + { + "credentialId": "2", + "patterns": [ + { + "issuer": "did:web:app.altme.io:issuer", + "claims": [ + { + "claimPath": "$.credentialSubject.type" + } + ] + } + ] + } +] diff --git a/vclogin/__tests__/testdata/policies/acceptMultiVCFromAltmeConstr.json b/vclogin/__tests__/testdata/policies/acceptMultiVCFromAltmeConstr.json new file mode 100644 index 0000000..03bf27b --- /dev/null +++ b/vclogin/__tests__/testdata/policies/acceptMultiVCFromAltmeConstr.json @@ -0,0 +1,38 @@ +[ + { + "credentialId": "1", + "patterns": [ + { + "issuer": "did:web:app.altme.io:issuer", + "claims": [ + { + "claimPath": "$.credentialSubject.email" + } + ], + "constraint": { + "op": "equalsDID", + "a": "$VP.proof.verificationMethod", + "b": "$1.credentialSubject.id" + } + } + ] + }, + { + "credentialId": "2", + "patterns": [ + { + "issuer": "did:web:app.altme.io:issuer", + "claims": [ + { + "claimPath": "$.credentialSubject.firstName" + } + ], + "constraint": { + "op": "equals", + "a": "$2.credentialSubject.id", + "b": "$1.credentialSubject.id" + } + } + ] + } +] diff --git a/vclogin/__tests__/testdata/policies/acceptMultiVCFromAltmeMisconfigured.json b/vclogin/__tests__/testdata/policies/acceptMultiVCFromAltmeMisconfigured.json new file mode 100644 index 0000000..5e1db23 --- /dev/null +++ b/vclogin/__tests__/testdata/policies/acceptMultiVCFromAltmeMisconfigured.json @@ -0,0 +1,28 @@ +[ + { + "credentialId": "one", + "patterns": [ + { + "issuer": "*", + "claims": [ + { + "claimPath": "$.credentialSubject.type" + } + ] + } + ] + }, + { + "credentialId": "two", + "patterns": [ + { + "issuer": "*", + "claims": [ + { + "claimPath": "$.credentialSubject.id" + } + ] + } + ] + } +] diff --git a/vclogin/__tests__/testdata/policies/acceptMultiVCFromAltmeSimpleConstr.json b/vclogin/__tests__/testdata/policies/acceptMultiVCFromAltmeSimpleConstr.json new file mode 100644 index 0000000..a3fa9fa --- /dev/null +++ b/vclogin/__tests__/testdata/policies/acceptMultiVCFromAltmeSimpleConstr.json @@ -0,0 +1,33 @@ +[ + { + "credentialId": "1", + "patterns": [ + { + "issuer": "did:web:app.altme.io:issuer", + "claims": [ + { + "claimPath": "$.credentialSubject.email" + } + ], + "constraint": { + "op": "equalsDID", + "a": "$VP.proof.verificationMethod", + "b": "$1.credentialSubject.id" + } + } + ] + }, + { + "credentialId": "2", + "patterns": [ + { + "issuer": "did:web:app.altme.io:issuer", + "claims": [ + { + "claimPath": "$.credentialSubject.firstName" + } + ] + } + ] + } +] diff --git a/vclogin/__tests__/testdata/presentations/VP_MultiEmailPass.json b/vclogin/__tests__/testdata/presentations/VP_MultiEmailPass.json new file mode 100644 index 0000000..c894f23 --- /dev/null +++ b/vclogin/__tests__/testdata/presentations/VP_MultiEmailPass.json @@ -0,0 +1,123 @@ +{ + "@context": ["https://www.w3.org/2018/credentials/v1"], + "id": "urn:uuid:2c0b29f7-c530-4405-b509-d34d4a8a4028", + "type": ["VerifiablePresentation"], + "verifiableCredential": [ + { + "@context": [ + "https://www.w3.org/2018/credentials/v1", + { + "EmailPass": { + "@context": { + "@protected": true, + "@version": 1.1, + "email": "schema:email", + "id": "@id", + "issuedBy": { + "@context": { + "@protected": true, + "@version": 1.1, + "logo": { + "@id": "schema:image", + "@type": "@id" + }, + "name": "schema:name" + }, + "@id": "schema:issuedBy" + }, + "schema": "https://schema.org/", + "type": "@type" + }, + "@id": "https://github.com/TalaoDAO/context#emailpass" + } + } + ], + "id": "urn:uuid:a8e2b30c-eeb6-11ee-a822-0a1628958560", + "type": ["VerifiableCredential", "EmailPass"], + "credentialSubject": { + "id": "did:key:z6Mkj5B9HcSKWGuuawpBcvy5wQwZJ9g2k5HzfmXPAjwbQ9TT", + "issuedBy": { + "name": "Altme" + }, + "email": "first.vc@gmail.com", + "type": "EmailPass" + }, + "issuer": "did:web:app.altme.io:issuer", + "issuanceDate": "2024-03-30T16:57:55Z", + "proof": { + "type": "Ed25519Signature2018", + "proofPurpose": "assertionMethod", + "verificationMethod": "did:web:app.altme.io:issuer#key-1", + "created": "2024-03-30T16:57:58.048Z", + "jws": "eyJhbGciOiJFZERTQSIsImNyaXQiOlsiYjY0Il0sImI2NCI6ZmFsc2V9..F9EXluMjwPtCe8A0WKusX0zSsCi6JKh0HDLuNk47-Wvig_8wrwh56IocbNMyNG8b2J4wGuXUuGLTkQuftx6iDA" + }, + "expirationDate": "2025-03-30T16:57:55.390537Z", + "issued": "2024-03-30T16:57:57Z", + "validUntil": "2025-03-30T16:57:57.995257Z", + "validFrom": "2024-03-30T16:57:57Z" + }, + { + "@context": [ + "https://www.w3.org/2018/credentials/v1", + { + "EmailPass": { + "@context": { + "@protected": true, + "@version": 1.1, + "email": "schema:email", + "id": "@id", + "issuedBy": { + "@context": { + "@protected": true, + "@version": 1.1, + "logo": { + "@id": "schema:image", + "@type": "@id" + }, + "name": "schema:name" + }, + "@id": "schema:issuedBy" + }, + "schema": "https://schema.org/", + "type": "@type" + }, + "@id": "https://github.com/TalaoDAO/context#emailpass" + } + } + ], + "id": "urn:uuid:b55c651d-3f84-11ef-a702-0a1628958560", + "type": ["VerifiableCredential", "EmailPass"], + "credentialSubject": { + "id": "did:key:z6Mkj5B9HcSKWGuuawpBcvy5wQwZJ9g2k5HzfmXPAjwbQ9TT", + "type": "EmailPass", + "issuedBy": { + "name": "Altme" + }, + "email": "second.vc@gmail.com" + }, + "issuer": "did:web:app.altme.io:issuer", + "issuanceDate": "2024-07-11T12:54:20Z", + "proof": { + "type": "Ed25519Signature2018", + "proofPurpose": "assertionMethod", + "verificationMethod": "did:web:app.altme.io:issuer#key-1", + "created": "2024-07-11T12:54:28.288Z", + "jws": "eyJhbGciOiJFZERTQSIsImNyaXQiOlsiYjY0Il0sImI2NCI6ZmFsc2V9..kI_Fp52mHMxGR59cHPBf3NZD7QL5OqnsOR112hGJ9zmyAhd0288o9Hzazz-AmsMvW2LaaEDx_qWFwDwQ9Gi0DA" + }, + "expirationDate": "2025-07-11T12:54:20.699044Z", + "validFrom": "2024-07-11T12:54:28Z", + "validUntil": "2025-07-11T12:54:28.273969Z", + "issued": "2024-07-11T12:54:28Z" + } + ], + "proof": { + "type": "Ed25519Signature2018", + "proofPurpose": "authentication", + "challenge": "615e51a4-3f84-11ef-876f-0a1628958560", + "verificationMethod": "did:key:z6Mkj5B9HcSKWGuuawpBcvy5wQwZJ9g2k5HzfmXPAjwbQ9TT#z6Mkj5B9HcSKWGuuawpBcvy5wQwZJ9g2k5HzfmXPAjwbQ9TT", + "created": "2024-07-11T12:52:05.187708Z", + "domain": "https://talao.co/", + "jws": "eyJhbGciOiJFZERTQSIsImNyaXQiOlsiYjY0Il0sImI2NCI6ZmFsc2V9..ux7ny5CsclcU_4oovjitC4r7EktTOBH_qbdbca7OUKNbyFW-6CbREe0xj_izYxEqESvoJ-YfuvTkpgR35uw7AQ" + }, + "holder": "did:key:z6Mkj5B9HcSKWGuuawpBcvy5wQwZJ9g2k5HzfmXPAjwbQ9TT" +} diff --git a/vclogin/__tests__/testdata/presentations/VP_MultiVC.json b/vclogin/__tests__/testdata/presentations/VP_MultiVC.json new file mode 100644 index 0000000..959963d --- /dev/null +++ b/vclogin/__tests__/testdata/presentations/VP_MultiVC.json @@ -0,0 +1,122 @@ +{ + "@context": ["https://www.w3.org/2018/credentials/v1"], + "id": "urn:uuid:2c0b29f7-c530-4405-b509-d34d4a8a4028", + "type": ["VerifiablePresentation"], + "verifiableCredential": [ + { + "@context": [ + "https://www.w3.org/2018/credentials/v1", + { + "EmailPass": { + "@context": { + "@protected": true, + "@version": 1.1, + "email": "schema:email", + "id": "@id", + "issuedBy": { + "@context": { + "@protected": true, + "@version": 1.1, + "logo": { + "@id": "schema:image", + "@type": "@id" + }, + "name": "schema:name" + }, + "@id": "schema:issuedBy" + }, + "schema": "https://schema.org/", + "type": "@type" + }, + "@id": "https://github.com/TalaoDAO/context#emailpass" + } + } + ], + "id": "urn:uuid:a8e2b30c-eeb6-11ee-a822-0a1628958560", + "type": ["VerifiableCredential", "EmailPass"], + "credentialSubject": { + "id": "did:key:z6Mkj5B9HcSKWGuuawpBcvy5wQwZJ9g2k5HzfmXPAjwbQ9TT", + "issuedBy": { + "name": "Altme" + }, + "email": "first.vc@gmail.com", + "type": "EmailPass" + }, + "issuer": "did:web:app.altme.io:issuer", + "issuanceDate": "2024-03-30T16:57:55Z", + "proof": { + "type": "Ed25519Signature2018", + "proofPurpose": "assertionMethod", + "verificationMethod": "did:web:app.altme.io:issuer#key-1", + "created": "2024-03-30T16:57:58.048Z", + "jws": "eyJhbGciOiJFZERTQSIsImNyaXQiOlsiYjY0Il0sImI2NCI6ZmFsc2V9..F9EXluMjwPtCe8A0WKusX0zSsCi6JKh0HDLuNk47-Wvig_8wrwh56IocbNMyNG8b2J4wGuXUuGLTkQuftx6iDA" + }, + "expirationDate": "2025-03-30T16:57:55.390537Z", + "issued": "2024-03-30T16:57:57Z", + "validUntil": "2025-03-30T16:57:57.995257Z", + "validFrom": "2024-03-30T16:57:57Z" + }, + { + "@context": [ + "https://www.w3.org/2018/credentials/v1", + { + "VerifiableId": { + "@context": { + "@protected": true, + "@version": 1.1, + "dateIssued": "schema:dateIssued", + "dateOfBirth": "schema:birthDate", + "familyName": "schema:lastName", + "firstName": "schema:firstName", + "gender": "schema:gender", + "id": "@id", + "idRecto": "schema:image", + "idVerso": "schema:image", + "schema": "https://schema.org/", + "type": "@type" + }, + "@id": "urn:employeecredential" + } + } + ], + "id": "urn:uuid:f1fcf591-1396-11ef-8875-0a1628958560", + "type": ["VerifiableCredential", "VerifiableId"], + "credentialSubject": { + "id": "did:key:z6Mkj5B9HcSKWGuuawpBcvy5wQwZJ9g2k5HzfmXPAjwbQ9TT", + "dateOfBirth": "1930-10-01", + "firstName": "Bianca", + "gender": "F", + "dateIssued": "2022-12-20", + "familyName": "Castafiori", + "type": "VerifiableId" + }, + "issuer": "did:web:app.altme.io:issuer", + "issuanceDate": "2024-05-16T15:14:02Z", + "proof": { + "type": "EcdsaSecp256k1Signature2019", + "proofPurpose": "assertionMethod", + "verificationMethod": "did:web:app.altme.io:issuer#key-3", + "created": "2024-05-16T15:14:09.781Z", + "jws": "eyJhbGciOiJFUzI1NksiLCJjcml0IjpbImI2NCJdLCJiNjQiOmZhbHNlfQ..smC_xxfdYC9fVhv2ETHWLBR9KCFlXRkujTJjQH6vAA47J5nal7QlVNeDGcb_nOlASBiGY_pG4PzaqoPe6-9q4A" + }, + "expirationDate": "2025-05-16T15:14:02Z", + "credentialSchema": { + "id": "https://api-conformance.ebsi.eu/trusted-schemas-registry/v2/schemas/z22ZAMdQtNLwi51T2vdZXGGZaYyjrsuP1yzWyXZirCAHv", + "type": "FullJsonSchemaValidator2021" + }, + "validUntil": "2025-05-16T15:14:09.768457Z", + "issued": "2024-05-16T15:14:09Z", + "validFrom": "2024-05-16T15:14:09Z" + } + ], + "proof": { + "type": "Ed25519Signature2018", + "proofPurpose": "authentication", + "challenge": "615e51a4-3f84-11ef-876f-0a1628958560", + "verificationMethod": "did:key:z6Mkj5B9HcSKWGuuawpBcvy5wQwZJ9g2k5HzfmXPAjwbQ9TT#z6Mkj5B9HcSKWGuuawpBcvy5wQwZJ9g2k5HzfmXPAjwbQ9TT", + "created": "2024-07-11T12:52:05.187708Z", + "domain": "https://talao.co/", + "jws": "eyJhbGciOiJFZERTQSIsImNyaXQiOlsiYjY0Il0sImI2NCI6ZmFsc2V9..ux7ny5CsclcU_4oovjitC4r7EktTOBH_qbdbca7OUKNbyFW-6CbREe0xj_izYxEqESvoJ-YfuvTkpgR35uw7AQ" + }, + "holder": "did:key:z6Mkj5B9HcSKWGuuawpBcvy5wQwZJ9g2k5HzfmXPAjwbQ9TT" +} diff --git a/vclogin/__tests__/unit/lib/evaluateLoginPolicy.test.ts b/vclogin/__tests__/unit/lib/evaluateLoginPolicy.test.ts index 90e38d2..a5e8b98 100644 --- a/vclogin/__tests__/unit/lib/evaluateLoginPolicy.test.ts +++ b/vclogin/__tests__/unit/lib/evaluateLoginPolicy.test.ts @@ -6,6 +6,8 @@ import { describe, it, expect } from "vitest"; import { isTrustedPresentation } from "@/lib/extractClaims"; import vpEmployee from "@/testdata/presentations/VP_EmployeeCredential.json"; +import vpMultiEmail from "@/testdata/presentations/VP_MultiEmailPass.json"; +import vpMultiVC from "@/testdata/presentations/VP_MultiVC.json"; import vpEmail from "@/testdata/presentations/VP_EmailPass.json"; import vpTezos from "@/testdata/presentations/VP_TezosAssociatedAddress.json"; import policyAcceptAnything from "@/testdata/policies/acceptAnything.json"; @@ -14,65 +16,156 @@ import policyEmailFromAltme from "@/testdata/policies/acceptEmailFromAltme.json" import policyFromAltme from "@/testdata/policies/acceptFromAltme.json"; import policyEmailFromAltmeConstr from "@/testdata/policies/acceptEmailFromAltmeConstr.json"; import policyEmployeeFromAnyoneConstr from "@/testdata/policies/acceptEmployeeFromAnyoneConstr.json"; +import policyMultiEmailFromAltmeConstr from "@/testdata/policies/acceptMultiEmailFromAltmeConstr.json"; +import policyMultiEmailFromAltmeSimpleConstr from "@/testdata/policies/acceptMultiEmailFromAltmeSimpleConstr.json"; +import policyMultiVCFromAltmeConstr from "@/testdata/policies/acceptMultiVCFromAltmeConstr.json"; +import policyMultiVCFromAltmeSimpleConstr from "@/testdata/policies/acceptMultiVCFromAltmeSimpleConstr.json"; describe("evaluateLoginPolicy", () => { - it("defaults to false if no policy is available", () => { - var trusted = isTrustedPresentation(vpEmployee, undefined); + it("defaults to false if no policy is available", async () => { + var trusted = await isTrustedPresentation(vpEmployee, undefined); expect(trusted).toBe(false); }); - it("accepts valid VPs with acceptAnything policy", () => { - var trusted = isTrustedPresentation(vpEmployee, policyAcceptAnything); + it("accepts valid VPs with acceptAnything policy", async () => { + var trusted = await isTrustedPresentation(vpEmployee, policyAcceptAnything); expect(trusted).toBe(true); - trusted = isTrustedPresentation(vpEmail, policyAcceptAnything); + trusted = await isTrustedPresentation(vpEmail, policyAcceptAnything); expect(trusted).toBe(true); - trusted = isTrustedPresentation(vpTezos, policyAcceptAnything); + trusted = await isTrustedPresentation(vpTezos, policyAcceptAnything); expect(trusted).toBe(true); }); - it("accepts only VP with credential(s) of a certain type", () => { - var trusted = isTrustedPresentation(vpEmployee, policyEmployeeFromAnyone); + it("accepts only VP with credential(s) of a certain type", async () => { + var trusted = await isTrustedPresentation( + vpEmployee, + policyEmployeeFromAnyone, + ); expect(trusted).toBe(true); - trusted = isTrustedPresentation(vpEmail, policyEmployeeFromAnyone); + trusted = await isTrustedPresentation(vpEmail, policyEmployeeFromAnyone); expect(trusted).toBe(false); - trusted = isTrustedPresentation(vpTezos, policyEmployeeFromAnyone); + trusted = await isTrustedPresentation(vpTezos, policyEmployeeFromAnyone); expect(trusted).toBe(false); }); - it("accepts only VP with credential(s) of a certain type from a certain issuer", () => { - var trusted = isTrustedPresentation(vpEmail, policyEmailFromAltme); + it("accepts only VP with credential(s) of a certain type from a certain issuer", async () => { + var trusted = await isTrustedPresentation(vpEmail, policyEmailFromAltme); expect(trusted).toBe(true); - trusted = isTrustedPresentation(vpEmployee, policyEmailFromAltme); + trusted = await isTrustedPresentation(vpEmployee, policyEmailFromAltme); expect(trusted).toBe(false); - trusted = isTrustedPresentation(vpTezos, policyEmailFromAltme); + trusted = await isTrustedPresentation(vpTezos, policyEmailFromAltme); expect(trusted).toBe(false); }); - it("accepts all VP from a certain issuer", () => { - var trusted = isTrustedPresentation(vpEmail, policyFromAltme); + it("accepts all VP from a certain issuer", async () => { + var trusted = await isTrustedPresentation(vpEmail, policyFromAltme); expect(trusted).toBe(true); - trusted = isTrustedPresentation(vpEmployee, policyFromAltme); + trusted = await isTrustedPresentation(vpEmployee, policyFromAltme); expect(trusted).toBe(false); }); - it("accepts only VP with credential(s) with simple constraint", () => { - var trusted = isTrustedPresentation(vpEmail, policyEmailFromAltmeConstr); + it("accepts only VP with credential(s) with simple constraint", async () => { + var trusted = await isTrustedPresentation( + vpEmail, + policyEmailFromAltmeConstr, + ); expect(trusted).toBe(true); - trusted = isTrustedPresentation(vpEmployee, policyEmailFromAltmeConstr); + trusted = await isTrustedPresentation( + vpEmployee, + policyEmailFromAltmeConstr, + ); expect(trusted).toBe(false); - trusted = isTrustedPresentation(vpTezos, policyEmailFromAltmeConstr); + trusted = await isTrustedPresentation(vpTezos, policyEmailFromAltmeConstr); expect(trusted).toBe(false); }); - it("accepts only VP with credential(s) with complicated constraint", () => { - var trusted = isTrustedPresentation( + it("accepts only VP with credential(s) with complicated constraint", async () => { + var trusted = await isTrustedPresentation( vpEmployee, policyEmployeeFromAnyoneConstr, ); expect(trusted).toBe(true); - trusted = isTrustedPresentation(vpEmail, policyEmployeeFromAnyoneConstr); + trusted = await isTrustedPresentation( + vpEmail, + policyEmployeeFromAnyoneConstr, + ); expect(trusted).toBe(false); - trusted = isTrustedPresentation(vpTezos, policyEmployeeFromAnyoneConstr); + trusted = await isTrustedPresentation( + vpTezos, + policyEmployeeFromAnyoneConstr, + ); + expect(trusted).toBe(false); + }); + + it("accepts only VP with two credentials (same type of VCs that have common credentialSubject fields) with cross constraints", async () => { + var trusted = await isTrustedPresentation( + vpMultiEmail, + policyMultiEmailFromAltmeConstr, + ); + expect(trusted).toBe(true); + trusted = await isTrustedPresentation( + vpEmail, + policyMultiEmailFromAltmeConstr, + ); + expect(trusted).toBe(false); + trusted = await isTrustedPresentation( + vpTezos, + policyMultiEmailFromAltmeConstr, + ); + expect(trusted).toBe(false); + }); + + it("accepts only VP with two credentials (same type of VCs that have common credentialSubject fields) with simple constraints", async () => { + var trusted = await isTrustedPresentation( + vpMultiEmail, + policyMultiEmailFromAltmeSimpleConstr, + ); + expect(trusted).toBe(true); + trusted = await isTrustedPresentation( + vpEmail, + policyMultiEmailFromAltmeSimpleConstr, + ); + expect(trusted).toBe(false); + trusted = await isTrustedPresentation( + vpTezos, + policyMultiEmailFromAltmeSimpleConstr, + ); + expect(trusted).toBe(false); + }); + + it("accepts only VP with two credentials (different type of VCs that have common credentialSubject fields) with cross constraints", async () => { + var trusted = await isTrustedPresentation( + vpMultiVC, + policyMultiVCFromAltmeConstr, + ); + expect(trusted).toBe(true); + trusted = await isTrustedPresentation( + vpEmail, + policyMultiVCFromAltmeConstr, + ); + expect(trusted).toBe(false); + trusted = await isTrustedPresentation( + vpMultiEmail, + policyMultiVCFromAltmeConstr, + ); + expect(trusted).toBe(false); + }); + + it("accepts only VP with two credentials (different type of VCs that have common credentialSubject fields) with simple constraints", async () => { + var trusted = await isTrustedPresentation( + vpMultiVC, + policyMultiVCFromAltmeSimpleConstr, + ); + expect(trusted).toBe(true); + trusted = await isTrustedPresentation( + vpEmail, + policyMultiVCFromAltmeSimpleConstr, + ); + expect(trusted).toBe(false); + trusted = await isTrustedPresentation( + vpMultiEmail, + policyMultiVCFromAltmeSimpleConstr, + ); expect(trusted).toBe(false); }); }); diff --git a/vclogin/__tests__/unit/lib/extractClaims.test.ts b/vclogin/__tests__/unit/lib/extractClaims.test.ts index 182e8f4..65daf38 100644 --- a/vclogin/__tests__/unit/lib/extractClaims.test.ts +++ b/vclogin/__tests__/unit/lib/extractClaims.test.ts @@ -7,14 +7,21 @@ import { describe, it, expect } from "vitest"; import { extractClaims } from "@/lib/extractClaims"; import vpEmployee from "@/testdata/presentations/VP_EmployeeCredential.json"; import vpEmail from "@/testdata/presentations/VP_EmailPass.json"; +import vpMultiEmail from "@/testdata/presentations/VP_MultiEmailPass.json"; +import vpMultiVC from "@/testdata/presentations/VP_MultiVC.json"; import policyAcceptAnything from "@/testdata/policies/acceptAnything.json"; +import policyAcceptAnythingMisconfigured from "@/testdata/policies/acceptAnythingMisconfigured.json"; +import policyAcceptAnythingMultiVC from "@/testdata/policies/acceptAnythingMultiVC.json"; import policyEmailFromAltme from "@/testdata/policies/acceptEmailFromAltme.json"; import policyEmailFromAltmeConstr from "@/testdata/policies/acceptEmailFromAltmeConstr.json"; import policyEmployeeFromAnyone from "@/testdata/policies/acceptEmployeeFromAnyone.json"; +import policyMultiEmailFromAltmeConstr from "@/testdata/policies/acceptMultiEmailFromAltmeConstr.json"; +import policyMultiVCromAltmeConstr from "@/testdata/policies/acceptMultiVCFromAltmeConstr.json"; +import policyAcceptMultiVCMisconfigured from "@/testdata/policies/acceptMultiVCFromAltmeMisconfigured.json"; describe("extractClaims", () => { - it("all subject claims from an EmployeeCredential are extracted", () => { - var claims = extractClaims(vpEmployee, policyAcceptAnything); + it("all subject claims from an EmployeeCredential are extracted", async () => { + var claims = await extractClaims(vpEmployee, policyAcceptAnything); var expected = { tokenAccess: {}, tokenId: { @@ -38,8 +45,8 @@ describe("extractClaims", () => { expect(claims).toStrictEqual(expected); }); - it("all designated claims from an EmailPass Credential are mapped", () => { - var claims = extractClaims(vpEmail, policyEmailFromAltme); + it("all designated claims from an EmailPass Credential are mapped", async () => { + var claims = await extractClaims(vpEmail, policyEmailFromAltme); var expected = { tokenId: { email: "felix.hoops@tum.de", @@ -49,8 +56,8 @@ describe("extractClaims", () => { expect(claims).toStrictEqual(expected); }); - it("all designated claims from an EmailPass Credential are mapped (constrained)", () => { - var claims = extractClaims(vpEmail, policyEmailFromAltmeConstr); + it("all designated claims from an EmailPass Credential are mapped (constrained)", async () => { + var claims = await extractClaims(vpEmail, policyEmailFromAltmeConstr); var expected = { tokenId: { email: "felix.hoops@tum.de", @@ -60,8 +67,8 @@ describe("extractClaims", () => { expect(claims).toStrictEqual(expected); }); - it("all designated claims from an EmployeeCredential are extracted", () => { - var claims = extractClaims(vpEmployee, policyEmployeeFromAnyone); + it("all designated claims from an EmployeeCredential are extracted", async () => { + var claims = await extractClaims(vpEmployee, policyEmployeeFromAnyone); var expected = { tokenAccess: {}, tokenId: { @@ -72,4 +79,82 @@ describe("extractClaims", () => { }; expect(claims).toStrictEqual(expected); }); + + it("all designated claims from a multi VC (EmailPass) are extracted", async () => { + var claims = await extractClaims( + vpMultiEmail, + policyMultiEmailFromAltmeConstr, + ); + var expected = { + tokenAccess: {}, + tokenId: { + email: "first.vc@gmail.com", + type: "EmailPass", + }, + }; + expect(claims).toStrictEqual(expected); + }); + + it("all designated claims from a multi VC (EmailPass and VerifiableId) are extracted", async () => { + var claims = await extractClaims(vpMultiVC, policyMultiVCromAltmeConstr); + var expected = { + tokenAccess: {}, + tokenId: { + email: "first.vc@gmail.com", + firstName: "Bianca", + }, + }; + expect(claims).toStrictEqual(expected); + }); + + it("all designated claims from a multi VC (EmailPass and VerifiableId) with misconfigured policy", async () => { + var claims = await extractClaims( + vpMultiVC, + policyAcceptMultiVCMisconfigured, + ); + var expected = { + tokenAccess: {}, + tokenId: {}, + }; + expect(claims).toStrictEqual(expected); + }); + + it("all designated claims from a EmailPass with misconfigured policy", async () => { + var claims = await extractClaims( + vpEmail, + policyAcceptAnythingMisconfigured, + ); + var expected = { + tokenAccess: {}, + tokenId: {}, + }; + expect(claims).toStrictEqual(expected); + }); + + it("all designated claims from a multi VC (EmailPass and VerifiableId)", async () => { + var claims = await extractClaims(vpMultiVC, policyAcceptAnythingMultiVC); + var expected = { + tokenAccess: {}, + tokenId: { + firstCredentialSubject: { + email: "first.vc@gmail.com", + id: "did:key:z6Mkj5B9HcSKWGuuawpBcvy5wQwZJ9g2k5HzfmXPAjwbQ9TT", + issuedBy: { + name: "Altme", + }, + type: "EmailPass", + }, + secondCredentialSubject: { + dateIssued: "2022-12-20", + dateOfBirth: "1930-10-01", + familyName: "Castafiori", + firstName: "Bianca", + gender: "F", + id: "did:key:z6Mkj5B9HcSKWGuuawpBcvy5wQwZJ9g2k5HzfmXPAjwbQ9TT", + type: "VerifiableId", + }, + }, + }; + expect(claims).toStrictEqual(expected); + }); }); diff --git a/vclogin/__tests__/unit/lib/verifyPresentation.test.ts b/vclogin/__tests__/unit/lib/verifyPresentation.test.ts index bf9b896..35713ac 100644 --- a/vclogin/__tests__/unit/lib/verifyPresentation.test.ts +++ b/vclogin/__tests__/unit/lib/verifyPresentation.test.ts @@ -12,20 +12,18 @@ import vpEmployee from "@/testdata/presentations/VP_EmployeeCredential.json"; // of all those web requests would be lots of work describe("verifyPresentation", () => { // kind of a sanity check - it("didkit verifies a valid Employee VC", () => { - return verifyCredential( + it("didkit verifies a valid Employee VC", async () => { + const result = await verifyCredential( JSON.stringify(vpEmployee.verifiableCredential), "{}", - ).then((result) => { - const verifyResult = JSON.parse(result); - //console.log(verifyResult); - expect(verifyResult.errors.length).toBe(0); - }); + ); + const verifyResult = JSON.parse(result); + //console.log(verifyResult); + expect(verifyResult.errors.length).toBe(0); }); - it("verifies a valid VP with Employee VC", () => { - return verifyAuthenticationPresentation(vpEmployee).then((result) => { - expect(result).toBe(true); - }); + it("verifies a valid VP with Employee VC", async () => { + const result = await verifyAuthenticationPresentation(vpEmployee); + expect(result).toBe(true); }); }); diff --git a/vclogin/lib/extractClaims.ts b/vclogin/lib/extractClaims.ts index 644ff37..3d85e01 100644 --- a/vclogin/lib/extractClaims.ts +++ b/vclogin/lib/extractClaims.ts @@ -17,7 +17,6 @@ export const isTrustedPresentation = async (VP: any, policy?: LoginPolicy) => { if (!policy && configuredPolicy === undefined) return false; var usedPolicy = policy ? policy : configuredPolicy!; - const creds = Array.isArray(VP.verifiableCredential) ? VP.verifiableCredential : [VP.verifiableCredential]; @@ -35,7 +34,11 @@ export const extractClaims = async (VP: any, policy?: LoginPolicy) => { ? VP.verifiableCredential : [VP.verifiableCredential]; - const vcClaims = creds.map((vc: any) => extractClaimsFromVC(vc, usedPolicy)); + const vcClaims = creds.map((vc: any, credentialIndex: number) => + // Important: credentialIndex defines helps us to extract the correct claims from the policy + // Ideally, the credentialIndex should be the same as the credentialId in the policy + extractClaimsFromVC(vc, usedPolicy, (credentialIndex + 1).toString()), + ); const claims: any = {}; vcClaims.forEach((claim: any) => { @@ -109,9 +112,10 @@ const isCredentialFittingPattern = ( } for (const claim of pattern.claims) { + const claimPath = claim.claimPath.replace(/\$\d+\./g, "$."); if ( (!Object.hasOwn(claim, "required") || claim.required) && - jp.paths(cred, claim.claimPath).length === 0 + jp.paths(cred, claimPath).length === 0 ) { return false; } @@ -151,6 +155,7 @@ const isValidConstraintFit = ( VP: any, ): boolean => { const credDict: any = {}; + credFit = credFit.flat(Infinity); for (let i = 0; i < policy.length; i++) { credDict[policy[i].credentialId] = credFit[i]; } @@ -210,10 +215,8 @@ const evaluateConstraint = ( case "equals": return a === b; case "equalsDID": - return ( - a.split("#").slice(0, -1).join("#") === - b.split("#").slice(0, -1).join("#") - ); + b = b.includes("#") ? b.split("#").slice(0, -1).join("#") : b; + return a.split("#").slice(0, -1).join("#") === b; case "startsWith": return a.startsWith(b); case "endsWith": @@ -308,14 +311,23 @@ const resolveValue = ( return resolveSingleNodeValue(expression, cred, VP); }; -const extractClaimsFromVC = (VC: any, policy: LoginPolicy) => { +const extractClaimsFromVC = ( + VC: any, + policy: LoginPolicy, + credentialIndex: string, +) => { let reiterateOuterLoop = false; for (let expectation of policy) { + const credentialId = expectation.credentialId; for (let pattern of expectation.patterns) { + if (credentialId !== credentialIndex) { + break; + } if (pattern.issuer === VC.issuer || pattern.issuer === "*") { const containsAllRequired = pattern.claims.filter((claim: ClaimEntry) => { - const claimPathLength = jp.paths(VC, claim.claimPath).length; + const claimPath = claim.claimPath.replace(`${credentialId}`, ""); + const claimPathLength = jp.paths(VC, claimPath).length; return claim.required && claimPathLength === 1; }).length > 0 || pattern.claims.filter((claim: ClaimEntry) => claim.required) @@ -331,7 +343,9 @@ const extractClaimsFromVC = (VC: any, policy: LoginPolicy) => { }; for (let claim of pattern.claims) { - const nodes = jp.nodes(VC, claim.claimPath); + const claimPath = claim.claimPath.replace(`${credentialId}`, ""); + + const nodes = jp.nodes(VC, claimPath); let newPath = claim.newPath; let value: any; @@ -366,6 +380,7 @@ const extractClaimsFromVC = (VC: any, policy: LoginPolicy) => { claim.token === "access_token" ? extractedClaims.tokenAccess : extractedClaims.tokenId; + jp.value(claimTarget, newPath, value); }