From 2de514271ab983c5bf6c8dae5aee7f95bcd4ece8 Mon Sep 17 00:00:00 2001 From: Mustafa Alsalfiti Date: Wed, 1 May 2024 15:37:26 +0200 Subject: [PATCH 1/5] fix: trying launchWebauthflow to logout Signed-off-by: Mirko Mollik --- .../src/app/app.component.html | 2 +- .../src/app/auth/auth.service.ts | 45 ++++++++++++++++++- .../environments/environment.development.ts | 14 ++++++ 3 files changed, 58 insertions(+), 3 deletions(-) diff --git a/apps/holder/projects/browser-extension/src/app/app.component.html b/apps/holder/projects/browser-extension/src/app/app.component.html index ac88b78f..b4171ffa 100644 --- a/apps/holder/projects/browser-extension/src/app/app.component.html +++ b/apps/holder/projects/browser-extension/src/app/app.component.html @@ -1,7 +1,7 @@
- +
qr_code_scanner console.log(err) + (err) => console.log(err), ); } } + /** + * Logs out the user + */ + async logout() { + // this.http.post(`${environment.keycloakHost}/realms/${environment.keycloakRealm}/protocol/openid-connect/logout`, null).subscribe(() => { + // console.log('successfully logged out'); + // this.router.navigateByUrl('/login'); + // }); + + await chrome.identity + .launchWebAuthFlow({ + interactive: false, + url: this.getLogoutUrl(), + }) + .then( + () => { + window.sessionStorage.clear(); + localStorage.clear(); + this.router.navigateByUrl('/login'); + }, + (err) => console.log(err), + ); + } + /** * Generates the auth url that will be used for login. * @returns @@ -78,4 +108,15 @@ export class AuthService { // Returning the extracted values return accessToken; } + + /** + * Generates the logout URL that will be used for logging out. + * @returns {string} The logout URL. + */ + private getLogoutUrl() { + let logoutUrl = `${environment.keycloakHost}/realms/${environment.keycloakRealm}/protocol/openid-connect/logout`; + logoutUrl += `?redirect_uri=${encodeURIComponent(chrome.identity.getRedirectURL())}`; + console.log(logoutUrl); + return logoutUrl; + } } diff --git a/apps/holder/projects/browser-extension/src/environments/environment.development.ts b/apps/holder/projects/browser-extension/src/environments/environment.development.ts index 0ad5d87e..5b0388aa 100644 --- a/apps/holder/projects/browser-extension/src/environments/environment.development.ts +++ b/apps/holder/projects/browser-extension/src/environments/environment.development.ts @@ -1,3 +1,15 @@ +// eslint-disable-next-line @typescript-eslint/no-namespace +export declare namespace globalThis { + let environment: { + backendUrl: string; + keycloakHost: string; + keycloakClient: string; + keycloakRealm: string; + demoIssuer: string; + demoVerifier: string; + }; +} + export const environment = { backendUrl: 'http://localhost:3000', keycloakHost: 'http://localhost:8080', @@ -6,3 +18,5 @@ export const environment = { demoIssuer: 'http://localhost:3001', demoVerifier: 'http://localhost:3002', }; + +globalThis.environment = environment; From 591843120133e1a3568df59f896edafc4828699d Mon Sep 17 00:00:00 2001 From: Mirko Mollik Date: Wed, 1 May 2024 14:53:20 +0200 Subject: [PATCH 2/5] Improve login redirect Signed-off-by: Mirko Mollik --- apps/holder/projects/pwa/src/app/login/login.component.html | 4 +--- apps/holder/projects/pwa/src/app/login/login.component.ts | 4 ++++ 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/apps/holder/projects/pwa/src/app/login/login.component.html b/apps/holder/projects/pwa/src/app/login/login.component.html index d66af68a..f80b2a44 100644 --- a/apps/holder/projects/pwa/src/app/login/login.component.html +++ b/apps/holder/projects/pwa/src/app/login/login.component.html @@ -1,3 +1 @@ - + diff --git a/apps/holder/projects/pwa/src/app/login/login.component.ts b/apps/holder/projects/pwa/src/app/login/login.component.ts index 7a07c852..0ae7f6f8 100644 --- a/apps/holder/projects/pwa/src/app/login/login.component.ts +++ b/apps/holder/projects/pwa/src/app/login/login.component.ts @@ -11,4 +11,8 @@ import { MatButtonModule } from '@angular/material/button'; }) export class LoginComponent { constructor(public authService: AuthService) {} + + login() { + this.authService.login('credentials'); + } } From 7ee6e5fcb6789646ba1105c47548e063af2cd6d0 Mon Sep 17 00:00:00 2001 From: Mirko Mollik Date: Wed, 1 May 2024 18:52:55 +0200 Subject: [PATCH 3/5] Make issuer and verifier input more dynamic Fixes #15 Signed-off-by: Mirko Mollik --- apps/issuer/package.json | 1 + apps/issuer/src/config.ts | 22 + apps/issuer/src/issuer.ts | 16 +- apps/issuer/src/main.ts | 3 +- apps/issuer/tsconfig.json | 2 +- apps/verifier/package.json | 1 + apps/verifier/src/RPManager.ts | 31 +- apps/verifier/src/config.ts | 22 + apps/verifier/src/main.ts | 2 +- apps/verifier/src/session-manager.ts | 467 ++++++++++++++++++ package.json | 2 +- ...3.3.1.patch => @sphereon__pex@3.3.3.patch} | 50 +- pnpm-lock.yaml | 24 +- 13 files changed, 617 insertions(+), 26 deletions(-) create mode 100644 apps/issuer/src/config.ts create mode 100644 apps/verifier/src/config.ts create mode 100644 apps/verifier/src/session-manager.ts rename patches/{@sphereon__pex@3.3.1.patch => @sphereon__pex@3.3.3.patch} (66%) diff --git a/apps/issuer/package.json b/apps/issuer/package.json index 7ae5b8bb..7bb19599 100644 --- a/apps/issuer/package.json +++ b/apps/issuer/package.json @@ -40,6 +40,7 @@ "dotenv": "^16.4.5", "express": "^4.19.2", "express-list-routes": "^1.2.1", + "joi": "^17.13.0", "jose": "^5.2.4", "passport-azure-ad": "^4.3.5", "passport-http-bearer": "^1.0.1", diff --git a/apps/issuer/src/config.ts b/apps/issuer/src/config.ts new file mode 100644 index 00000000..e32a3796 --- /dev/null +++ b/apps/issuer/src/config.ts @@ -0,0 +1,22 @@ +import Joi from 'joi'; +import 'dotenv/config'; + +/** + * Define the environment variables schema + */ +const envVarsSchema = Joi.object() + .keys({ + PORT: Joi.number().default(3000), + CONFIG_RELOAD: Joi.boolean().default(false), + ISSUER_BASE_URL: Joi.string().required(), + NODE_ENVIRONMENT: Joi.string() + .valid('development', 'production') + .default('development'), + }) + .unknown(); + +const { error, value: envVars } = envVarsSchema.validate(process.env); + +if (error) { + throw new Error(`Config validation error: ${error.message}`); +} diff --git a/apps/issuer/src/issuer.ts b/apps/issuer/src/issuer.ts index 250d0154..1c0f6822 100644 --- a/apps/issuer/src/issuer.ts +++ b/apps/issuer/src/issuer.ts @@ -5,12 +5,13 @@ import { CredentialSchema } from './types.js'; /** * The issuer class is responsible for managing the credentials and the metadata of the issuer. + * In case the CONFIG_REALOD environment variable is set, the issuer will reload the configuration every time a method is called. */ export class Issuer { /** * The metadata of the issuer. */ - private metadata: CredentialIssuerMetadataOpts; + private metadata!: CredentialIssuerMetadataOpts; /** * The credentials supported by the issuer. @@ -21,6 +22,10 @@ export class Issuer { * Creates a new instance of the issuer. */ constructor() { + this.loadConfig(); + } + + private loadConfig() { //instead of reading at the beginning, we could implement a read on demand. this.metadata = JSON.parse( readFileSync(join('templates', 'metadata.json'), 'utf-8') @@ -54,6 +59,9 @@ export class Issuer { * @returns */ getCredential(id: string) { + if (process.env.CONFIG_RELOAD) { + this.loadConfig(); + } const credential = this.credentials.get(id); if (!credential) { throw new Error(`The credential with the id ${id} is not supported.`); @@ -67,6 +75,9 @@ export class Issuer { * @returns */ getDisclosureFrame(id: string) { + if (process.env.CONFIG_RELOAD) { + this.loadConfig(); + } const credential = this.credentials.get(id); if (!credential) { throw new Error(`The credential with the id ${id} is not supported.`); @@ -78,6 +89,9 @@ export class Issuer { * Returns the metadata of the issuer. */ getMetadata() { + if (process.env.CONFIG_RELOAD) { + this.loadConfig(); + } return this.metadata; } } diff --git a/apps/issuer/src/main.ts b/apps/issuer/src/main.ts index 7e9fcfc1..fbc58299 100644 --- a/apps/issuer/src/main.ts +++ b/apps/issuer/src/main.ts @@ -1,3 +1,4 @@ +import './config.js'; import { ES256, digest, generateSalt } from '@sd-jwt/crypto-nodejs'; import { SDJwtVcInstance } from '@sd-jwt/sd-jwt-vc'; import { @@ -18,7 +19,7 @@ import { import { OID4VCIServer } from '@sphereon/oid4vci-issuer-server'; import { SdJwtDecodedVerifiableCredentialPayload } from '@sphereon/ssi-types'; import { DIDDocument } from 'did-resolver'; -import 'dotenv/config'; + import expressListRoutes from 'express-list-routes'; import { JWK, diff --git a/apps/issuer/tsconfig.json b/apps/issuer/tsconfig.json index b45706a4..925b848b 100644 --- a/apps/issuer/tsconfig.json +++ b/apps/issuer/tsconfig.json @@ -11,5 +11,5 @@ "strict": true /* Enable all strict type-checking options. */, "skipLibCheck": true /* Skip type checking all .d.ts files. */ }, - "include": ["src/**/*"] + "include": ["src/**/*", "src/config.ts"] } diff --git a/apps/verifier/package.json b/apps/verifier/package.json index 5cd60a6e..f2ba392f 100644 --- a/apps/verifier/package.json +++ b/apps/verifier/package.json @@ -40,6 +40,7 @@ "dotenv": "^16.4.5", "express": "^4.19.2", "express-list-routes": "^1.2.1", + "joi": "^17.13.0", "jose": "^5.2.4", "passport-azure-ad": "^4.3.5", "passport-http-bearer": "^1.0.1", diff --git a/apps/verifier/src/RPManager.ts b/apps/verifier/src/RPManager.ts index e686a7d2..d071c69d 100644 --- a/apps/verifier/src/RPManager.ts +++ b/apps/verifier/src/RPManager.ts @@ -1,7 +1,6 @@ import { ES256, digest } from '@sd-jwt/crypto-nodejs'; import { EcdsaSignature, - InMemoryRPSessionManager, JWK, JWTPayload, PassBy, @@ -29,6 +28,7 @@ import { importJWK, jwtVerify } from 'jose'; import { getKeys, getPublicKey } from './keys.js'; import { EventEmitter } from 'node:events'; import { RPInstance } from './types.js'; +import { InMemoryRPSessionManager } from './session-manager.js'; // load the keys const { privateKey, publicKey } = await getKeys(); @@ -40,7 +40,9 @@ export const kid = did; // create the event emitter to listen to events. export const eventEmitter = new EventEmitter(); //TODO: implement a persistant session manager so reloads don't lose state -export const sessionManager = new InMemoryRPSessionManager(eventEmitter); +export const sessionManager = new InMemoryRPSessionManager(eventEmitter, { + // maxAgeInSeconds: 10, +}); /** * The RPManager is responsible for managing the relying parties. @@ -58,11 +60,36 @@ export class RPManager { let rp = this.rp.get(id); if (!rp) { rp = this.buildRP(id); + // checks every minute if the rp has active sessions. If there is none, the rp is removed. We want to do this so we can update the rp with new input without losing state. This approach could be improved since we are waiting around 4 minutes for the last finished request until the entries are removed. + setInterval(async () => { + this.remove(id); + }, 1000 * 60); this.rp.set(id, rp); } return rp; } + /** + * Removes a relying party. This is useful when the instance should be restarted with a new definition. + * @param id + */ + async remove(id: string, force = false) { + const rp = this.rp.get(id); + if (!rp) { + return; + } + if ( + !force && + //the limit for a session is 5 minutes, so after this a session becomes idle an can be removed. + !(await (rp.rp.sessionManager as InMemoryRPSessionManager).isIdle()) + ) { + // we have active sessions, we don't want to remove the rp. But at this point we do not know if they have already finished it. We just know they are not over the maximum defined limit (default 5 minutes). + return; + } + this.rp.delete(id); + console.log('Removed the rp'); + } + private buildRP(id: string) { // create the relying party const verifierFile = readFileSync(join('templates', `${id}.json`), 'utf-8'); diff --git a/apps/verifier/src/config.ts b/apps/verifier/src/config.ts new file mode 100644 index 00000000..2f0b4cfe --- /dev/null +++ b/apps/verifier/src/config.ts @@ -0,0 +1,22 @@ +import Joi from 'joi'; +import 'dotenv/config'; + +/** + * Define the environment variables schema + */ +const envVarsSchema = Joi.object() + .keys({ + PORT: Joi.number().default(3000), + CONFIG_RELOAD: Joi.boolean().default(false), + VERIFIER_BASE_URL: Joi.string().required(), + NODE_ENVIRONMENT: Joi.string() + .valid('development', 'production') + .default('development'), + }) + .unknown(); + +const { error, value: envVars } = envVarsSchema.validate(process.env); + +if (error) { + throw new Error(`Config validation error: ${error.message}`); +} diff --git a/apps/verifier/src/main.ts b/apps/verifier/src/main.ts index 6b64edd8..90b29094 100644 --- a/apps/verifier/src/main.ts +++ b/apps/verifier/src/main.ts @@ -1,8 +1,8 @@ +import './config.js'; import { PresentationDefinitionLocation, SupportedVersion, } from '@sphereon/did-auth-siop'; -import 'dotenv/config'; import expressListRoutes from 'express-list-routes'; import { v4 } from 'uuid'; import { expressSupport } from './server.js'; diff --git a/apps/verifier/src/session-manager.ts b/apps/verifier/src/session-manager.ts new file mode 100644 index 00000000..ea02b77a --- /dev/null +++ b/apps/verifier/src/session-manager.ts @@ -0,0 +1,467 @@ +import { + IRPSessionManager, + AuthorizationRequestState, + AuthorizationResponseState, + AuthorizationEvents, + AuthorizationEvent, + AuthorizationRequest, + AuthorizationRequestStateStatus, + AuthorizationResponse, + AuthorizationResponseStateStatus, +} from '@sphereon/did-auth-siop'; +import { EventEmitter } from 'node:events'; + +//we copied the code from the session manager to implement a function that allows to check if there are still active sessions so we can reinint the rp that should use other definitions. + +/** + * Please note that this session manager is not really meant to be used in large production settings, as it stores everything in memory! + * It also doesn't do scheduled cleanups. It runs a cleanup whenever a request or response is received. In a high-volume production setting you will want scheduled cleanups running in the background + * Since this is a low level library we have not created a full-fledged implementation. + * We suggest to create your own implementation using the event system of the library + */ +export class InMemoryRPSessionManager implements IRPSessionManager { + private readonly authorizationRequests: Record< + string, + AuthorizationRequestState + > = {}; + private readonly authorizationResponses: Record< + string, + AuthorizationResponseState + > = {}; + + // stored by hashcode + private readonly nonceMapping: Record = {}; + // stored by hashcode + private readonly stateMapping: Record = {}; + private readonly maxAgeInSeconds: number; + + private static getKeysForCorrelationId( + mapping: Record, + correlationId: string + ): number[] { + return Object.entries(mapping) + .filter((entry) => entry[1] === correlationId) + .map((filtered) => Number.parseInt(filtered[0])); + } + + public constructor( + eventEmitter: EventEmitter, + opts?: { maxAgeInSeconds?: number } + ) { + if (!eventEmitter) { + throw Error( + 'RP Session manager depends on an event emitter in the application' + ); + } + this.maxAgeInSeconds = opts?.maxAgeInSeconds ?? 5 * 60; + eventEmitter.on( + AuthorizationEvents.ON_AUTH_REQUEST_CREATED_SUCCESS, + this.onAuthorizationRequestCreatedSuccess.bind(this) + ); + eventEmitter.on( + AuthorizationEvents.ON_AUTH_REQUEST_CREATED_FAILED, + this.onAuthorizationRequestCreatedFailed.bind(this) + ); + eventEmitter.on( + AuthorizationEvents.ON_AUTH_REQUEST_SENT_SUCCESS, + this.onAuthorizationRequestSentSuccess.bind(this) + ); + eventEmitter.on( + AuthorizationEvents.ON_AUTH_REQUEST_SENT_FAILED, + this.onAuthorizationRequestSentFailed.bind(this) + ); + eventEmitter.on( + AuthorizationEvents.ON_AUTH_RESPONSE_RECEIVED_SUCCESS, + this.onAuthorizationResponseReceivedSuccess.bind(this) + ); + eventEmitter.on( + AuthorizationEvents.ON_AUTH_RESPONSE_RECEIVED_FAILED, + this.onAuthorizationResponseReceivedFailed.bind(this) + ); + eventEmitter.on( + AuthorizationEvents.ON_AUTH_RESPONSE_VERIFIED_SUCCESS, + this.onAuthorizationResponseVerifiedSuccess.bind(this) + ); + eventEmitter.on( + AuthorizationEvents.ON_AUTH_RESPONSE_VERIFIED_FAILED, + this.onAuthorizationResponseVerifiedFailed.bind(this) + ); + } + + /** + * Checks if there are entries in the session manager. If not the RP can be reinitialized in a safe way. + */ + isIdle(): Promise { + return this.cleanup().then( + () => + Object.keys(this.authorizationRequests).length === 0 && + Object.keys(this.authorizationResponses).length === 0 + ); + } + + async getRequestStateByCorrelationId( + correlationId: string, + errorOnNotFound?: boolean + ): Promise { + return await this.getFromMapping( + 'correlationId', + correlationId, + this.authorizationRequests, + errorOnNotFound + ); + } + + async getRequestStateByNonce( + nonce: string, + errorOnNotFound?: boolean + ): Promise { + return await this.getFromMapping( + 'nonce', + nonce, + this.authorizationRequests, + errorOnNotFound + ); + } + + async getRequestStateByState( + state: string, + errorOnNotFound?: boolean + ): Promise { + return await this.getFromMapping( + 'state', + state, + this.authorizationRequests, + errorOnNotFound + ); + } + + async getResponseStateByCorrelationId( + correlationId: string, + errorOnNotFound?: boolean + ): Promise { + return await this.getFromMapping( + 'correlationId', + correlationId, + this.authorizationResponses, + errorOnNotFound + ); + } + + async getResponseStateByNonce( + nonce: string, + errorOnNotFound?: boolean + ): Promise { + return await this.getFromMapping( + 'nonce', + nonce, + this.authorizationResponses, + errorOnNotFound + ); + } + + async getResponseStateByState( + state: string, + errorOnNotFound?: boolean + ): Promise { + return await this.getFromMapping( + 'state', + state, + this.authorizationResponses, + errorOnNotFound + ); + } + + private async getFromMapping( + type: 'nonce' | 'state' | 'correlationId', + value: string, + mapping: Record, + errorOnNotFound?: boolean + ): Promise { + const correlationId = await this.getCorrelationIdImpl( + type, + value, + errorOnNotFound + ); + const result = mapping[correlationId as string] as T; + if (!result && errorOnNotFound) { + throw Error( + `Could not find ${type} from correlation id ${correlationId}` + ); + } + return result; + } + + private async onAuthorizationRequestCreatedSuccess( + event: AuthorizationEvent + ): Promise { + this.cleanup().catch((error) => console.log(JSON.stringify(error))); + this.updateState( + 'request', + event, + AuthorizationRequestStateStatus.CREATED + ).catch((error) => console.log(JSON.stringify(error))); + } + + private async onAuthorizationRequestCreatedFailed( + event: AuthorizationEvent + ): Promise { + this.cleanup().catch((error) => console.log(JSON.stringify(error))); + this.updateState( + 'request', + event, + AuthorizationRequestStateStatus.ERROR + ).catch((error) => console.log(JSON.stringify(error))); + } + + private async onAuthorizationRequestSentSuccess( + event: AuthorizationEvent + ): Promise { + this.cleanup().catch((error) => console.log(JSON.stringify(error))); + this.updateState( + 'request', + event, + AuthorizationRequestStateStatus.SENT + ).catch((error) => console.log(JSON.stringify(error))); + } + + private async onAuthorizationRequestSentFailed( + event: AuthorizationEvent + ): Promise { + this.cleanup().catch((error) => console.log(JSON.stringify(error))); + this.updateState( + 'request', + event, + AuthorizationRequestStateStatus.ERROR + ).catch((error) => console.log(JSON.stringify(error))); + } + + private async onAuthorizationResponseReceivedSuccess( + event: AuthorizationEvent + ): Promise { + this.cleanup().catch((error) => console.log(JSON.stringify(error))); + await this.updateState( + 'response', + event, + AuthorizationResponseStateStatus.RECEIVED + ); + } + + private async onAuthorizationResponseReceivedFailed( + event: AuthorizationEvent + ): Promise { + this.cleanup().catch((error) => console.log(JSON.stringify(error))); + await this.updateState( + 'response', + event, + AuthorizationResponseStateStatus.ERROR + ); + } + + private async onAuthorizationResponseVerifiedFailed( + event: AuthorizationEvent + ): Promise { + await this.updateState( + 'response', + event, + AuthorizationResponseStateStatus.ERROR + ); + } + + private async onAuthorizationResponseVerifiedSuccess( + event: AuthorizationEvent + ): Promise { + await this.updateState( + 'response', + event, + AuthorizationResponseStateStatus.VERIFIED + ); + } + + public async getCorrelationIdByNonce( + nonce: string, + errorOnNotFound?: boolean + ): Promise { + return await this.getCorrelationIdImpl('nonce', nonce, errorOnNotFound); + } + + public async getCorrelationIdByState( + state: string, + errorOnNotFound?: boolean + ): Promise { + return await this.getCorrelationIdImpl('state', state, errorOnNotFound); + } + + private async getCorrelationIdImpl( + type: 'nonce' | 'state' | 'correlationId', + value: string, + errorOnNotFound?: boolean + ): Promise { + if (!value || !type) { + throw Error('No type or value provided'); + } + if (type === 'correlationId') { + return value; + } + const hash = await hashCode(value); + const correlationId = + type === 'nonce' ? this.nonceMapping[hash] : this.stateMapping[hash]; + if (!correlationId && errorOnNotFound) { + throw Error(`Could not find ${type} value for ${value}`); + } + return correlationId; + } + + private async updateMapping( + mapping: Record, + event: AuthorizationEvent, + key: string, + value: string | undefined, + allowExisting: boolean + ) { + const hash = await hashcodeForValue(event, key); + const existing = mapping[hash]; + if (existing) { + if (!allowExisting) { + throw Error( + `Mapping exists for key ${key} and we do not allow overwriting values` + ); + // biome-ignore lint/style/noUselessElse: + } else if (value && existing !== value) { + throw Error('Value changed for key'); + } + } + if (!value) { + delete mapping[hash]; + } else { + mapping[hash] = value; + } + } + + private async updateState( + type: 'request' | 'response', + event: AuthorizationEvent, + status: AuthorizationRequestStateStatus | AuthorizationResponseStateStatus + ): Promise { + if (!event) { + throw new Error('event not present'); + // biome-ignore lint/style/noUselessElse: + } else if (!event.correlationId) { + throw new Error( + `'${type} ${status}' event without correlation id received` + ); + } + try { + const eventState = { + correlationId: event.correlationId, + ...(type === 'request' ? { request: event.subject } : {}), + ...(type === 'response' ? { response: event.subject } : {}), + ...(event.error ? { error: event.error } : {}), + status, + timestamp: event.timestamp, + lastUpdated: event.timestamp, + }; + if (type === 'request') { + this.authorizationRequests[event.correlationId] = + eventState as AuthorizationRequestState; + // We do not await these + this.updateMapping( + this.nonceMapping, + event, + 'nonce', + event.correlationId, + true + ).catch((error) => console.log(JSON.stringify(error))); + this.updateMapping( + this.stateMapping, + event, + 'state', + event.correlationId, + true + ).catch((error) => console.log(JSON.stringify(error))); + } else { + this.authorizationResponses[event.correlationId] = + eventState as AuthorizationResponseState; + } + } catch (error: unknown) { + console.log(`Error in update state happened: ${error}`); + // TODO VDX-166 handle error + } + } + + async deleteStateForCorrelationId(correlationId: string) { + InMemoryRPSessionManager.cleanMappingForCorrelationId( + this.nonceMapping, + correlationId + ).catch((error) => console.log(JSON.stringify(error))); + InMemoryRPSessionManager.cleanMappingForCorrelationId( + this.stateMapping, + correlationId + ).catch((error) => console.log(JSON.stringify(error))); + delete this.authorizationRequests[correlationId]; + delete this.authorizationResponses[correlationId]; + } + private static async cleanMappingForCorrelationId( + mapping: Record, + correlationId: string + ): Promise { + const keys = InMemoryRPSessionManager.getKeysForCorrelationId( + mapping, + correlationId + ); + if (keys && keys.length > 0) { + // biome-ignore lint/complexity/noForEach: + keys.forEach((key) => delete mapping[key]); + } + } + + private async cleanup() { + const now = Date.now(); + const maxAgeInMS = this.maxAgeInSeconds * 1000; + + const cleanupCorrelations = ( + reqByCorrelationId: [ + string, + AuthorizationRequestState | AuthorizationResponseState, + ] + ) => { + const correlationId = reqByCorrelationId[0]; + const authRequest = reqByCorrelationId[1]; + if (authRequest) { + const ts = authRequest.lastUpdated || authRequest.timestamp; + if (maxAgeInMS !== 0 && now > ts + maxAgeInMS) { + this.deleteStateForCorrelationId(correlationId); + } + } + }; + + // biome-ignore lint/complexity/noForEach: + Object.entries(this.authorizationRequests).forEach((reqByCorrelationId) => { + cleanupCorrelations.call(this, reqByCorrelationId); + }); + // biome-ignore lint/complexity/noForEach: + Object.entries(this.authorizationResponses).forEach( + (resByCorrelationId) => { + cleanupCorrelations.call(this, resByCorrelationId); + } + ); + } +} + +async function hashcodeForValue( + event: AuthorizationEvent, + key: string +): Promise { + const value = (await event.subject.getMergedProperty(key)) as string; + if (!value) { + throw Error(`No value found for key ${key} in Authorization Request`); + } + return hashCode(value); +} + +function hashCode(s: string): number { + let h = 1; + for (let i = 0; i < s.length; i++) + h = (Math.imul(31, h) + s.charCodeAt(i)) | 0; + + return h; +} diff --git a/package.json b/package.json index ce8d7796..af58902e 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,7 @@ "@sphereon/ssi-types": "^0.22.0" }, "patchedDependencies": { - "@sphereon/pex@3.3.1": "patches/@sphereon__pex@3.3.1.patch" + "@sphereon/pex@3.3.3": "patches/@sphereon__pex@3.3.3.patch" } }, "devDependencies": { diff --git a/patches/@sphereon__pex@3.3.1.patch b/patches/@sphereon__pex@3.3.3.patch similarity index 66% rename from patches/@sphereon__pex@3.3.1.patch rename to patches/@sphereon__pex@3.3.3.patch index e6717d82..3342a268 100644 --- a/patches/@sphereon__pex@3.3.1.patch +++ b/patches/@sphereon__pex@3.3.3.patch @@ -20,6 +20,19 @@ index b749cf35e6962380cee8671bd6c5f033ce9dcb78..b6db8459009e0b00e1328b7a103886b0 }, }; presentation = Object.assign(Object.assign({}, presentation), { kbJwt }); +diff --git a/dist/browser/lib/signing/types.d.ts b/dist/browser/lib/signing/types.d.ts +index 979338af02eb6923b98642bf2f84ebca808f090a..2a9ae541faef1ef5ed269c9754a7fb0d43e391dc 100644 +--- a/dist/browser/lib/signing/types.d.ts ++++ b/dist/browser/lib/signing/types.d.ts +@@ -66,7 +66,7 @@ export interface SdJwtKbJwtInput { + }; + payload: { + iat: number; +- _sd_hash: string; ++ sd_hash: string; + nonce?: string; + }; + } diff --git a/dist/main/lib/PEX.js b/dist/main/lib/PEX.js index b749cf35e6962380cee8671bd6c5f033ce9dcb78..b6db8459009e0b00e1328b7a103886b0edb6891e 100644 --- a/dist/main/lib/PEX.js @@ -42,19 +55,23 @@ index b749cf35e6962380cee8671bd6c5f033ce9dcb78..b6db8459009e0b00e1328b7a103886b0 }, }; presentation = Object.assign(Object.assign({}, presentation), { kbJwt }); +diff --git a/dist/main/lib/signing/types.d.ts b/dist/main/lib/signing/types.d.ts +index 979338af02eb6923b98642bf2f84ebca808f090a..2a9ae541faef1ef5ed269c9754a7fb0d43e391dc 100644 +--- a/dist/main/lib/signing/types.d.ts ++++ b/dist/main/lib/signing/types.d.ts +@@ -66,7 +66,7 @@ export interface SdJwtKbJwtInput { + }; + payload: { + iat: number; +- _sd_hash: string; ++ sd_hash: string; + nonce?: string; + }; + } diff --git a/dist/module/lib/PEX.js b/dist/module/lib/PEX.js -index 65aa74adf86973e99345938c826613ddcba6ca7e..b963ec86360ac1d861583031fbea56d1d8f40731 100644 +index 65aa74adf86973e99345938c826613ddcba6ca7e..464545177f8d156ba07019e20e0e6c17e6d086ef 100644 --- a/dist/module/lib/PEX.js +++ b/dist/module/lib/PEX.js -@@ -3,7 +3,7 @@ import { Status } from './ConstraintUtils'; - import { EvaluationClientWrapper } from './evaluation'; - import { PresentationSubmissionLocation, } from './signing'; - import { PEVersion, SSITypesBuilder } from './types'; --import { calculateSdHash, definitionVersionDiscovery, getSubjectIdsAsString } from './utils'; -+import { calculatesd_hash: sdHash, definitionVersionDiscovery, getSubjectIdsAsString } from './utils'; - import { PresentationDefinitionV1VB, PresentationDefinitionV2VB, PresentationSubmissionVB, ValidationEngine } from './validation'; - /** - * This is the main interfacing class to be used by developers using the PEX library. @@ -174,7 +174,7 @@ export class PEX { // aud MUST be set by the signer or provided by e.g. SIOP/OpenID4VP lib payload: { @@ -73,3 +90,16 @@ index 65aa74adf86973e99345938c826613ddcba6ca7e..b963ec86360ac1d861583031fbea56d1 }, }; presentation = { +diff --git a/dist/module/lib/signing/types.d.ts b/dist/module/lib/signing/types.d.ts +index 979338af02eb6923b98642bf2f84ebca808f090a..2a9ae541faef1ef5ed269c9754a7fb0d43e391dc 100644 +--- a/dist/module/lib/signing/types.d.ts ++++ b/dist/module/lib/signing/types.d.ts +@@ -66,7 +66,7 @@ export interface SdJwtKbJwtInput { + }; + payload: { + iat: number; +- _sd_hash: string; ++ sd_hash: string; + nonce?: string; + }; + } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 70598ccb..f2610502 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,9 +8,9 @@ overrides: '@sphereon/ssi-types': ^0.22.0 patchedDependencies: - '@sphereon/pex@3.3.1': - hash: aasonhtvgrpgzz22orxg5mryvi - path: patches/@sphereon__pex@3.3.1.patch + '@sphereon/pex@3.3.3': + hash: bzkrsrlwcpq6eepymelnbd2pca + path: patches/@sphereon__pex@3.3.3.patch importers: @@ -60,7 +60,7 @@ importers: version: 0.10.3(encoding@0.1.13) '@sphereon/pex': specifier: ^3.3.3 - version: 3.3.3 + version: 3.3.3(patch_hash=bzkrsrlwcpq6eepymelnbd2pca) '@sphereon/ssi-types': specifier: ^0.22.0 version: 0.22.0 @@ -208,7 +208,7 @@ importers: version: 0.10.1(encoding@0.1.13) '@sphereon/pex': specifier: ^3.3.1 - version: 3.3.1(patch_hash=aasonhtvgrpgzz22orxg5mryvi) + version: 3.3.1 '@sphereon/ssi-types': specifier: ^0.22.0 version: 0.22.0 @@ -354,6 +354,9 @@ importers: express-list-routes: specifier: ^1.2.1 version: 1.2.1 + joi: + specifier: ^17.13.0 + version: 17.13.0 jose: specifier: ^5.2.4 version: 5.2.4 @@ -433,6 +436,9 @@ importers: express-list-routes: specifier: ^1.2.1 version: 1.2.1 + joi: + specifier: ^17.13.0 + version: 17.13.0 jose: specifier: ^5.2.4 version: 5.2.4 @@ -10810,7 +10816,7 @@ snapshots: dependencies: '@astronautlabs/jsonpath': 1.1.2 '@sphereon/did-uni-client': 0.6.2(encoding@0.1.13) - '@sphereon/pex': 3.3.1(patch_hash=aasonhtvgrpgzz22orxg5mryvi) + '@sphereon/pex': 3.3.1 '@sphereon/pex-models': 2.2.4 '@sphereon/ssi-types': 0.22.0 '@sphereon/wellknown-dids-client': 0.1.3(encoding@0.1.13) @@ -10831,7 +10837,7 @@ snapshots: dependencies: '@astronautlabs/jsonpath': 1.1.2 '@sphereon/did-uni-client': 0.6.3(encoding@0.1.13) - '@sphereon/pex': 3.3.3 + '@sphereon/pex': 3.3.3(patch_hash=bzkrsrlwcpq6eepymelnbd2pca) '@sphereon/pex-models': 2.2.4 '@sphereon/ssi-types': 0.22.0 '@sphereon/wellknown-dids-client': 0.1.3(encoding@0.1.13) @@ -10940,7 +10946,7 @@ snapshots: '@sphereon/pex-models@2.2.4': {} - '@sphereon/pex@3.3.1(patch_hash=aasonhtvgrpgzz22orxg5mryvi)': + '@sphereon/pex@3.3.1': dependencies: '@astronautlabs/jsonpath': 1.1.2 '@sd-jwt/decode': 0.6.1 @@ -10955,7 +10961,7 @@ snapshots: string.prototype.matchall: 4.0.11 uint8arrays: 3.1.1 - '@sphereon/pex@3.3.3': + '@sphereon/pex@3.3.3(patch_hash=bzkrsrlwcpq6eepymelnbd2pca)': dependencies: '@astronautlabs/jsonpath': 1.1.2 '@sd-jwt/decode': 0.6.1 From 91e40705b808585b8862e7c539fe51bd4bb566aa Mon Sep 17 00:00:00 2001 From: Mirko Mollik Date: Wed, 1 May 2024 20:29:21 +0200 Subject: [PATCH 4/5] add endpoint to delete a rp manually Signed-off-by: Mirko Mollik --- apps/verifier/src/RPManager.ts | 22 +++++++++++++++------- apps/verifier/src/main.ts | 12 ++++++++++++ 2 files changed, 27 insertions(+), 7 deletions(-) diff --git a/apps/verifier/src/RPManager.ts b/apps/verifier/src/RPManager.ts index d071c69d..fbccd896 100644 --- a/apps/verifier/src/RPManager.ts +++ b/apps/verifier/src/RPManager.ts @@ -18,7 +18,7 @@ import { } from '@sphereon/did-auth-siop'; import { JWkResolver, encodeDidJWK } from './did.js'; import { readFileSync } from 'node:fs'; -import { join } from 'node:path'; +import { join, normalize, sep } from 'node:path'; import { VerifierRP } from './types.js'; import { SDJwtVcInstance } from '@sd-jwt/sd-jwt-vc'; import { KbVerifier, Verifier } from '@sd-jwt/types'; @@ -60,10 +60,12 @@ export class RPManager { let rp = this.rp.get(id); if (!rp) { rp = this.buildRP(id); - // checks every minute if the rp has active sessions. If there is none, the rp is removed. We want to do this so we can update the rp with new input without losing state. This approach could be improved since we are waiting around 4 minutes for the last finished request until the entries are removed. - setInterval(async () => { - this.remove(id); - }, 1000 * 60); + if (process.env.CONFIG_RELOAD) { + // checks every minute if the rp has active sessions. If there is none, the rp is removed. We want to do this so we can update the rp with new input without losing state. This approach could be improved since we are waiting around 4 minutes for the last finished request until the entries are removed. + setInterval(async () => { + this.remove(id); + }, 1000 * 60); + } this.rp.set(id, rp); } return rp; @@ -90,9 +92,15 @@ export class RPManager { console.log('Removed the rp'); } + // create the relying party private buildRP(id: string) { - // create the relying party - const verifierFile = readFileSync(join('templates', `${id}.json`), 'utf-8'); + // escape potential path traversal attacks + const safeId = normalize(id).split(sep).pop(); + // instead of reading a file, we could pass a storage reference. Then the storage can be implemented in different ways, like using a database or a file system. + const verifierFile = readFileSync( + join('templates', `${safeId}.json`), + 'utf-8' + ); if (!verifierFile) { throw new Error(`The verifier with the id ${id} is not supported.`); } diff --git a/apps/verifier/src/main.ts b/apps/verifier/src/main.ts index 90b29094..100dcec7 100644 --- a/apps/verifier/src/main.ts +++ b/apps/verifier/src/main.ts @@ -87,6 +87,18 @@ expressSupport.express.post( } ); +// only set this when reload is activated +if (process.env.CONFIG_RELOAD) { + /** + * This will remove a rp so it can be reloaded with new values + */ + expressSupport.express.delete('/siop/:rp', async (req, res) => { + const rpId = req.params.rp; + await rpManager.remove(rpId, true); + res.send(); + }); +} + expressSupport.express.get('/health', async (req, res) => { res.send('ok'); }); From 277226d42aaa51087a1bf8bbcaa6838b5507ca66 Mon Sep 17 00:00:00 2001 From: Mirko Mollik Date: Wed, 1 May 2024 22:11:01 +0200 Subject: [PATCH 5/5] fix: implement logout function into extension Signed-off-by: Mirko Mollik --- .../src/app/app.component.html | 6 +- .../src/app/app.component.ts | 11 +- .../browser-extension/src/app/app.config.ts | 10 +- .../src/app/auth/auth.guard.ts | 19 ++- .../src/app/auth/auth.service.ts | 157 ++++++++++++------ .../src/app/login/login.component.ts | 8 +- 6 files changed, 147 insertions(+), 64 deletions(-) diff --git a/apps/holder/projects/browser-extension/src/app/app.component.html b/apps/holder/projects/browser-extension/src/app/app.component.html index b4171ffa..24006282 100644 --- a/apps/holder/projects/browser-extension/src/app/app.component.html +++ b/apps/holder/projects/browser-extension/src/app/app.component.html @@ -1,7 +1,11 @@
- +
qr_code_scanner { + this.isAuthenticated = isAuthenticated; + }); + } } diff --git a/apps/holder/projects/browser-extension/src/app/app.config.ts b/apps/holder/projects/browser-extension/src/app/app.config.ts index 93d9f7be..a6cddd23 100644 --- a/apps/holder/projects/browser-extension/src/app/app.config.ts +++ b/apps/holder/projects/browser-extension/src/app/app.config.ts @@ -10,14 +10,18 @@ import { ApiModule, Configuration } from '../../../shared/api/kms'; import { AuthServiceInterface } from '../../../shared/settings/settings.component'; import { AuthService } from './auth/auth.service'; +// eslint-disable-next-line @typescript-eslint/no-namespace +export declare namespace globalThis { + let token: string; +} + function getConfiguration() { return new Configuration({ //TODO: the basepath is static, therefore we can not set it during the login process. basePath: environment.backendUrl, credentials: { - oauth2: () => { - return localStorage.getItem('accessToken') as string; - }, + // we fetch the token via globalThis since we can not access it via the chrome.storage API since it's async. + oauth2: () => globalThis.token, }, }); } diff --git a/apps/holder/projects/browser-extension/src/app/auth/auth.guard.ts b/apps/holder/projects/browser-extension/src/app/auth/auth.guard.ts index 7f6807b2..0dc06d9b 100644 --- a/apps/holder/projects/browser-extension/src/app/auth/auth.guard.ts +++ b/apps/holder/projects/browser-extension/src/app/auth/auth.guard.ts @@ -2,10 +2,21 @@ import { inject } from '@angular/core'; import { CanActivateFn } from '@angular/router'; import { AuthService } from './auth.service'; +// eslint-disable-next-line @typescript-eslint/no-namespace +export declare namespace globalThis { + let token: string; +} + export const authGuard: CanActivateFn = async () => { const authService: AuthService = inject(AuthService); - if (!authService.isAuthenticated()) { - authService.login(); - } - return true; + return authService + .isAuthenticated() + .then(async () => { + globalThis.token = await authService.getToken(); + return true; + }) + .catch(() => { + authService.login(); + return false; + }); }; diff --git a/apps/holder/projects/browser-extension/src/app/auth/auth.service.ts b/apps/holder/projects/browser-extension/src/app/auth/auth.service.ts index 5f1a2e5d..7f3befe3 100644 --- a/apps/holder/projects/browser-extension/src/app/auth/auth.service.ts +++ b/apps/holder/projects/browser-extension/src/app/auth/auth.service.ts @@ -2,34 +2,57 @@ import { Injectable } from '@angular/core'; import { environment } from '../../environments/environment'; import { decodeJwt } from 'jose'; import { Router } from '@angular/router'; -import { HttpClient } from '@angular/common/http'; +import { BehaviorSubject } from 'rxjs'; + +interface Storage { + access_token: string; + expiration_time: number; + id_token: string; +} @Injectable({ providedIn: 'root', }) export class AuthService { - constructor( - private router: Router, - private http: HttpClient, - ) {} + changed: BehaviorSubject = new BehaviorSubject(false); + + constructor(private router: Router) {} /** * Checks if the access token exists and if it is expired * @returns */ - isAuthenticated() { - const token = localStorage.getItem('accessToken'); - if (!token) return false; - const jwt = decodeJwt(token as string); - if (jwt.exp && new Date(jwt.exp * 1000) < new Date()) return false; - return true; + isAuthenticated(): Promise { + return new Promise((resolve, reject) => { + chrome.storage.local.get(['access_token'], (values) => { + const token = (values as Storage).access_token; + if (!token) return reject(false); + const jwt = decodeJwt(token as string); + if (jwt.exp && new Date(jwt.exp * 1000) < new Date()) + return reject(false); + this.changed.next(true); + resolve(true); + }); + }); + } + + /** + * Gets the access token from the storage + * @returns + */ + getToken(): Promise { + return new Promise((resolve) => { + chrome.storage.local.get('access_token', (values) => { + const token = (values as Storage).access_token; + resolve(token); + }); + }); } /** * Launches the web auth flow to authenticate the user */ async login() { - console.log(this.getAuthUrl()); if (typeof chrome.identity !== 'undefined') { await chrome.identity .launchWebAuthFlow({ @@ -39,52 +62,53 @@ export class AuthService { .then( (redirectUri: string | undefined) => { if (!redirectUri) return; - const accessToken = this.extractTokenFromRedirectUri(redirectUri); - localStorage.setItem('accessToken', accessToken); + const { accessToken, expiresIn, idToken } = + this.parseUrl(redirectUri); + return chrome.storage.local.set({ + access_token: accessToken, + id_token: idToken, + expiration_time: + new Date().getTime() / 1000 + Number.parseInt(expiresIn), + }); }, - (err) => console.log(err), + (err) => console.log(err) ); } } - /** - * Logs out the user + /** + * Generates a random string + * @returns */ - async logout() { - // this.http.post(`${environment.keycloakHost}/realms/${environment.keycloakRealm}/protocol/openid-connect/logout`, null).subscribe(() => { - // console.log('successfully logged out'); - // this.router.navigateByUrl('/login'); - // }); - - await chrome.identity - .launchWebAuthFlow({ - interactive: false, - url: this.getLogoutUrl(), - }) - .then( - () => { - window.sessionStorage.clear(); - localStorage.clear(); - this.router.navigateByUrl('/login'); - }, - (err) => console.log(err), - ); - } + private generateRandomString() { + const array = new Uint32Array(28); + crypto.getRandomValues(array); + return Array.from(array, (dec) => `0${dec.toString(16)}`.substr(-2)).join( + '' + ); + } /** * Generates the auth url that will be used for login. * @returns */ private getAuthUrl() { - //TODO: it's not just the endpoint that needs to be passed, it's the host, client id and also the backend endpoints. We should provide this via an url that will be loaded and saved locally so it can be used on demand. const redirectURL = chrome.identity.getRedirectURL(); const scopes = ['openid']; - let authURL = `${environment.keycloakHost}/realms/${environment.keycloakRealm}/protocol/openid-connect/auth`; - authURL += `?client_id=${environment.keycloakClient}`; - authURL += '&response_type=token'; - authURL += `&redirect_uri=${encodeURIComponent(redirectURL)}`; - authURL += `&scope=${encodeURIComponent(scopes.join(' '))}`; - return authURL; + const nonce = this.generateRandomString(); + + const url = new URL( + `${environment.keycloakHost}/realms/${environment.keycloakRealm}/protocol/openid-connect/auth` + ); + const params = { + client_id: environment.keycloakClient, + response_type: 'id_token token', + redirect_uri: redirectURL, + nonce: nonce, + scope: scopes.join(' '), + }; + url.search = new URLSearchParams(params).toString(); + return url.toString(); } /** @@ -92,11 +116,16 @@ export class AuthService { * @param redirectUri * @returns */ - private extractTokenFromRedirectUri(redirectUri: string): string { + private parseUrl(redirectUri: string) { // Assuming redirectUri is the URL you provided const fragmentString = redirectUri.split('#')[1]; const params = new URLSearchParams(fragmentString); const accessToken = params.get('access_token') as string; + const idToken = params.get('id_token') as string; + const expiresIn = params.get('expires_in') as string; + if (!idToken || !accessToken || !expiresIn) { + throw new Error('Missing required parameters in redirect URL.'); + } //TODO parse the access_token to get the expiration time const payload = JSON.parse(window.atob(accessToken.split('.')[1])); const refreshTimer = @@ -106,17 +135,43 @@ export class AuthService { // Refresh the token 10 seconds before it expires setTimeout(() => this.login(), refreshTimer - 1000 * 10); // Returning the extracted values - return accessToken; + return { idToken, accessToken, expiresIn }; + } + + /** + * Logs out the user. It will close the window after the user is logged out. + */ + async logout() { + await chrome.identity + .launchWebAuthFlow({ + interactive: false, + url: await this.getLogoutUrl(), + }) + .then( + () => + chrome.storage.local.remove( + ['access_token', 'expiration_time', 'id_token'], + () => window.close() + ), + (err) => console.log(err) + ); } /** * Generates the logout URL that will be used for logging out. * @returns {string} The logout URL. */ - private getLogoutUrl() { - let logoutUrl = `${environment.keycloakHost}/realms/${environment.keycloakRealm}/protocol/openid-connect/logout`; - logoutUrl += `?redirect_uri=${encodeURIComponent(chrome.identity.getRedirectURL())}`; - console.log(logoutUrl); - return logoutUrl; + private getLogoutUrl(): Promise { + return new Promise((resolve) => { + chrome.storage.local.get(['id_token'], (values) => { + const idToken = (values as Storage).id_token; + const logoutUrl = + `${environment.keycloakHost}/realms/${environment.keycloakRealm}/protocol/openid-connect/logout` + + `?client_id=${environment.keycloakClient}` + + `&id_token_hint=${idToken}` + + `&post_logout_redirect_uri=${encodeURIComponent(chrome.identity.getRedirectURL(''))}`; + resolve(logoutUrl); + }); + }); } } diff --git a/apps/holder/projects/browser-extension/src/app/login/login.component.ts b/apps/holder/projects/browser-extension/src/app/login/login.component.ts index 34aa1b98..e25701de 100644 --- a/apps/holder/projects/browser-extension/src/app/login/login.component.ts +++ b/apps/holder/projects/browser-extension/src/app/login/login.component.ts @@ -36,9 +36,11 @@ export class LoginComponent implements OnInit { ) {} ngOnInit(): void { - if (this.authService.isAuthenticated()) { - this.router.navigate(['/credentials']); - } + this.authService.isAuthenticated().then((isAuthenticated) => { + if (isAuthenticated) { + this.router.navigate(['/credentials']); + } + }); } async login() {