import { AlertService, AbstractService, InternalEventBusInterface, StorageKey, DiagnosticInfo, ChallengePrompt, ChallengeValidation, ChallengeKeyboardType, ChallengeReason, ChallengePromptTitle, EncryptionService, SessionsClientInterface, SessionManagerResponse, SessionStrings, SignInStrings, INVALID_PASSWORD_COST, API_MESSAGE_FALLBACK_LOGIN_FAIL, API_MESSAGE_GENERIC_SYNC_FAIL, EXPIRED_PROTOCOL_VERSION, StrictSignInFailed, UNSUPPORTED_KEY_DERIVATION, UNSUPPORTED_PROTOCOL_VERSION, Challenge, } from '@standardnotes/services' import { Base64String } from '@standardnotes/sncrypto-common' import { ClientDisplayableError, SessionBody } from '@standardnotes/responses' import { CopyPayloadWithContentOverride } from '@standardnotes/models' import { isNullOrUndefined } from '@standardnotes/utils' import { LegacySession, MapperInterface, Session, SessionToken } from '@standardnotes/domain-core' import { KeyParamsFromApiResponse, SNRootKeyParams, SNRootKey, CreateNewRootKey } from '@standardnotes/encryption' import * as Responses from '@standardnotes/responses' import { Subscription } from '@standardnotes/security' import * as Common from '@standardnotes/common' import { RemoteSession, RawStorageValue } from './Sessions/Types' import { ShareToken } from './ShareToken' import { SNApiService } from '../Api/ApiService' import { DiskStorageService } from '../Storage/DiskStorageService' import { SNWebSocketsService } from '../Api/WebsocketsService' import { Strings } from '@Lib/Strings' import { UuidString } from '@Lib/Types/UuidString' import { ChallengeService } from '../Challenge' import { ApiCallError, ErrorMessage, HttpErrorResponseBody, HttpServiceInterface, UserApiServiceInterface, UserRegistrationResponseBody, } from '@standardnotes/api' export const MINIMUM_PASSWORD_LENGTH = 8 export const MissingAccountParams = 'missing-params' const cleanedEmailString = (email: string) => { return email.trim().toLowerCase() } export enum SessionEvent { Restored = 'SessionRestored', Revoked = 'SessionRevoked', } /** * The session manager is responsible for loading initial user state, and any relevant * server credentials, such as the session token. It also exposes methods for registering * for a new account, signing into an existing one, or changing an account password. */ export class SNSessionManager extends AbstractService implements SessionsClientInterface { private user?: Responses.User private isSessionRenewChallengePresented = false constructor( private diskStorageService: DiskStorageService, private apiService: SNApiService, private userApiService: UserApiServiceInterface, private alertService: AlertService, private protocolService: EncryptionService, private challengeService: ChallengeService, private webSocketsService: SNWebSocketsService, private httpService: HttpServiceInterface, private sessionStorageMapper: MapperInterface>, private legacySessionStorageMapper: MapperInterface>, protected override internalEventBus: InternalEventBusInterface, ) { super(internalEventBus) apiService.setInvalidSessionObserver((revoked) => { if (revoked) { void this.notifyEvent(SessionEvent.Revoked) } else { void this.reauthenticateInvalidSession() } }) } override deinit(): void { ;(this.protocolService as unknown) = undefined ;(this.diskStorageService as unknown) = undefined ;(this.apiService as unknown) = undefined ;(this.alertService as unknown) = undefined ;(this.challengeService as unknown) = undefined ;(this.webSocketsService as unknown) = undefined this.user = undefined super.deinit() } private setUser(user?: Responses.User) { this.user = user this.apiService.setUser(user) } async initializeFromDisk() { this.setUser(this.diskStorageService.getValue(StorageKey.User)) if (!this.user) { const legacyUuidLookup = this.diskStorageService.getValue(StorageKey.LegacyUuid) if (legacyUuidLookup) { this.setUser({ uuid: legacyUuidLookup, email: legacyUuidLookup }) } } const rawSession = this.diskStorageService.getValue(StorageKey.Session) if (rawSession) { try { const session = 'jwt' in rawSession ? this.legacySessionStorageMapper.toDomain(rawSession) : this.sessionStorageMapper.toDomain(rawSession) this.setSession(session, false) } catch (error) { console.error(`Could not deserialize session from storage: ${(error as Error).message}`) } } } private setSession(session: Session | LegacySession, persist = true): void { if (session instanceof Session) { this.httpService.setSession(session) } this.apiService.setSession(session, persist) void this.webSocketsService.startWebSocketConnection() } public online() { return !this.offline() } public offline() { return isNullOrUndefined(this.apiService.getSession()) } public getUser(): Responses.User | undefined { return this.user } public getSureUser() { return this.user as Responses.User } public getSession() { return this.apiService.getSession() } public async signOut() { this.setUser(undefined) const session = this.apiService.getSession() if (session && session instanceof Session) { await this.apiService.signOut() this.webSocketsService.closeWebSocketConnection() } } public isSignedIn(): boolean { return this.getUser() != undefined } public isSignedIntoFirstPartyServer(): boolean { return this.isSignedIn() && !this.apiService.isThirdPartyHostUsed() } public async reauthenticateInvalidSession( cancelable = true, onResponse?: (response: Responses.HttpResponse) => void, ): Promise { if (this.isSessionRenewChallengePresented) { return } this.isSessionRenewChallengePresented = true const challenge = new Challenge( [ new ChallengePrompt(ChallengeValidation.None, undefined, SessionStrings.EmailInputPlaceholder, false), new ChallengePrompt(ChallengeValidation.None, undefined, SessionStrings.PasswordInputPlaceholder), ], ChallengeReason.Custom, cancelable, SessionStrings.EnterEmailAndPassword, SessionStrings.RecoverSession(this.getUser()?.email), ) return new Promise((resolve) => { this.challengeService.addChallengeObserver(challenge, { onCancel: () => { this.isSessionRenewChallengePresented = false }, onComplete: () => { this.isSessionRenewChallengePresented = false }, onNonvalidatedSubmit: async (challengeResponse) => { const email = challengeResponse.values[0].value as string const password = challengeResponse.values[1].value as string const currentKeyParams = this.protocolService.getAccountKeyParams() const signInResult = await this.signIn( email, password, false, this.diskStorageService.isEphemeralSession(), currentKeyParams?.version, ) if (signInResult.response.error) { this.challengeService.setValidationStatusForChallenge(challenge, challengeResponse!.values[1], false) onResponse?.(signInResult.response) } else { resolve() this.challengeService.completeChallenge(challenge) void this.notifyEvent(SessionEvent.Restored) void this.alertService.alert(SessionStrings.SessionRestored) } }, }) void this.challengeService.promptForChallengeResponse(challenge) }) } public async getSubscription(): Promise { const result = await this.apiService.getSubscription(this.getSureUser().uuid) if (result.error) { return ClientDisplayableError.FromError(result.error) } const subscription = (result as Responses.GetSubscriptionResponse).data!.subscription! return subscription } public async getAvailableSubscriptions(): Promise { const response = await this.apiService.getAvailableSubscriptions() if (response.error) { return ClientDisplayableError.FromError(response.error) } return (response as Responses.GetAvailableSubscriptionsResponse).data! } private async promptForMfaValue(): Promise { const challenge = new Challenge( [ new ChallengePrompt( ChallengeValidation.None, ChallengePromptTitle.Mfa, SessionStrings.MfaInputPlaceholder, false, ChallengeKeyboardType.Numeric, ), ], ChallengeReason.Custom, true, SessionStrings.EnterMfa, ) const response = await this.challengeService.promptForChallengeResponse(challenge) if (response) { this.challengeService.completeChallenge(challenge) return response.values[0].value as string } return undefined } async register(email: string, password: string, ephemeral: boolean): Promise { if (password.length < MINIMUM_PASSWORD_LENGTH) { throw new ApiCallError( ErrorMessage.InsufficientPasswordMessage.replace('%LENGTH%', MINIMUM_PASSWORD_LENGTH.toString()), ) } const { wrappingKey, canceled } = await this.challengeService.getWrappingKeyIfApplicable() if (canceled) { throw new ApiCallError(ErrorMessage.PasscodeRequired) } email = cleanedEmailString(email) const rootKey = await this.protocolService.createRootKey(email, password, Common.KeyParamsOrigination.Registration) const serverPassword = rootKey.serverPassword as string const keyParams = rootKey.keyParams const registerResponse = await this.userApiService.register({ email, serverPassword, keyParams, ephemeral }) if ('error' in registerResponse.data) { throw new ApiCallError((registerResponse.data as HttpErrorResponseBody).error.message) } await this.handleAuthentication({ rootKey, wrappingKey, session: registerResponse.data.session, user: registerResponse.data.user, }) return registerResponse.data } private async retrieveKeyParams( email: string, mfaKeyPath?: string, mfaCode?: string, ): Promise<{ keyParams?: SNRootKeyParams response: Responses.KeyParamsResponse | Responses.HttpResponse mfaKeyPath?: string mfaCode?: string }> { const response = await this.apiService.getAccountKeyParams({ email, mfaKeyPath, mfaCode, }) if (response.error || isNullOrUndefined(response.data)) { if (mfaCode) { await this.alertService.alert(SignInStrings.IncorrectMfa) } if (response.error?.payload?.mfa_key) { /** Prompt for MFA code and try again */ const inputtedCode = await this.promptForMfaValue() if (!inputtedCode) { /** User dismissed window without input */ return { response: this.apiService.createErrorResponse( SignInStrings.SignInCanceledMissingMfa, Responses.StatusCode.CanceledMfa, ), } } return this.retrieveKeyParams(email, response.error.payload.mfa_key, inputtedCode) } else { return { response } } } /** Make sure to use client value for identifier/email */ const keyParams = KeyParamsFromApiResponse(response as Responses.KeyParamsResponse, email) if (!keyParams || !keyParams.version) { return { response: this.apiService.createErrorResponse(API_MESSAGE_FALLBACK_LOGIN_FAIL), } } return { keyParams, response, mfaKeyPath, mfaCode } } public async signIn( email: string, password: string, strict = false, ephemeral = false, minAllowedVersion?: Common.ProtocolVersion, ): Promise { const result = await this.performSignIn(email, password, strict, ephemeral, minAllowedVersion) if ( result.response.error && result.response.error.status !== Responses.StatusCode.LocalValidationError && result.response.error.status !== Responses.StatusCode.CanceledMfa ) { const cleanedEmail = cleanedEmailString(email) if (cleanedEmail !== email) { /** * Try signing in with trimmed + lowercase version of email */ return this.performSignIn(cleanedEmail, password, strict, ephemeral, minAllowedVersion) } else { return result } } else { return result } } private async performSignIn( email: string, password: string, strict = false, ephemeral = false, minAllowedVersion?: Common.ProtocolVersion, ): Promise { const paramsResult = await this.retrieveKeyParams(email) if (paramsResult.response.error) { return { response: paramsResult.response, } } const keyParams = paramsResult.keyParams! if (!this.protocolService.supportedVersions().includes(keyParams.version)) { if (this.protocolService.isVersionNewerThanLibraryVersion(keyParams.version)) { return { response: this.apiService.createErrorResponse(UNSUPPORTED_PROTOCOL_VERSION), } } else { return { response: this.apiService.createErrorResponse(EXPIRED_PROTOCOL_VERSION), } } } if (Common.isProtocolVersionExpired(keyParams.version)) { /* Cost minimums only apply to now outdated versions (001 and 002) */ const minimum = this.protocolService.costMinimumForVersion(keyParams.version) if (keyParams.content002.pw_cost < minimum) { return { response: this.apiService.createErrorResponse(INVALID_PASSWORD_COST), } } const expiredMessages = Strings.Confirm.ProtocolVersionExpired(keyParams.version) const confirmed = await this.alertService.confirm( expiredMessages.Message, expiredMessages.Title, expiredMessages.ConfirmButton, ) if (!confirmed) { return { response: this.apiService.createErrorResponse(API_MESSAGE_FALLBACK_LOGIN_FAIL), } } } if (!this.protocolService.platformSupportsKeyDerivation(keyParams)) { return { response: this.apiService.createErrorResponse(UNSUPPORTED_KEY_DERIVATION), } } if (strict) { minAllowedVersion = this.protocolService.getLatestVersion() } if (!isNullOrUndefined(minAllowedVersion)) { if (!Common.leftVersionGreaterThanOrEqualToRight(keyParams.version, minAllowedVersion)) { return { response: this.apiService.createErrorResponse(StrictSignInFailed(keyParams.version, minAllowedVersion)), } } } const rootKey = await this.protocolService.computeRootKey(password, keyParams) const signInResponse = await this.bypassChecksAndSignInWithRootKey(email, rootKey, ephemeral) return { response: signInResponse, } } public async bypassChecksAndSignInWithRootKey( email: string, rootKey: SNRootKey, ephemeral = false, ): Promise { const { wrappingKey, canceled } = await this.challengeService.getWrappingKeyIfApplicable() if (canceled) { return this.apiService.createErrorResponse( SignInStrings.PasscodeRequired, Responses.StatusCode.LocalValidationError, ) } const signInResponse = await this.apiService.signIn({ email, serverPassword: rootKey.serverPassword!, ephemeral, }) if (signInResponse.error || !signInResponse.data) { return signInResponse } const updatedKeyParams = (signInResponse as Responses.SignInResponse).data.key_params const expandedRootKey = new SNRootKey( CopyPayloadWithContentOverride(rootKey.payload, { keyParams: updatedKeyParams || rootKey.keyParams.getPortableValue(), }), ) await this.handleSuccessAuthResponse(signInResponse as Responses.SignInResponse, expandedRootKey, wrappingKey) return signInResponse } public async changeCredentials(parameters: { currentServerPassword: string newRootKey: SNRootKey wrappingKey?: SNRootKey newEmail?: string }): Promise { const userUuid = this.user!.uuid const response = await this.apiService.changeCredentials({ userUuid, currentServerPassword: parameters.currentServerPassword, newServerPassword: parameters.newRootKey.serverPassword!, newKeyParams: parameters.newRootKey.keyParams, newEmail: parameters.newEmail, }) return this.processChangeCredentialsResponse( response as Responses.ChangeCredentialsResponse, parameters.newRootKey, parameters.wrappingKey, ) } public async getSessionsList(): Promise< (Responses.HttpResponse & { data: RemoteSession[] }) | Responses.HttpResponse > { const response = await this.apiService.getSessionsList() if (response.error || isNullOrUndefined(response.data)) { return response } ;( response as Responses.HttpResponse & { data: RemoteSession[] } ).data = (response as Responses.SessionListResponse).data .map((session) => ({ ...session, updated_at: new Date(session.updated_at), })) .sort((s1: RemoteSession, s2: RemoteSession) => (s1.updated_at < s2.updated_at ? 1 : -1)) return response } public async revokeSession(sessionId: UuidString): Promise { const response = await this.apiService.deleteSession(sessionId) return response } public async revokeAllOtherSessions(): Promise { const response = await this.getSessionsList() if (response.error != undefined || response.data == undefined) { throw new Error(response.error?.message ?? API_MESSAGE_GENERIC_SYNC_FAIL) } const sessions = response.data as RemoteSession[] const otherSessions = sessions.filter((session) => !session.current) await Promise.all(otherSessions.map((session) => this.revokeSession(session.uuid))) } private async processChangeCredentialsResponse( response: Responses.ChangeCredentialsResponse, newRootKey: SNRootKey, wrappingKey?: SNRootKey, ): Promise { if (!response.error && response.data) { await this.handleSuccessAuthResponse(response as Responses.ChangeCredentialsResponse, newRootKey, wrappingKey) } return { response: response, keyParams: (response as Responses.ChangeCredentialsResponse).data?.key_params, } } public async createDemoShareToken(): Promise { const session = this.getSession() if (!session) { return new ClientDisplayableError('Cannot generate share token without active session') } if (!(session instanceof Session)) { return new ClientDisplayableError('Cannot generate share token with non-token session') } const keyParams = (await this.protocolService.getRootKeyParams()) as SNRootKeyParams const payload: ShareToken = { accessToken: session.accessToken.value, refreshToken: session.refreshToken.value, accessExpiration: session.accessToken.expiresAt, refreshExpiration: session.refreshToken.expiresAt, readonlyAccess: true, masterKey: this.protocolService.getRootKey()?.masterKey as string, keyParams: keyParams.content, user: this.getSureUser(), host: this.apiService.getHost(), } return this.protocolService.crypto.base64Encode(JSON.stringify(payload)) } private decodeDemoShareToken(token: Base64String): ShareToken { const jsonString = this.protocolService.crypto.base64Decode(token) return JSON.parse(jsonString) } public async populateSessionFromDemoShareToken(token: Base64String): Promise { const sharePayload = this.decodeDemoShareToken(token) const rootKey = CreateNewRootKey({ masterKey: sharePayload.masterKey, keyParams: sharePayload.keyParams, version: sharePayload.keyParams.version, }) const user = sharePayload.user const session = this.createSession( sharePayload.accessToken, sharePayload.accessExpiration, sharePayload.refreshToken, sharePayload.refreshExpiration, sharePayload.readonlyAccess, ) if (session !== null) { await this.populateSession(rootKey, user, session, sharePayload.host) } } private async populateSession( rootKey: SNRootKey, user: Responses.User, session: Session | LegacySession, host: string, wrappingKey?: SNRootKey, ) { await this.protocolService.setRootKey(rootKey, wrappingKey) this.setUser(user) this.diskStorageService.setValue(StorageKey.User, user) void this.apiService.setHost(host) this.httpService.setHost(host) this.setSession(session) } async handleAuthentication(dto: { session: SessionBody user: { uuid: string email: string } rootKey: SNRootKey wrappingKey?: SNRootKey }): Promise { const session = this.createSession( dto.session.access_token, dto.session.access_expiration, dto.session.refresh_token, dto.session.refresh_expiration, dto.session.readonly_access, ) if (session !== null) { await this.populateSession(dto.rootKey, dto.user, session, this.apiService.getHost(), dto.wrappingKey) } } /** * @deprecated use handleAuthentication instead */ private async handleSuccessAuthResponse( response: Responses.SignInResponse | Responses.ChangeCredentialsResponse, rootKey: SNRootKey, wrappingKey?: SNRootKey, ) { const { data } = response const user = data.user as Responses.User const isLegacyJwtResponse = data.token != undefined if (isLegacyJwtResponse) { const sessionOrError = LegacySession.create(data.token as string) if (!sessionOrError.isFailed()) { await this.populateSession(rootKey, user, sessionOrError.getValue(), this.apiService.getHost(), wrappingKey) } } else if (data.session) { const session = this.createSession( data.session.access_token, data.session.access_expiration, data.session.refresh_token, data.session.refresh_expiration, data.session.readonly_access, ) if (session !== null) { await this.populateSession(rootKey, user, session, this.apiService.getHost(), wrappingKey) } } } private createSession( accessTokenValue: string, accessExpiration: number, refreshTokenValue: string, refreshExpiration: number, readonlyAccess: boolean, ): Session | null { const accessTokenOrError = SessionToken.create(accessTokenValue, accessExpiration) if (accessTokenOrError.isFailed()) { return null } const accessToken = accessTokenOrError.getValue() const refreshTokenOrError = SessionToken.create(refreshTokenValue, refreshExpiration) if (refreshTokenOrError.isFailed()) { return null } const refreshToken = refreshTokenOrError.getValue() const sessionOrError = Session.create(accessToken, refreshToken, readonlyAccess) if (sessionOrError.isFailed()) { return null } return sessionOrError.getValue() } override getDiagnostics(): Promise { return Promise.resolve({ session: { isSessionRenewChallengePresented: this.isSessionRenewChallengePresented, online: this.online(), offline: this.offline(), isSignedIn: this.isSignedIn(), isSignedIntoFirstPartyServer: this.isSignedIntoFirstPartyServer(), }, }) } }