fix(snjs): refreshing sessions (#2106)

* fix(snjs): refreshing sessions

* fix(snjs): bring back all tests

* fix(snjs): passing session tokens values

* fix(api): remove redundant specs

* fix(snjs): add projecting sessions to storage values

* fix(snjs): deps tree

* fix(snjs): bring back subscription tests

* fix(snjs): remove only tag for migration tests

* fix(snjs): session specs
This commit is contained in:
Karol Sójko
2022-12-19 08:28:10 +01:00
committed by GitHub
parent abdaec89b7
commit bb4f1ff099
37 changed files with 467 additions and 1430 deletions

View File

@@ -82,6 +82,9 @@ import { SNLog } from '../Log'
import { ChallengeResponse, ListedClientInterface } from '../Services'
import { ApplicationConstructorOptions, FullyResolvedApplicationOptions } from './Options/ApplicationOptions'
import { ApplicationOptionsDefaults } from './Options/Defaults'
import { LegacySession, MapperInterface, Session } from '@standardnotes/domain-core'
import { SessionStorageMapper } from '@Lib/Services/Mapping/SessionStorageMapper'
import { LegacySessionStorageMapper } from '@Lib/Services/Mapping/LegacySessionStorageMapper'
/** How often to automatically sync, in milliseconds */
const DEFAULT_AUTO_SYNC_INTERVAL = 30_000
@@ -154,6 +157,8 @@ export class SNApplication implements ApplicationInterface, AppGroupManagedAppli
private integrityService!: ExternalServices.IntegrityService
private statusService!: ExternalServices.StatusService
private filesBackupService?: FilesBackupService
private declare sessionStorageMapper: MapperInterface<Session, Record<string, unknown>>
private declare legacySessionStorageMapper: MapperInterface<LegacySession, Record<string, unknown>>
private internalEventBus!: ExternalServices.InternalEventBusInterface
@@ -1078,6 +1083,7 @@ export class SNApplication implements ApplicationInterface, AppGroupManagedAppli
}
private constructServices() {
this.createMappers()
this.createPayloadManager()
this.createItemManager()
this.createDiskStorageManager()
@@ -1169,6 +1175,8 @@ export class SNApplication implements ApplicationInterface, AppGroupManagedAppli
;(this.mutatorService as unknown) = undefined
;(this.filesBackupService as unknown) = undefined
;(this.statusService as unknown) = undefined
;(this.sessionStorageMapper as unknown) = undefined
;(this.legacySessionStorageMapper as unknown) = undefined
this.services = []
}
@@ -1289,6 +1297,7 @@ export class SNApplication implements ApplicationInterface, AppGroupManagedAppli
environment: this.environment,
identifier: this.identifier,
internalEventBus: this.internalEventBus,
legacySessionStorageMapper: this.legacySessionStorageMapper,
})
this.services.push(this.migrationService)
}
@@ -1335,6 +1344,8 @@ export class SNApplication implements ApplicationInterface, AppGroupManagedAppli
this.options.defaultHost,
this.inMemoryStore,
this.options.crypto,
this.sessionStorageMapper,
this.legacySessionStorageMapper,
this.internalEventBus,
)
this.services.push(this.apiService)
@@ -1419,9 +1430,15 @@ export class SNApplication implements ApplicationInterface, AppGroupManagedAppli
this.options.appVersion,
SnjsVersion,
this.apiService.processMetaObject.bind(this.apiService),
this.apiService.setSession.bind(this.apiService),
)
}
private createMappers() {
this.sessionStorageMapper = new SessionStorageMapper()
this.legacySessionStorageMapper = new LegacySessionStorageMapper()
}
private createPayloadManager() {
this.payloadManager = new InternalServices.PayloadManager(this.internalEventBus)
this.services.push(this.payloadManager)
@@ -1497,6 +1514,8 @@ export class SNApplication implements ApplicationInterface, AppGroupManagedAppli
this.challengeService,
this.webSocketsService,
this.httpService,
this.sessionStorageMapper,
this.legacySessionStorageMapper,
this.internalEventBus,
)
this.serviceObservers.push(

View File

@@ -5,6 +5,7 @@ import { SNSessionManager } from '../Services/Session/SessionManager'
import { ApplicationIdentifier } from '@standardnotes/common'
import { ItemManager } from '@Lib/Services/Items/ItemManager'
import { ChallengeService, SNSingletonManager, SNFeaturesService, DiskStorageService } from '@Lib/Services'
import { LegacySession, MapperInterface } from '@standardnotes/domain-core'
export type MigrationServices = {
protocolService: EncryptionService
@@ -17,5 +18,6 @@ export type MigrationServices = {
featuresService: SNFeaturesService
environment: Environment
identifier: ApplicationIdentifier
legacySessionStorageMapper: MapperInterface<LegacySession, Record<string, unknown>>
internalEventBus: InternalEventBusInterface
}

View File

@@ -1,5 +1,4 @@
import { AnyKeyParamsContent, ContentType, ProtocolVersion } from '@standardnotes/common'
import { JwtSession } from '../../Services/Session/Sessions/JwtSession'
import { Migration } from '@Lib/Migrations/Migration'
import { MigrationServices } from '../MigrationServices'
import { PreviousSnjsVersion2_0_0 } from '../../Version'
@@ -16,6 +15,7 @@ import {
PayloadTimestampDefaults,
} from '@standardnotes/models'
import { isMobileDevice } from '@standardnotes/services'
import { LegacySession } from '@standardnotes/domain-core'
interface LegacyStorageContent extends Models.ItemContent {
storage: unknown
@@ -673,8 +673,13 @@ export class Migration2_0_0 extends Migration {
}
}
const session = new JwtSession(currentToken)
this.services.storageService.setValue(Services.StorageKey.Session, session)
const sessionOrError = LegacySession.create(currentToken)
if (!sessionOrError.isFailed()) {
this.services.storageService.setValue(
Services.StorageKey.Session,
this.services.legacySessionStorageMapper.toProjection(sessionOrError.getValue()),
)
}
/** Server has to be migrated separately on mobile */
if (isEnvironmentMobile(this.services.environment)) {

View File

@@ -34,23 +34,22 @@ import {
API_MESSAGE_TOKEN_REFRESH_IN_PROGRESS,
} from '@standardnotes/services'
import { FilesApiInterface } from '@standardnotes/files'
import { ServerSyncPushContextualPayload, SNFeatureRepo, FileContent } from '@standardnotes/models'
import * as Responses from '@standardnotes/responses'
import { LegacySession, MapperInterface, Session, SessionToken } from '@standardnotes/domain-core'
import { HttpResponseMeta } from '@standardnotes/api'
import { SNRootKeyParams } from '@standardnotes/encryption'
import { ApiEndpointParam, ClientDisplayableError, CreateValetTokenPayload } from '@standardnotes/responses'
import { PureCryptoInterface } from '@standardnotes/sncrypto-common'
import { HttpParams, HttpRequest, HttpVerb, SNHttpService } from './HttpService'
import { isUrlFirstParty, TRUSTED_FEATURE_HOSTS } from '@Lib/Hosts'
import { Paths } from './Paths'
import { Session } from '../Session/Sessions/Session'
import { TokenSession } from '../Session/Sessions/TokenSession'
import { DiskStorageService } from '../Storage/DiskStorageService'
import { HttpResponseMeta } from '@standardnotes/api'
import { UuidString } from '../../Types/UuidString'
import merge from 'lodash/merge'
import { SettingsServerInterface } from '../Settings/SettingsServerInterface'
import { Strings } from '@Lib/Strings'
import { SNRootKeyParams } from '@standardnotes/encryption'
import { ApiEndpointParam, ClientDisplayableError, CreateValetTokenPayload } from '@standardnotes/responses'
import { PureCryptoInterface } from '@standardnotes/sncrypto-common'
/** Legacy api version field to be specified in params when calling v0 APIs. */
const V0_API_VERSION = '20200115'
@@ -66,7 +65,7 @@ export class SNApiService
ItemsServerInterface,
SettingsServerInterface
{
private session?: Session
private session: Session | LegacySession | null
public user?: Responses.User
private registering = false
private authenticating = false
@@ -81,16 +80,20 @@ export class SNApiService
private host: string,
private inMemoryStore: KeyValueStoreInterface<string>,
private crypto: PureCryptoInterface,
private sessionStorageMapper: MapperInterface<Session, Record<string, unknown>>,
private legacySessionStorageMapper: MapperInterface<LegacySession, Record<string, unknown>>,
protected override internalEventBus: InternalEventBusInterface,
) {
super(internalEventBus)
this.session = null
}
override deinit(): void {
;(this.httpService as unknown) = undefined
;(this.storageService as unknown) = undefined
this.invalidSessionObserver = undefined
this.session = undefined
this.session = null
super.deinit()
}
@@ -145,14 +148,21 @@ export class SNApiService
return this.filesHost
}
public setSession(session: Session, persist = true): void {
public setSession(session: Session | LegacySession, persist = true): void {
this.session = session
if (persist) {
this.storageService.setValue(StorageKey.Session, session)
let sessionProjection: Record<string, unknown>
if (session instanceof Session) {
sessionProjection = this.sessionStorageMapper.toProjection(session)
} else {
sessionProjection = this.legacySessionStorageMapper.toProjection(session)
}
this.storageService.setValue(StorageKey.Session, sessionProjection)
}
}
public getSession(): Session | undefined {
public getSession(): Session | LegacySession | null {
return this.session
}
@@ -252,7 +262,7 @@ export class SNApiService
fallbackErrorMessage: API_MESSAGE_GENERIC_INVALID_LOGIN,
params,
/** A session is optional here, if valid, endpoint bypasses 2FA and returns additional params */
authentication: this.session?.authorizationValue,
authentication: this.getSessionAccessToken(),
})
}
@@ -289,7 +299,7 @@ export class SNApiService
signOut(): Promise<Responses.SignOutResponse> {
const url = joinPaths(this.host, Paths.v1.signOut)
return this.httpService.postAbsolute(url, undefined, this.session?.authorizationValue).catch((errorResponse) => {
return this.httpService.postAbsolute(url, undefined, this.getSessionAccessToken()).catch((errorResponse) => {
return errorResponse
}) as Promise<Responses.SignOutResponse>
}
@@ -317,7 +327,7 @@ export class SNApiService
...parameters.newKeyParams.getPortableValue(),
})
const response = await this.httpService
.putAbsolute(url, params, this.session?.authorizationValue)
.putAbsolute(url, params, this.getSessionAccessToken())
.catch(async (errorResponse) => {
if (Responses.isErrorResponseExpiredToken(errorResponse)) {
return this.refreshSessionThenRetryRequest({
@@ -353,7 +363,7 @@ export class SNApiService
[ApiEndpointParam.SyncDlLimit]: limit,
})
const response = await this.httpService
.postAbsolute(url, params, this.session?.authorizationValue)
.postAbsolute(url, params, this.getSessionAccessToken())
.catch<Responses.HttpResponse>(async (errorResponse) => {
this.preprocessAuthenticatedErrorResponse(errorResponse)
if (Responses.isErrorResponseExpiredToken(errorResponse)) {
@@ -378,7 +388,7 @@ export class SNApiService
return this.httpService
.runHttp({
...httpRequest,
authentication: this.session?.authorizationValue,
authentication: this.getSessionAccessToken(),
})
.catch((errorResponse) => {
return errorResponse
@@ -393,16 +403,54 @@ export class SNApiService
}
this.refreshingSession = true
const url = joinPaths(this.host, Paths.v1.refreshSession)
const session = this.session as TokenSession
const session = this.session as Session
const params = this.params({
access_token: session.accessToken,
refresh_token: session.refreshToken,
access_token: session.accessToken.value,
refresh_token: session.refreshToken.value,
})
const result = await this.httpService
.postAbsolute(url, params)
.then(async (response) => {
const session = TokenSession.FromApiResponse(response as Responses.SessionRenewalResponse)
await this.setSession(session)
const sessionRenewalResponse = response as Responses.SessionRenewalResponse
if (
sessionRenewalResponse.error ||
sessionRenewalResponse.data?.error ||
!sessionRenewalResponse.data.session
) {
return null
}
const accessTokenOrError = SessionToken.create(
sessionRenewalResponse.data.session.access_token,
sessionRenewalResponse.data.session.access_expiration,
)
if (accessTokenOrError.isFailed()) {
return null
}
const accessToken = accessTokenOrError.getValue()
const refreshTokenOrError = SessionToken.create(
sessionRenewalResponse.data.session.refresh_token,
sessionRenewalResponse.data.session.refresh_expiration,
)
if (refreshTokenOrError.isFailed()) {
return null
}
const refreshToken = refreshTokenOrError.getValue()
const sessionOrError = Session.create(
accessToken,
refreshToken,
sessionRenewalResponse.data.session.readonly_access,
)
if (sessionOrError.isFailed()) {
return null
}
const session = sessionOrError.getValue()
this.session = session
this.setSession(session)
this.processResponse(response)
return response
})
@@ -411,6 +459,11 @@ export class SNApiService
return this.errorResponseWithFallbackMessage(errorResponse, API_MESSAGE_GENERIC_TOKEN_REFRESH_FAIL)
})
this.refreshingSession = false
if (result === null) {
return this.createErrorResponse(API_MESSAGE_INVALID_SESSION)
}
return result
}
@@ -421,7 +474,7 @@ export class SNApiService
}
const url = joinPaths(this.host, Paths.v1.sessions)
const response = await this.httpService
.getAbsolute(url, {}, this.session?.authorizationValue)
.getAbsolute(url, {}, this.getSessionAccessToken())
.catch(async (errorResponse) => {
this.preprocessAuthenticatedErrorResponse(errorResponse)
if (Responses.isErrorResponseExpiredToken(errorResponse)) {
@@ -444,7 +497,7 @@ export class SNApiService
}
const url = joinPaths(this.host, <string>Paths.v1.session(sessionId))
const response: Responses.RevisionListResponse | Responses.HttpResponse = await this.httpService
.deleteAbsolute(url, { uuid: sessionId }, this.session?.authorizationValue)
.deleteAbsolute(url, { uuid: sessionId }, this.getSessionAccessToken())
.catch((error: Responses.HttpResponse) => {
const errorResponse = error as Responses.HttpResponse
this.preprocessAuthenticatedErrorResponse(errorResponse)
@@ -467,7 +520,7 @@ export class SNApiService
}
const url = joinPaths(this.host, Paths.v1.itemRevisions(itemId))
const response: Responses.RevisionListResponse | Responses.HttpResponse = await this.httpService
.getAbsolute(url, undefined, this.session?.authorizationValue)
.getAbsolute(url, undefined, this.getSessionAccessToken())
.catch((errorResponse: Responses.HttpResponse) => {
this.preprocessAuthenticatedErrorResponse(errorResponse)
if (Responses.isErrorResponseExpiredToken(errorResponse)) {
@@ -492,7 +545,7 @@ export class SNApiService
}
const url = joinPaths(this.host, Paths.v1.itemRevision(itemId, entry.uuid))
const response: Responses.SingleRevisionResponse | Responses.HttpResponse = await this.httpService
.getAbsolute(url, undefined, this.session?.authorizationValue)
.getAbsolute(url, undefined, this.getSessionAccessToken())
.catch((errorResponse: Responses.HttpResponse) => {
this.preprocessAuthenticatedErrorResponse(errorResponse)
if (Responses.isErrorResponseExpiredToken(errorResponse)) {
@@ -510,7 +563,7 @@ export class SNApiService
async getUserFeatures(userUuid: UuidString): Promise<Responses.HttpResponse | Responses.UserFeaturesResponse> {
const url = joinPaths(this.host, Paths.v1.userFeatures(userUuid))
const response = await this.httpService
.getAbsolute(url, undefined, this.session?.authorizationValue)
.getAbsolute(url, undefined, this.getSessionAccessToken())
.catch((errorResponse: Responses.HttpResponse) => {
this.preprocessAuthenticatedErrorResponse(errorResponse)
if (Responses.isErrorResponseExpiredToken(errorResponse)) {
@@ -550,7 +603,7 @@ export class SNApiService
verb: HttpVerb.Get,
url: joinPaths(this.host, Paths.v1.settings(userUuid)),
fallbackErrorMessage: API_MESSAGE_FAILED_GET_SETTINGS,
authentication: this.session?.authorizationValue,
authentication: this.getSessionAccessToken(),
})
}
@@ -568,7 +621,7 @@ export class SNApiService
return this.tokenRefreshableRequest<Responses.UpdateSettingResponse>({
verb: HttpVerb.Put,
url: joinPaths(this.host, Paths.v1.settings(userUuid)),
authentication: this.session?.authorizationValue,
authentication: this.getSessionAccessToken(),
fallbackErrorMessage: API_MESSAGE_FAILED_UPDATE_SETTINGS,
params,
})
@@ -578,7 +631,7 @@ export class SNApiService
return await this.tokenRefreshableRequest<Responses.GetSettingResponse>({
verb: HttpVerb.Get,
url: joinPaths(this.host, Paths.v1.setting(userUuid, settingName.toLowerCase() as SettingName)),
authentication: this.session?.authorizationValue,
authentication: this.getSessionAccessToken(),
fallbackErrorMessage: API_MESSAGE_FAILED_GET_SETTINGS,
})
}
@@ -593,7 +646,7 @@ export class SNApiService
this.host,
Paths.v1.subscriptionSetting(userUuid, settingName.toLowerCase() as SubscriptionSettingName),
),
authentication: this.session?.authorizationValue,
authentication: this.getSessionAccessToken(),
fallbackErrorMessage: API_MESSAGE_FAILED_GET_SETTINGS,
})
}
@@ -602,7 +655,7 @@ export class SNApiService
return this.tokenRefreshableRequest<Responses.DeleteSettingResponse>({
verb: HttpVerb.Delete,
url: joinPaths(this.host, Paths.v1.setting(userUuid, settingName)),
authentication: this.session?.authorizationValue,
authentication: this.getSessionAccessToken(),
fallbackErrorMessage: API_MESSAGE_FAILED_UPDATE_SETTINGS,
})
}
@@ -616,7 +669,7 @@ export class SNApiService
verb: HttpVerb.Delete,
url,
fallbackErrorMessage: API_MESSAGE_FAILED_DELETE_REVISION,
authentication: this.session?.authorizationValue,
authentication: this.getSessionAccessToken(),
})
return response
}
@@ -635,7 +688,7 @@ export class SNApiService
const response = await this.tokenRefreshableRequest({
verb: HttpVerb.Get,
url,
authentication: this.session?.authorizationValue,
authentication: this.getSessionAccessToken(),
fallbackErrorMessage: API_MESSAGE_FAILED_SUBSCRIPTION_INFO,
})
return response
@@ -658,7 +711,7 @@ export class SNApiService
const response: Responses.HttpResponse | Responses.PostSubscriptionTokensResponse = await this.request({
verb: HttpVerb.Post,
url,
authentication: this.session?.authorizationValue,
authentication: this.getSessionAccessToken(),
fallbackErrorMessage: API_MESSAGE_FAILED_ACCESS_PURCHASE,
})
return (response as Responses.PostSubscriptionTokensResponse).data?.token
@@ -706,7 +759,7 @@ export class SNApiService
verb: HttpVerb.Post,
url: joinPaths(this.host, Paths.v1.listedRegistration(this.user.uuid)),
fallbackErrorMessage: API_MESSAGE_FAILED_LISTED_REGISTRATION,
authentication: this.session?.authorizationValue,
authentication: this.getSessionAccessToken(),
})
}
@@ -725,7 +778,7 @@ export class SNApiService
const response = await this.tokenRefreshableRequest<Responses.CreateValetTokenResponse>({
verb: HttpVerb.Post,
url: url,
authentication: this.session?.authorizationValue,
authentication: this.getSessionAccessToken(),
fallbackErrorMessage: API_MESSAGE_FAILED_CREATE_FILE_TOKEN,
params,
})
@@ -860,7 +913,7 @@ export class SNApiService
integrityPayloads,
},
fallbackErrorMessage: API_MESSAGE_GENERIC_INTEGRITY_CHECK_FAIL,
authentication: this.session?.authorizationValue,
authentication: this.getSessionAccessToken(),
})
}
@@ -869,7 +922,7 @@ export class SNApiService
verb: HttpVerb.Get,
url: joinPaths(this.host, Paths.v1.getSingleItem(itemUuid)),
fallbackErrorMessage: API_MESSAGE_GENERIC_SINGLE_ITEM_SYNC_FAIL,
authentication: this.session?.authorizationValue,
authentication: this.getSessionAccessToken(),
})
}
@@ -890,6 +943,18 @@ export class SNApiService
}
}
private getSessionAccessToken(): string | undefined {
if (!this.session) {
return undefined
}
if (this.session instanceof Session) {
return this.session.accessToken.value
}
return this.session.accessToken
}
override getDiagnostics(): Promise<DiagnosticInfo | undefined> {
return Promise.resolve({
api: {

View File

@@ -1,6 +1,5 @@
export * from './ApiService'
export * from './HttpService'
export * from './Paths'
export * from '../Session/Sessions/Session'
export * from '../Session/SessionManager'
export * from './WebsocketsService'

View File

@@ -290,7 +290,7 @@ export class ChallengeService extends AbstractService implements ChallengeServic
if (operation.isFinished()) {
this.deleteChallengeOperation(operation)
const observers = this.challengeObservers[challenge.id]
const observers = this.challengeObservers[challenge.id] || []
observers.forEach(clearChallengeObserver)
observers.length = 0

View File

@@ -0,0 +1,20 @@
import { LegacySession, MapperInterface } from '@standardnotes/domain-core'
export class LegacySessionStorageMapper implements MapperInterface<LegacySession, Record<string, unknown>> {
toDomain(projection: Record<string, unknown>): LegacySession {
const { jwt } = projection
const legacySessionOrError = LegacySession.create(jwt as string)
if (legacySessionOrError.isFailed()) {
throw new Error(legacySessionOrError.getError())
}
return legacySessionOrError.getValue()
}
toProjection(domain: LegacySession): Record<string, unknown> {
return {
jwt: domain.accessToken,
}
}
}

View File

@@ -0,0 +1,40 @@
import { MapperInterface, Session, SessionToken } from '@standardnotes/domain-core'
export class SessionStorageMapper implements MapperInterface<Session, Record<string, unknown>> {
toDomain(projection: Record<string, unknown>): Session {
const accessTokenOrError = SessionToken.create(
projection.accessToken as string,
projection.accessExpiration as number,
)
if (accessTokenOrError.isFailed()) {
throw new Error(accessTokenOrError.getError())
}
const accessToken = accessTokenOrError.getValue()
const refreshTokenOrError = SessionToken.create(
projection.refreshToken as string,
projection.refreshExpiration as number,
)
if (refreshTokenOrError.isFailed()) {
throw new Error(refreshTokenOrError.getError())
}
const refreshToken = refreshTokenOrError.getValue()
const session = Session.create(accessToken, refreshToken, projection.readonlyAccess as boolean)
if (session.isFailed()) {
throw new Error(session.getError())
}
return session.getValue()
}
toProjection(domain: Session): Record<string, unknown> {
return {
accessToken: domain.accessToken.value,
refreshToken: domain.refreshToken.value,
accessExpiration: domain.accessToken.expiresAt,
refreshExpiration: domain.refreshToken.expiresAt,
readonlyAccess: domain.isReadOnly(),
}
}
}

View File

@@ -27,21 +27,19 @@ import { Base64String } from '@standardnotes/sncrypto-common'
import { ClientDisplayableError } from '@standardnotes/responses'
import { CopyPayloadWithContentOverride } from '@standardnotes/models'
import { isNullOrUndefined } from '@standardnotes/utils'
import { JwtSession } from './Sessions/JwtSession'
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 { Session } from './Sessions/Session'
import { SessionFromRawStorageValue } from './Sessions/Generator'
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 { Subscription } from '@standardnotes/security'
import { TokenSession } from './Sessions/TokenSession'
import { UuidString } from '@Lib/Types/UuidString'
import * as Common from '@standardnotes/common'
import * as Responses from '@standardnotes/responses'
import { ChallengeService } from '../Challenge'
import {
ApiCallError,
@@ -82,6 +80,8 @@ export class SNSessionManager extends AbstractService<SessionEvent> implements S
private challengeService: ChallengeService,
private webSocketsService: SNWebSocketsService,
private httpService: HttpServiceInterface,
private sessionStorageMapper: MapperInterface<Session, Record<string, unknown>>,
private legacySessionStorageMapper: MapperInterface<LegacySession, Record<string, unknown>>,
protected override internalEventBus: InternalEventBusInterface,
) {
super(internalEventBus)
@@ -122,13 +122,23 @@ export class SNSessionManager extends AbstractService<SessionEvent> implements S
const rawSession = this.diskStorageService.getValue<RawStorageValue>(StorageKey.Session)
if (rawSession) {
const session = SessionFromRawStorageValue(rawSession)
this.setSession(session, false)
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, persist = true): void {
this.httpService.setAuthorizationToken(session.authorizationValue)
private setSession(session: Session | LegacySession, persist = true): void {
if (session instanceof Session) {
this.httpService.setSession(session)
}
this.apiService.setSession(session, persist)
@@ -158,7 +168,7 @@ export class SNSessionManager extends AbstractService<SessionEvent> implements S
public async signOut() {
this.setUser(undefined)
const session = this.apiService.getSession()
if (session && session.canExpire()) {
if (session && session instanceof Session) {
await this.apiService.signOut()
this.webSocketsService.closeWebSocketConnection()
}
@@ -560,17 +570,17 @@ export class SNSessionManager extends AbstractService<SessionEvent> implements S
if (!session) {
return new ClientDisplayableError('Cannot generate share token without active session')
}
if (!(session instanceof TokenSession)) {
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,
refreshToken: session.refreshToken,
accessExpiration: session.accessExpiration,
refreshExpiration: session.refreshExpiration,
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,
@@ -597,7 +607,7 @@ export class SNSessionManager extends AbstractService<SessionEvent> implements S
const user = sharePayload.user
const session = new TokenSession(
const session = this.createSession(
sharePayload.accessToken,
sharePayload.accessExpiration,
sharePayload.refreshToken,
@@ -605,13 +615,15 @@ export class SNSessionManager extends AbstractService<SessionEvent> implements S
sharePayload.readonlyAccess,
)
await this.populateSession(rootKey, user, session, sharePayload.host)
if (session !== null) {
await this.populateSession(rootKey, user, session, sharePayload.host)
}
}
private async populateSession(
rootKey: SNRootKey,
user: Responses.User,
session: Session,
session: Session | LegacySession,
host: string,
wrappingKey?: SNRootKey,
) {
@@ -629,14 +641,17 @@ export class SNSessionManager extends AbstractService<SessionEvent> implements S
}
private async handleAuthResponse(body: UserRegistrationResponseBody, rootKey: SNRootKey, wrappingKey?: SNRootKey) {
const session = new TokenSession(
const session = this.createSession(
body.session.access_token,
body.session.access_expiration,
body.session.refresh_token,
body.session.refresh_expiration,
body.session.readonly_access,
)
await this.populateSession(rootKey, body.user, session, this.apiService.getHost(), wrappingKey)
if (session !== null) {
await this.populateSession(rootKey, body.user, session, this.apiService.getHost(), wrappingKey)
}
}
/**
@@ -652,14 +667,51 @@ export class SNSessionManager extends AbstractService<SessionEvent> implements S
const isLegacyJwtResponse = data.token != undefined
if (isLegacyJwtResponse) {
const session = new JwtSession(data.token as string)
await this.populateSession(rootKey, user, session, this.apiService.getHost(), wrappingKey)
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 = TokenSession.FromApiResponse(response)
await this.populateSession(rootKey, user, session, this.apiService.getHost(), wrappingKey)
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<DiagnosticInfo | undefined> {
return Promise.resolve({
session: {

View File

@@ -1,18 +0,0 @@
import { JwtSession } from './JwtSession'
import { TokenSession } from './TokenSession'
import { RawSessionPayload, RawStorageValue } from './Types'
export function SessionFromRawStorageValue(raw: RawStorageValue): JwtSession | TokenSession {
if ('jwt' in raw) {
return new JwtSession(raw.jwt as string)
} else {
const rawSession = raw as RawSessionPayload
return new TokenSession(
rawSession.accessToken,
rawSession.accessExpiration,
rawSession.refreshToken,
rawSession.refreshExpiration,
rawSession.readonlyAccess,
)
}
}

View File

@@ -1,20 +0,0 @@
import { Session } from './Session'
/** Legacy, for protocol versions <= 003 */
export class JwtSession extends Session {
public jwt: string
constructor(jwt: string) {
super()
this.jwt = jwt
}
public get authorizationValue(): string {
return this.jwt
}
public canExpire(): false {
return false
}
}

View File

@@ -1,6 +0,0 @@
export abstract class Session {
public abstract canExpire(): boolean
/** Return the token that should be included in the header of authorized network requests */
public abstract get authorizationValue(): string
}

View File

@@ -1,46 +0,0 @@
import { SessionBody, SessionRenewalResponse } from '@standardnotes/responses'
import { Session } from './Session'
/** For protocol versions >= 004 */
export class TokenSession extends Session {
static FromApiResponse(response: SessionRenewalResponse) {
const body = response.data.session as SessionBody
const accessToken: string = body.access_token
const refreshToken: string = body.refresh_token
const accessExpiration: number = body.access_expiration
const refreshExpiration: number = body.refresh_expiration
const readonlyAccess: boolean = body.readonly_access
return new TokenSession(accessToken, accessExpiration, refreshToken, refreshExpiration, readonlyAccess)
}
constructor(
public accessToken: string,
public accessExpiration: number,
public refreshToken: string,
public refreshExpiration: number,
private readonlyAccess: boolean,
) {
super()
}
isReadOnly() {
return this.readonlyAccess
}
private getExpireAt() {
return this.accessExpiration || 0
}
public get authorizationValue() {
return this.accessToken
}
public canExpire() {
return true
}
public isExpired() {
return this.getExpireAt() < Date.now()
}
}

View File

@@ -1,5 +1 @@
export * from './Generator'
export * from './JwtSession'
export * from './Session'
export * from './TokenSession'
export * from './Types'

View File

@@ -31,7 +31,7 @@ describe('server session', function () {
async function sleepUntilSessionExpires(application, basedOnAccessToken = true) {
const currentSession = application.apiService.session
const timestamp = basedOnAccessToken ? currentSession.accessExpiration : currentSession.refreshExpiration
const timestamp = basedOnAccessToken ? currentSession.accessToken.expiresAt : currentSession.refreshToken.expiresAt
const timeRemaining = (timestamp - Date.now()) / 1000 // in ms
/*
If the token has not expired yet, we will return the remaining time.
@@ -98,12 +98,12 @@ describe('server session', function () {
// After the above sync request is completed, we obtain the session information.
const sessionAfterSync = this.application.apiService.getSession()
expect(sessionBeforeSync).to.not.equal(sessionAfterSync)
expect(sessionBeforeSync.accessToken).to.not.equal(sessionAfterSync.accessToken)
expect(sessionBeforeSync.refreshToken).to.not.equal(sessionAfterSync.refreshToken)
expect(sessionBeforeSync.accessExpiration).to.be.lessThan(sessionAfterSync.accessExpiration)
expect(sessionBeforeSync.equals(sessionAfterSync)).to.not.equal(true)
expect(sessionBeforeSync.accessToken.value).to.not.equal(sessionAfterSync.accessToken.value)
expect(sessionBeforeSync.refreshToken.value).to.not.equal(sessionAfterSync.refreshToken.value)
expect(sessionBeforeSync.accessToken.expiresAt).to.be.lessThan(sessionAfterSync.accessToken.expiresAt)
// New token should expire in the future.
expect(sessionAfterSync.accessExpiration).to.be.greaterThan(Date.now())
expect(sessionAfterSync.accessToken.expiresAt).to.be.greaterThan(Date.now())
})
it('should succeed when a sync request is perfomed after signing into an ephemeral session', async function () {
@@ -142,14 +142,22 @@ describe('server session', function () {
const sessionFromStorage = await getSessionFromStorage(this.application)
const sessionFromApiService = this.application.apiService.getSession()
expect(sessionFromStorage).to.equal(sessionFromApiService)
expect(sessionFromStorage.accessToken).to.equal(sessionFromApiService.accessToken.value)
expect(sessionFromStorage.refreshToken).to.equal(sessionFromApiService.refreshToken.value)
expect(sessionFromStorage.accessExpiration).to.equal(sessionFromApiService.accessToken.expiresAt)
expect(sessionFromStorage.refreshExpiration).to.equal(sessionFromApiService.refreshToken.expiresAt)
expect(sessionFromStorage.readonlyAccess).to.equal(sessionFromApiService.isReadOnly())
await this.application.apiService.refreshSession()
const updatedSessionFromStorage = await getSessionFromStorage(this.application)
const updatedSessionFromApiService = this.application.apiService.getSession()
expect(updatedSessionFromStorage).to.equal(updatedSessionFromApiService)
expect(updatedSessionFromStorage.accessToken).to.equal(updatedSessionFromApiService.accessToken.value)
expect(updatedSessionFromStorage.refreshToken).to.equal(updatedSessionFromApiService.refreshToken.value)
expect(updatedSessionFromStorage.accessExpiration).to.equal(updatedSessionFromApiService.accessToken.expiresAt)
expect(updatedSessionFromStorage.refreshExpiration).to.equal(updatedSessionFromApiService.refreshToken.expiresAt)
expect(updatedSessionFromStorage.readonlyAccess).to.equal(updatedSessionFromApiService.isReadOnly())
})
it('should be performed successfully and terminate session with a valid access token', async function () {
@@ -221,8 +229,16 @@ describe('server session', function () {
let { application, password } = await Factory.createAndInitSimpleAppContext({
registerUser: true,
})
const fakeSession = application.apiService.getSession()
fakeSession.accessToken = 'this-is-a-fake-token-1234'
application.diskStorageService.setValue(StorageKey.Session, {
accessToken: 'this-is-a-fake-token-1234',
refreshToken: 'this-is-a-fake-token-1234',
accessExpiration: 999999999999999,
refreshExpiration: 99999999999999,
readonlyAccess: false,
})
application.sessions.initializeFromDisk()
Factory.ignoreChallenges(application)
const newEmail = UuidGenerator.GenerateUuid()
@@ -311,8 +327,15 @@ describe('server session', function () {
password: this.password,
})
const fakeSession = this.application.apiService.getSession()
fakeSession.accessToken = 'this-is-a-fake-token-1234'
this.application.diskStorageService.setValue(StorageKey.Session, {
accessToken: 'this-is-a-fake-token-1234',
refreshToken: 'this-is-a-fake-token-1234',
accessExpiration: 999999999999999,
refreshExpiration: 99999999999999,
readonlyAccess: false,
})
this.application.sessions.initializeFromDisk()
Factory.ignoreChallenges(this.application)
const changePasswordResponse = await this.application.changePassword(this.password, this.newPassword)
expect(changePasswordResponse.error.message).to.equal('Invalid login credentials.')
@@ -354,7 +377,7 @@ describe('server session', function () {
expect(currentSession).to.be.ok
expect(currentSession.accessToken).to.be.ok
expect(currentSession.refreshToken).to.be.ok
expect(currentSession.accessExpiration).to.be.greaterThan(Date.now())
expect(currentSession.accessToken.expiresAt).to.be.greaterThan(Date.now())
})
it('should fail when renewing a session with an expired refresh token', async function () {
@@ -392,10 +415,16 @@ describe('server session', function () {
password: this.password,
})
const fakeSession = this.application.apiService.getSession()
fakeSession.refreshToken = 'this-is-a-fake-token-1234'
const originalSession = this.application.apiService.getSession()
await this.application.apiService.setSession(fakeSession, true)
this.application.diskStorageService.setValue(StorageKey.Session, {
accessToken: originalSession.accessToken.value,
refreshToken: 'this-is-a-fake-token-1234',
accessExpiration: originalSession.accessToken.expiresAt,
refreshExpiration: originalSession.refreshToken.expiresAt,
readonlyAccess: false,
})
this.application.sessions.initializeFromDisk()
const refreshSessionResponse = await this.application.apiService.refreshSession()
@@ -530,8 +559,14 @@ describe('server session', function () {
const oldRootKey = await appA.protocolService.getRootKey()
/** Set the session as nonsense */
appA.apiService.session.accessToken = 'foo'
appA.apiService.session.refreshToken = 'bar'
appA.diskStorageService.setValue(StorageKey.Session, {
accessToken: 'foo',
refreshToken: 'bar',
accessExpiration: 999999999999999,
refreshExpiration: 999999999999999,
readonlyAccess: false,
})
appA.sessions.initializeFromDisk()
/** Perform an authenticated network request */
await appA.sync.sync()
@@ -540,8 +575,8 @@ describe('server session', function () {
await Factory.sleep(5.0)
expect(didPromptForSignIn).to.equal(true)
expect(appA.apiService.session.accessToken).to.not.equal('foo')
expect(appA.apiService.session.refreshToken).to.not.equal('bar')
expect(appA.apiService.session.accessToken.value).to.not.equal('foo')
expect(appA.apiService.session.refreshToken.value).to.not.equal('bar')
/** Expect that the session recovery replaces the global root key */
const newRootKey = await appA.protocolService.getRootKey()
@@ -646,9 +681,14 @@ describe('server session', function () {
password: this.password,
})
const invalidSession = this.application.apiService.getSession()
invalidSession.accessToken = undefined
invalidSession.refreshToken = undefined
this.application.diskStorageService.setValue(StorageKey.Session, {
accessToken: undefined,
refreshToken: undefined,
accessExpiration: 999999999999999,
refreshExpiration: 999999999999999,
readonlyAccess: false,
})
this.application.sessions.initializeFromDisk()
const storageKey = this.application.diskStorageService.getPersistenceKey()
expect(localStorage.getItem(storageKey)).to.be.ok

View File

@@ -2,7 +2,7 @@ import * as Factory from './lib/factory.js'
chai.use(chaiAsPromised)
const expect = chai.expect
describe.skip('subscriptions', function () {
describe('subscriptions', function () {
this.timeout(Factory.TwentySecondTimeout)
let application

View File

@@ -83,5 +83,8 @@
"webpack": "*",
"webpack-cli": "*",
"webpack-merge": "^5.8.0"
},
"dependencies": {
"@standardnotes/domain-core": "^1.11.0"
}
}