/**
 * This service uses the Redis client to store and retrieve session information.
 */ import { isArray } from "util" ; import { array } from "fp-ts/lib/Array" ; import { Either , isLeft , isRight , left , parseJSON , right , toError } from "fp-ts/lib/Either" ; import { none , Option , some } from "fp-ts/lib/Option" ; import { collect , StrMap } from "fp-ts/lib/StrMap" ; import { fromEither , fromLeft , TaskEither , taskEither , taskify , tryCatch } from "fp-ts/lib/TaskEither" ; import { errorsToReadableMessages } from "italia-ts-commons/lib/reporters" ; import { EmailString , FiscalCode } from "italia-ts-commons/lib/strings" ; import * as redis from "redis" ; import { SessionInfo } from "../../generated/backend/SessionInfo" ; import { SessionsList } from "../../generated/backend/SessionsList" ; import { assertUnreachable } from "../types/commons" ; import { BPDToken , MyPortalToken , SessionToken , WalletToken , ZendeskToken } from "../types/token" ; import { User , UserV1 , UserV2 , UserV3 , UserV4 } from "../types/user" ; import { multipleErrorsFormatter } from "../utils/errorsFormatter" ; import { log } from "../utils/logger" ; import { ISessionStorage } from "./ISessionStorage" ; import RedisStorageUtils from "./redisStorageUtils" ; const sessionKeyPrefix = "SESSION-" ; const walletKeyPrefix = "WALLET-" ; const myPortalTokenPrefix = "MYPORTAL-" ; const bpdTokenPrefix = "BPD-" ; const zendeskTokenPrefix = "ZENDESK-" ; const userSessionsSetKeyPrefix = "USERSESSIONS-" ; const sessionInfoKeyPrefix = "SESSIONINFO-" ; const noticeEmailPrefix = "NOTICEEMAIL-" ; const blockedUserSetKey = "BLOCKEDUSERS" ; export const sessionNotFoundError = new Error ( "Session not found" ) ; export default class RedisSessionStorage extends RedisStorageUtils implements ISessionStorage { private readonly mgetTask : ( ... args : ReadonlyArray < string > ) => TaskEither < Error , ReadonlyArray < string > > ; private readonly sismemberTask : ( ... args : ReadonlyArray < string > ) => TaskEither < Error , number > ; private readonly ttlTask : ( key : string ) => TaskEither < Error , number > ; constructor ( private readonly redisClient , private readonly tokenDurationSecs ) { super ( ) ; this . mgetTask = taskify ( this . redisClient . mget . bind ( this . redisClient ) ) ; this . sismemberTask = taskify ( this . redisClient . sismember . bind ( this . redisClient ) ) ; this . ttlTask = taskify ( this . redisClient . ttl . bind ( this . redisClient ) ) ; } /**
     * {@inheritDoc}
     */ public async set ( user , expireSec = this . tokenDurationSecs ) { const setSessionToken = new Promise < Either < Error , boolean > > ( resolve => { this . redisClient . set ( `${ sessionKeyPrefix }${ user . session_token }` , JSON . stringify ( user ) , "EX" , expireSec , ( err , response ) => resolve ( this . falsyResponseToError ( this . singleStringReply ( err , response ) , new Error ( "Error setting session token" ) ) ) ) ; } ) ; const setWalletToken = new Promise < Either < Error , boolean > > ( resolve => { this . redisClient . set ( `${ walletKeyPrefix }${ user . wallet_token }` , user . session_token , "EX" , expireSec , ( err , response ) => resolve ( this . falsyResponseToError ( this . singleStringReply ( err , response ) , new Error ( "Error setting wallet token" ) ) ) ) ; } ) ; const setMyPortalToken = new Promise < Either < Error , boolean > > ( resolve => { this . redisClient . set ( `${ myPortalTokenPrefix }${ user . myportal_token }` , user . session_token , "EX" , expireSec , ( err , response ) => resolve ( this . falsyResponseToError ( this . singleStringReply ( err , response ) , new Error ( "Error setting MyPortal token" ) ) ) ) ; } ) ; const setBPDToken = new Promise < Either < Error , boolean > > ( resolve => { this . redisClient . set ( `${ bpdTokenPrefix }${ user . bpd_token }` , user . session_token , "EX" , expireSec , ( err , response ) => resolve ( this . falsyResponseToError ( this . singleStringReply ( err , response ) , new Error ( "Error setting BPD token" ) ) ) ) ; } ) ; const setZendeskToken = new Promise < Either < Error , boolean > > ( resolve => { this . redisClient . set ( `${ zendeskTokenPrefix }${ user . zendesk_token }` , user . session_token , "EX" , expireSec , ( err , response ) => resolve ( this . falsyResponseToError ( this . singleStringReply ( err , response ) , new Error ( "Error setting Zendesk token" ) ) ) ) ; } ) ; let saveSessionInfoPromise = Promise . resolve ( right ( true ) ) ; if ( expireSec === this . tokenDurationSecs ) { const sessionInfo = { createdAt : new Date ( ) , sessionToken : user . session_token } ; saveSessionInfoPromise = this . saveSessionInfo ( sessionInfo , user . fiscal_code ) ; } const removeOtherUserSessionsPromise = this . removeOtherUserSessions ( user ) ; const setPromisesResult = await Promise . all ( [ setSessionToken , setWalletToken , setMyPortalToken , setBPDToken , setZendeskToken , saveSessionInfoPromise , removeOtherUserSessionsPromise ] ) ; const isSetFailed = setPromisesResult . some ( isLeft ) ; if ( isSetFailed ) { return left < Error , boolean > ( multipleErrorsFormatter ( setPromisesResult . filter ( isLeft ) . map ( _ => _ . value ) , "RedisSessionStorage.set" ) ) ; } return right < Error , boolean > ( true ) ; } /**
     * {@inheritDoc}
     */ public async getBySessionToken ( token ) { const errorOrSession = await this . loadSessionBySessionToken ( token ) ; if ( isLeft ( errorOrSession ) ) { if ( errorOrSession . value === sessionNotFoundError ) { return right ( none ) ; } return left ( errorOrSession . value ) ; } const user = errorOrSession . value ; return right ( some ( user ) ) ; } /**
     * {@inheritDoc}
     */ public async getByWalletToken ( token ) { const errorOrSession = await this . loadSessionByToken ( walletKeyPrefix , token ) ; if ( isLeft ( errorOrSession ) ) { if ( errorOrSession . value === sessionNotFoundError ) { return right ( none ) ; } return left ( errorOrSession . value ) ; } const user = errorOrSession . value ; return right ( some ( user ) ) ; } /**
     * {@inheritDoc}
     */ public async getByMyPortalToken ( token ) { const errorOrSession = await this . loadSessionByToken ( myPortalTokenPrefix , token ) ; if ( isLeft ( errorOrSession ) ) { if ( errorOrSession . value === sessionNotFoundError ) { return right ( none ) ; } return left ( errorOrSession . value ) ; } const user = errorOrSession . value ; return right ( some ( user ) ) ; } /**
     * {@inheritDoc}
     */ public async getByBPDToken ( token ) { const errorOrSession = await this . loadSessionByToken ( bpdTokenPrefix , token ) ; if ( isLeft ( errorOrSession ) ) { if ( errorOrSession . value === sessionNotFoundError ) { return right ( none ) ; } return left ( errorOrSession . value ) ; } const user = errorOrSession . value ; return right ( some ( user ) ) ; } /**
     * {@inheritDoc}
     */ public async getByZendeskToken ( token ) { const errorOrSession = await this . loadSessionByToken ( zendeskTokenPrefix , token ) ; if ( isLeft ( errorOrSession ) ) { if ( errorOrSession . value === sessionNotFoundError ) { return right ( none ) ; } return left ( errorOrSession . value ) ; } const user = errorOrSession . value ; return right ( some ( user ) ) ; } /**
     * {@inheritDoc}
     */ public async del ( user ) { const tokens = collect ( this . getUserTokens ( user ) , ( _ , { prefix , value } ) => `${ prefix }${ value }` ) ; const deleteTokensPromise = await new Promise < Either < Error , true > > ( resolve => { this . redisClient . del ( ... tokens , ( err , response ) => resolve ( this . falsyResponseToError ( this . integerReply ( err , response , tokens . length ) , new Error ( "Unexpected response from redis client deleting user tokens." ) ) ) ) ; } ) ; if ( isLeft ( deleteTokensPromise ) ) { return left < Error , boolean > ( new Error ( `value [${ deleteTokensPromise . value . message }] at RedisSessionStorage.del` ) ) ; } this . redisClient . srem ( `${ userSessionsSetKeyPrefix }${ user . fiscal_code }` , `${ sessionInfoKeyPrefix }${ user . session_token }` , ( err , _ ) => { if ( err ) { log . warn ( `Error updating USERSESSIONS Set for ${ user . fiscal_code }` ) ; } } ) ; return right < Error , boolean > ( true ) ; } public async listUserSessions ( user ) { await this . clearExpiredSetValues ( user . fiscal_code ) ; const sessionKeys = await this . readSessionInfoKeys ( user . fiscal_code ) ; const initializedSessionKeys = [ ] ; if ( isLeft ( sessionKeys ) && sessionKeys . value !== sessionNotFoundError ) { return left ( sessionKeys . value ) ; } else if ( isLeft ( sessionKeys ) ) { const sessionInfo = { createdAt : new Date ( ) , sessionToken : user . session_token } ; const refreshUserSessionInfo = await this . saveSessionInfo ( sessionInfo , user . fiscal_code ) ; if ( isLeft ( refreshUserSessionInfo ) ) { return left ( sessionNotFoundError ) ; } initializedSessionKeys . push ( `${ sessionInfoKeyPrefix }${ user . session_token }` ) ; } return this . mgetTask ( ... sessionKeys . getOrElse ( initializedSessionKeys ) ) . map ( _ => this . parseUserSessionList ( _ ) ) . run ( ) ; } param fiscalCode public async clearExpiredSetValues ( fiscalCode ) { const userSessionSetKey = `${ userSessionsSetKeyPrefix }${ fiscalCode }` ; const keys = await new Promise < ReadonlyArray < string > > ( resolve => { this . redisClient . smembers ( userSessionSetKey , ( err , response ) => { if ( err ) { log . error ( "Error reading set members: %s" , err ) ; return resolve ( [ ] ) ; } resolve ( response ) ; } ) ; } ) ; const activeKeys = await Promise . all ( keys . map ( _ => new Promise < Either < string , string > > ( resolve => { this . redisClient . exists ( _ , ( err , response ) => { if ( err || ! response ) { return resolve ( left ( _ ) ) ; } return resolve ( right ( _ ) ) ; } ) ; } ) ) ) ; return await Promise . all ( activeKeys . filter ( isLeft ) . map ( _ => new Promise < Either < Error , boolean > > ( resolve => { this . redisClient . srem ( userSessionSetKey , _ . value , ( err , response ) => resolve ( this . integerReply ( err , response ) ) ) ; } ) ) ) ; } public async userHasActiveSessions ( fiscalCode ) { const sessionKeys = await this . readSessionInfoKeys ( fiscalCode ) ; if ( sessionKeys . value === sessionNotFoundError ) { return right ( false ) ; } else if ( isLeft ( sessionKeys ) ) { return left ( sessionKeys . value ) ; } const errorOrSessionTokens = await this . mgetTask ( ... sessionKeys . value ) . map ( _ => this . parseUserSessionList ( _ ) . sessions . map ( __ => __ . sessionToken ) ) . run ( ) ; if ( errorOrSessionTokens . isLeft ( ) ) { return left ( errorOrSessionTokens . value ) ; } else if ( errorOrSessionTokens . value . length === 0 ) { return right ( false ) ; } return this . mgetTask ( ... errorOrSessionTokens . value ) . map ( _ => _ . length > 0 ) . run ( ) ; } param fiscalCode returns public setBlockedUser ( fiscalCode ) { return new Promise < Either < Error , true > > ( resolve => { log . info ( `Adding ${ fiscalCode } to ${ blockedUserSetKey } set` ) ; this . redisClient . sadd ( blockedUserSetKey , fiscalCode , err => resolve ( err ? left ( err ) : right ( true ) ) ) ; } ) ; } param fiscalCode returns public unsetBlockedUser ( fiscalCode ) { return new Promise < Either < Error , boolean > > ( resolve => { log . info ( `Removing ${ fiscalCode } from ${ blockedUserSetKey } set` ) ; this . redisClient . srem ( blockedUserSetKey , fiscalCode , ( err , response ) => resolve ( this . falsyResponseToError ( this . integerReply ( err , response , 1 ) , new Error ( "Unexpected response from redis client deleting blockedUserKey" ) ) ) ) ; } ) ; } param fiscalCode returns public async isBlockedUser ( fiscalCode ) { return this . sismemberTask ( blockedUserSetKey , fiscalCode ) . bimap ( err => new Error ( `Error accessing blocked users collection: ${ err }` ) , result => result === 1 ) . run ( ) ; } param fiscalCode public async delUserAllSessions ( fiscalCode ) { const errorOrSessions = await this . readSessionInfoKeys ( fiscalCode ) ; const delEverySession = ( sessionTokens ) : TaskEither < Error , boolean > => array . sequence ( taskEither ) < Error , boolean > ( sessionTokens . map ( sessionToken => fromEither < Error , SessionToken > ( SessionToken . decode ( sessionToken ) . mapLeft ( _ => new Error ( "Error decoding token" ) ) ) . chain < boolean > ( ( token ) => tryCatch ( ( ) => this . delSingleSession ( token ) , toError ) . chain ( fromEither ) ) ) ) . map ( ( ) => true ) ; return fromEither ( errorOrSessions ) . foldTaskEither < Error , boolean > ( _ => ( _ === sessionNotFoundError ? taskEither . of ( true ) : fromLeft ( _ ) ) , sessionInfoKeys => delEverySession ( sessionInfoKeys . map ( sessionInfoKey => sessionInfoKey . replace ( sessionInfoKeyPrefix , "" ) as SessionToken ) ) ) . chain ( _ => tryCatch ( ( ) => this . delSessionsSet ( fiscalCode ) , toError ) . chain ( fromEither ) ) . run ( ) ; } param updatedUser public async update ( updatedUser ) { const errorOrSessionTtl = await this . getSessionTtl ( updatedUser . session_token ) ; if ( isLeft ( errorOrSessionTtl ) ) { return left ( new Error ( `Error retrieving user session ttl [${ errorOrSessionTtl . value . message }]` ) ) ; } const sessionTtl = errorOrSessionTtl . value ; if ( sessionTtl < 0 ) { throw new Error ( `Unexpected session TTL value [${ sessionTtl }]` ) ; } const errorOrIsSessionUpdated = await this . set ( updatedUser , sessionTtl ) ; if ( isLeft ( errorOrIsSessionUpdated ) ) { return left ( new Error ( `Error updating user session [${ errorOrIsSessionUpdated . value . message }]` ) ) ; } return right ( true ) ; } /**
     * Cache on redis the notify email for pagopa
     */ public async setPagoPaNoticeEmail ( user , NoticeEmail ) { const errorOrSessionTtl = await this . getSessionTtl ( user . session_token ) ; if ( isLeft ( errorOrSessionTtl ) ) { return left ( new Error ( `Error retrieving user session ttl [${ errorOrSessionTtl . value . message }]` ) ) ; } const sessionTtl = errorOrSessionTtl . value ; if ( sessionTtl < 0 ) { throw new Error ( `Unexpected session TTL value [${ sessionTtl }]` ) ; } return new Promise < Either < Error , boolean > > ( resolve => { this . redisClient . set ( `${ noticeEmailPrefix }${ user . session_token }` , NoticeEmail , "EX" , sessionTtl , ( err , response ) => resolve ( this . falsyResponseToError ( this . singleStringReply ( err , response ) , new Error ( "Error setting session token" ) ) ) ) ; } ) ; } /**
     * Delete notify email cache related to an user
     */ public async delPagoPaNoticeEmail ( user ) { return new Promise < Either < Error , true > > ( resolve => { log . info ( `Deleting cashed notify email ${ noticeEmailPrefix }${ user . fiscal_code }` ) ; this . redisClient . del ( `${ noticeEmailPrefix }${ user . session_token }` , err => resolve ( err ? left ( err ) : right ( true ) ) ) ; } ) ; } /**
     * Get the notify email value from cache
     */ public async getPagoPaNoticeEmail ( user ) { return new Promise < Either < Error , EmailString > > ( resolve => { this . redisClient . get ( `${ noticeEmailPrefix }${ user . session_token }` , ( err , value ) => { if ( err ) { return resolve ( left < Error , EmailString > ( err ) ) ; } if ( value === null ) { return resolve ( left < Error , EmailString > ( new Error ( "Notify email value not found" ) ) ) ; } const errorOrNoticeEmail = EmailString . decode ( value ) . mapLeft ( validationErrors => new Error ( errorsToReadableMessages ( validationErrors ) . join ( "/" ) ) ) ; return resolve ( errorOrNoticeEmail ) ; } ) ; } ) ; } param token private async getSessionTtl ( token ) { return this . ttlTask ( `${ sessionKeyPrefix }${ token }` ) . run ( ) ; } param token private async delSingleSession ( token ) { try { const errorOrUser = await this . loadSessionBySessionToken ( token ) ; const user = errorOrUser . getOrElseL ( err => { throw err ; } ) ; return this . del ( user ) ; } catch ( error ) { return error === sessionNotFoundError ? right < Error , boolean > ( true ) : left ( error ) ; } } private delSessionsSet ( fiscalCode ) { return new Promise < Either < Error , true > > ( resolve => { log . info ( `Deleting sessions set ${ userSessionsSetKeyPrefix }${ fiscalCode }` ) ; this . redisClient . del ( `${ userSessionsSetKeyPrefix }${ fiscalCode }` , err => resolve ( err ? left ( err ) : right ( true ) ) ) ; } ) ; } private saveSessionInfo ( sessionInfo , fiscalCode ) { const sessionInfoKey = `${ sessionInfoKeyPrefix }${ sessionInfo . sessionToken }` ; return new Promise < Either < Error , boolean > > ( resolve => { this . redisClient . set ( sessionInfoKey , JSON . stringify ( sessionInfo ) , "EX" , this . tokenDurationSecs , ( err , response ) => { const saveSessionInfoResult = this . falsyResponseToError ( this . singleStringReply ( err , response ) , new Error ( "Error setting user token info" ) ) ; if ( isLeft ( saveSessionInfoResult ) ) { return resolve ( saveSessionInfoResult ) ; } this . redisClient . sadd ( `${ userSessionsSetKeyPrefix }${ fiscalCode }` , sessionInfoKey , ( infoSetUpdateErr , infoSetUpdateRes ) => resolve ( this . falsyResponseToError ( this . integerReply ( infoSetUpdateErr , infoSetUpdateRes ) , new Error ( "Error updating user tokens info set" ) ) ) ) ; } ) ; } ) ; } /**
     * Return a Session for this token.
     */ private async loadSessionBySessionToken ( token ) { return new Promise ( resolve => { this . redisClient . get ( `${ sessionKeyPrefix }${ token }` , ( err , value ) => { if ( err ) { return resolve ( left < Error , User > ( err ) ) ; } if ( value === null ) { return resolve ( left < Error , User > ( sessionNotFoundError ) ) ; } const errorOrDeserializedUser = this . parseUser ( value ) ; return resolve ( errorOrDeserializedUser ) ; } ) ; } ) ; } /**
     * Return a Session for this token.
     */ private loadSessionByToken ( prefix , token ) { return new Promise ( resolve => { this . redisClient . get ( `${ prefix }${ token }` , ( err , value ) => { if ( err ) { return resolve ( left < Error , User > ( err ) ) ; } if ( value === null ) { return resolve ( left < Error , User > ( sessionNotFoundError ) ) ; } this . loadSessionBySessionToken ( value as SessionToken ) . then ( ( errorOrSession ) => { errorOrSession . fold ( error => resolve ( left < Error , User > ( error ) ) , session => { resolve ( right < Error , User > ( session ) ) ; } ) ; } , error => { resolve ( left < Error , User > ( error ) ) ; } ) ; } ) ; } ) ; } /**
     * Remove other user sessions and wallet tokens
     */ private async removeOtherUserSessions ( user ) { const errorOrSessionInfoKeys = await this . readSessionInfoKeys ( user . fiscal_code ) ; if ( isRight ( errorOrSessionInfoKeys ) ) { const oldSessionInfoKeys = errorOrSessionInfoKeys . value . filter ( _ => _ . startsWith ( sessionInfoKeyPrefix ) && _ !== `${ sessionInfoKeyPrefix }${ user . session_token }` ) ; const oldSessionKeys = oldSessionInfoKeys . map ( _ => `${ sessionKeyPrefix }${ _ . split ( sessionInfoKeyPrefix ) [ 1 ] }` ) ; const errorOrSerializedUser = await new Promise < Either < Error , ReadonlyArray < string > > > ( resolve => { this . redisClient . mget ( ... oldSessionKeys , ( err , response ) => resolve ( this . arrayStringReply ( err , response ) ) ) ; } ) ; const errorOrDeserializedUsers = errorOrSerializedUser . map ( _ => _ . map ( this . parseUser ) . filter ( isRight ) . map ( deserializedUser => deserializedUser . value ) ) ; const externalTokens = errorOrDeserializedUsers . fold ( _ => [ ] , _ => _ . map ( deserializedUser => collect ( this . getUserTokens ( deserializedUser ) . filter ( p => ! ( p . prefix === sessionInfoKeyPrefix || p . prefix === sessionKeyPrefix ) && ! ( p . prefix === walletKeyPrefix && p . value === user . wallet_token ) && ! ( p . prefix === myPortalTokenPrefix && p . value === user . myportal_token ) && ! ( p . prefix === bpdTokenPrefix && p . value === user . bpd_token ) && ! ( p . prefix === zendeskTokenPrefix && p . value === user . zendesk_token ) ) , ( _1 , { prefix , value } ) => `${ prefix }${ value }` ) ) . reduce ( ( prev , tokens ) => [ ... prev , ... tokens ] , [ ] ) ) ; const deleteOldKeysResponse = await new Promise < Either < Error , boolean > > ( resolve => { const keys = [ ... oldSessionInfoKeys , ... oldSessionKeys , ... externalTokens ] ; if ( keys . length === 0 ) { return resolve ( right ( true ) ) ; } this . redisClient . del ( ... keys , ( err , response ) => resolve ( this . integerReply ( err , response ) ) ) ; } ) ; await this . clearExpiredSetValues ( user . fiscal_code ) ; return deleteOldKeysResponse ; } return errorOrSessionInfoKeys . value === sessionNotFoundError ? right ( true ) : left ( errorOrSessionInfoKeys . value ) ; } private readSessionInfoKeys ( fiscalCode ) { return new Promise < Either < Error , ReadonlyArray < string > > > ( resolve => { this . redisClient . smembers ( `${ userSessionsSetKeyPrefix }${ fiscalCode }` , ( err , response ) => resolve ( this . arrayStringReply ( err , response ) ) ) ; } ) ; } private arrayStringReply ( err , replay ) { if ( err ) { return left ( err ) ; } else if ( ! isArray ( replay ) || replay . length === 0 ) { return left ( sessionNotFoundError ) ; } return right ( replay ) ; } private parseUser ( value ) { return parseJSON < Error > ( value , toError ) . chain ( data => User . decode ( data ) . mapLeft ( err => new Error ( errorsToReadableMessages ( err ) . join ( "/" ) ) ) ) ; } private parseUserSessionList ( userSessionTokensResult ) { return userSessionTokensResult . reduce ( ( prev , _ ) => parseJSON < Error > ( _ , toError ) . chain ( data => SessionInfo . decode ( data ) . mapLeft ( err => new Error ( errorsToReadableMessages ( err ) . join ( "/" ) ) ) ) . fold ( err => { log . warn ( "Unable to decode the session info: %s. Skipped." , err ) ; return prev ; } , sessionInfo => ( { sessions : [ ... prev . sessions , sessionInfo ] } ) ) , { sessions : [ ] } as SessionsList ) ; } private getUserTokens ( user ) { const requiredTokens = { session_info : { prefix : sessionInfoKeyPrefix , value : user . session_token } , session_token : { prefix : sessionKeyPrefix , value : user . session_token } , wallet_token : { prefix : walletKeyPrefix , value : user . wallet_token } } ; if ( UserV4 . is ( user ) ) { return new StrMap ( { ... requiredTokens , bpd_token : { prefix : bpdTokenPrefix , value : user . bpd_token } , myportal_token : { prefix : myPortalTokenPrefix , value : user . myportal_token } , zendesk_token : { prefix : zendeskTokenPrefix , value : user . zendesk_token } } ) ; } if ( UserV3 . is ( user ) ) { return new StrMap ( { ... requiredTokens , bpd_token : { prefix : bpdTokenPrefix , value : user . bpd_token } , myportal_token : { prefix : myPortalTokenPrefix , value : user . myportal_token } } ) ; } if ( UserV2 . is ( user ) ) { return new StrMap ( { ... requiredTokens , myportal_token : { prefix : myPortalTokenPrefix , value : user . myportal_token } } ) ; } if ( UserV1 . is ( user ) ) { return new StrMap ( { ... requiredTokens } ) ; } return assertUnreachable ( user ) ; } }