@sphereon/did-auth-siop
Version:
Self Issued OpenID V2 (SIOPv2) and OpenID 4 Verifiable Presentations (OID4VP)
268 lines (233 loc) • 12.1 kB
text/typescript
import { EventEmitter } from 'events'
import { AuthorizationRequest } from '../authorization-request'
import { AuthorizationResponse } from '../authorization-response'
import {
AuthorizationEvent,
AuthorizationEvents,
AuthorizationRequestState,
AuthorizationRequestStateStatus,
AuthorizationResponseState,
AuthorizationResponseStateStatus,
} from '../types'
import { IRPSessionManager } from './types'
/**
* 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<number, string> = {}
// stored by hashcode
private readonly stateMapping: Record<number, string> = {}
private readonly maxAgeInSeconds: number
private static getKeysForCorrelationId(mapping: Record<number, string>, 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))
}
async getRequestStateByCorrelationId(correlationId: string, errorOnNotFound?: boolean): Promise<AuthorizationRequestState | undefined> {
return await this.getFromMapping('correlationId', correlationId, this.authorizationRequests, errorOnNotFound)
}
async getRequestStateByNonce(nonce: string, errorOnNotFound?: boolean): Promise<AuthorizationRequestState | undefined> {
return await this.getFromMapping('nonce', nonce, this.authorizationRequests, errorOnNotFound)
}
async getRequestStateByState(state: string, errorOnNotFound?: boolean): Promise<AuthorizationRequestState | undefined> {
return await this.getFromMapping('state', state, this.authorizationRequests, errorOnNotFound)
}
async getResponseStateByCorrelationId(correlationId: string, errorOnNotFound?: boolean): Promise<AuthorizationResponseState | undefined> {
return await this.getFromMapping('correlationId', correlationId, this.authorizationResponses, errorOnNotFound)
}
async getResponseStateByNonce(nonce: string, errorOnNotFound?: boolean): Promise<AuthorizationResponseState | undefined> {
return await this.getFromMapping('nonce', nonce, this.authorizationResponses, errorOnNotFound)
}
async getResponseStateByState(state: string, errorOnNotFound?: boolean): Promise<AuthorizationResponseState | undefined> {
return await this.getFromMapping('state', state, this.authorizationResponses, errorOnNotFound)
}
private async getFromMapping<T>(
type: 'nonce' | 'state' | 'correlationId',
value: string,
mapping: Record<string, T>,
errorOnNotFound?: boolean,
): Promise<T> {
const correlationId = await this.getCorrelationIdImpl(type, value, errorOnNotFound)
const result = mapping[correlationId] as T
if (!result && errorOnNotFound) {
throw Error(`Could not find '${type}' belonging to correlation id '${correlationId}'`)
}
return result
}
private async onAuthorizationRequestCreatedSuccess(event: AuthorizationEvent<AuthorizationRequest>): Promise<void> {
try {
this.updateState('request', event, AuthorizationRequestStateStatus.CREATED)
this.cleanup().catch((error) => console.log(JSON.stringify(error)))
} catch (error) {
console.log(JSON.stringify(error))
}
}
private async onAuthorizationRequestCreatedFailed(event: AuthorizationEvent<AuthorizationRequest>): Promise<void> {
this.cleanup().catch((error) => console.log(JSON.stringify(error)))
this.updateState('request', event, AuthorizationRequestStateStatus.ERROR)
}
private async onAuthorizationRequestSentSuccess(event: AuthorizationEvent<AuthorizationRequest>): Promise<void> {
this.cleanup().catch((error) => console.log(JSON.stringify(error)))
this.updateState('request', event, AuthorizationRequestStateStatus.SENT)
}
private async onAuthorizationRequestSentFailed(event: AuthorizationEvent<AuthorizationRequest>): Promise<void> {
this.cleanup().catch((error) => console.log(JSON.stringify(error)))
this.updateState('request', event, AuthorizationRequestStateStatus.ERROR)
}
private async onAuthorizationResponseReceivedSuccess(event: AuthorizationEvent<AuthorizationResponse>): Promise<void> {
this.cleanup().catch((error) => console.log(JSON.stringify(error)))
this.updateState('response', event, AuthorizationResponseStateStatus.RECEIVED)
}
private async onAuthorizationResponseReceivedFailed(event: AuthorizationEvent<AuthorizationResponse>): Promise<void> {
this.cleanup().catch((error) => console.log(JSON.stringify(error)))
this.updateState('response', event, AuthorizationResponseStateStatus.ERROR)
}
private async onAuthorizationResponseVerifiedFailed(event: AuthorizationEvent<AuthorizationResponse>): Promise<void> {
this.updateState('response', event, AuthorizationResponseStateStatus.ERROR)
}
private async onAuthorizationResponseVerifiedSuccess(event: AuthorizationEvent<AuthorizationResponse>): Promise<void> {
this.updateState('response', event, AuthorizationResponseStateStatus.VERIFIED)
}
public async getCorrelationIdByNonce(nonce: string, errorOnNotFound?: boolean): Promise<string | undefined> {
return await this.getCorrelationIdImpl('nonce', nonce, errorOnNotFound)
}
public async getCorrelationIdByState(state: string, errorOnNotFound?: boolean): Promise<string | undefined> {
return await this.getCorrelationIdImpl('state', state, errorOnNotFound)
}
private async getCorrelationIdImpl(
type: 'nonce' | 'state' | 'correlationId',
value: string,
errorOnNotFound?: boolean,
): Promise<string | undefined> {
if (!value || !type) {
throw Error('No type or value provided')
}
if (type === 'correlationId') {
return value
}
const hash = 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 updateMapping(
mapping: Record<number, string>,
event: AuthorizationEvent<AuthorizationRequest | AuthorizationResponse>,
key: string,
value: string | undefined,
allowExisting: boolean,
): void {
const hash = 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`)
} else if (value && existing !== value) {
throw Error(`Value changed for key ${key} from ${existing} to ${value}`)
}
}
if (!value) {
delete mapping[hash]
} else {
mapping[hash] = value
}
}
private updateState(
type: 'request' | 'response',
event: AuthorizationEvent<AuthorizationRequest | AuthorizationResponse>,
status: AuthorizationRequestStateStatus | AuthorizationResponseStateStatus,
): void {
if (!event) {
throw new Error('event not present')
} 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
this.updateMapping(this.nonceMapping, event, 'nonce', event.correlationId, true)
this.updateMapping(this.stateMapping, event, 'state', event.correlationId, true)
} 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<number, string>, correlationId: string): Promise<void> {
const keys = InMemoryRPSessionManager.getKeysForCorrelationId(mapping, correlationId)
if (keys && keys.length > 0) {
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)
}
}
}
Object.entries(this.authorizationRequests).forEach((reqByCorrelationId) => {
cleanupCorrelations.call(this, reqByCorrelationId)
})
Object.entries(this.authorizationResponses).forEach((resByCorrelationId) => {
cleanupCorrelations.call(this, resByCorrelationId)
})
}
}
function hashcodeForValue(event: AuthorizationEvent<AuthorizationRequest | AuthorizationResponse>, key: string): number {
const value = event.subject.getMergedProperty<string>(key)
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
}