diff --git a/src/access.ts b/src/access.ts index f53ec9343..06e4808f2 100644 --- a/src/access.ts +++ b/src/access.ts @@ -21,9 +21,11 @@ export class Access { scopeModeMap: ScopeModeMap; rootPaths: string[]; storageType: string; + rs?: { _checkScopeChange?: () => void; }; - constructor() { - this.reset(); + constructor(rs?: { _checkScopeChange?: () => void; }) { + this.rs = rs; + this.reset(false); } /** @@ -74,6 +76,7 @@ export class Access { } this._adjustRootPaths(scope); this.scopeModeMap[scope] = mode; + this._notifyChange(); } /** @@ -99,11 +102,13 @@ export class Access { for (const name in this.scopeModeMap) { savedMap[name] = this.scopeModeMap[name]; } - this.reset(); + this.reset(false); delete savedMap[scope]; for (const name in savedMap) { - this.claim(name as AccessScope, savedMap[name]); + this._adjustRootPaths(name as AccessScope); + this.scopeModeMap[name] = savedMap[name]; } + this._notifyChange(); } /** @@ -141,9 +146,12 @@ export class Access { * * @ignore */ - reset(): void { + reset(notifyChange = true): void { this.rootPaths = []; this.scopeModeMap = {}; + if (notifyChange) { + this._notifyChange(); + } } /** @@ -193,6 +201,12 @@ export class Access { this.storageType = type; } + private _notifyChange (): void { + if (this.rs && typeof this.rs._checkScopeChange === 'function') { + this.rs._checkScopeChange(); + } + } + static _rs_init(): void { return; } diff --git a/src/authorize.ts b/src/authorize.ts index fb065b2b7..39640c7fe 100644 --- a/src/authorize.ts +++ b/src/authorize.ts @@ -10,6 +10,7 @@ interface AuthResult { access_token?: string; refresh_token?: string; code?: string; + scope?: string; rsDiscovery?: object; error?: string; remotestorage?: string; @@ -129,6 +130,8 @@ export class Authorize { throw new Error("Cannot authorize due to undefined or empty scope; did you forget to access.claim()?"); } + remoteStorage._rememberPendingScope(options.scope); + // TODO add a test for this // keep track of the discovery data during redirect if we can't save it in localStorage if (!localStorageAvailable() && remoteStorage.backend === 'remotestorage') { @@ -153,6 +156,7 @@ export class Authorize { .openWindow(url, options.redirectUri, 'location=yes,clearsessioncache=yes,clearcache=yes') .then((authResult: AuthResult) => { remoteStorage.remote.configure({ token: authResult.access_token }); + remoteStorage._completeAuthorization(authResult.scope || options.scope); }); return; } @@ -273,6 +277,7 @@ export class Authorize { } if (params.error) { + remoteStorage._forgetPendingScope(); if (params.error === 'access_denied') { throw new UnauthorizedError('Authorization failed: access denied', { code: 'access_denied' }); } else { @@ -288,6 +293,7 @@ export class Authorize { if (params.access_token) { remoteStorage.remote.configure({ token: params.access_token }); + remoteStorage._completeAuthorization(params.scope); authParamsUsed = true; } @@ -350,12 +356,14 @@ export class Authorize { }; if (settings.token) { remoteStorage.remote.configure(settings); + remoteStorage._completeAuthorization(xhr?.response?.scope); } else { remoteStorage._emit('error', new Error(`no access_token in "successful" response: ${xhr.response}`)); } sessionStorage.removeItem('remotestorage:codeVerifier'); break; default: + remoteStorage._forgetPendingScope(); remoteStorage._emit('error', new Error(`${xhr.statusText}: ${xhr.response}`)); } } diff --git a/src/remotestorage.ts b/src/remotestorage.ts index e37a0d06e..5e1a9d05b 100644 --- a/src/remotestorage.ts +++ b/src/remotestorage.ts @@ -39,6 +39,46 @@ const globalContext = getGlobalContext(); // } let hasLocalStorage: boolean; +const AUTHORIZED_SCOPE_KEY = 'remotestorage:authorized-scope'; +const PENDING_SCOPE_KEY = 'remotestorage:pending-scope'; + +interface StoredScopeSettings { + backend?: 'remotestorage' | 'dropbox' | 'googledrive'; + scope?: string; +} + +interface ScopeChangeEvent { + authorizedScope: string; + requestedScope: string; + reauthorize: () => void; +} + +function normalizeScope (scope?: string): string | null { + if (typeof scope !== 'string') { + return null; + } + + const scopes = scope + .trim() + .split(/\s+/) + .filter(Boolean); + + if (scopes.length === 0) { + return null; + } + + return Array.from(new Set(scopes)).sort().join(' '); +} + +function readStoredScopeSettings (key: string): StoredScopeSettings | null { + const settings = getJSONFromLocalStorage(key); + + if (typeof settings === 'object' && settings !== null) { + return settings as StoredScopeSettings; + } + + return null; +} // TODO document and/or refactor (seems weird) function emitUnauthorized(r) { @@ -204,6 +244,13 @@ enum ApiKeyType { * * Emitted before redirecting to the OAuth server * + * ### `scope-change-required` + * + * Emitted when the currently claimed access scopes differ from the last + * authorized scope stored in localStorage. The callback receives an object + * containing the previously authorized scope, the currently requested scope, + * and a `reauthorize()` helper. + * * ### `wire-busy` * * Emitted when a network request starts @@ -373,6 +420,9 @@ export class RemoteStorage { * @internal */ fireInitial: Function; + _authorizedScope: string | null; + _scopeChangeRequired: boolean; + _scopeChangeEvent: ScopeChangeEvent | null; constructor (cfg?: object) { @@ -383,7 +433,7 @@ export class RemoteStorage { this.addEvents([ 'ready', 'authing', 'connecting', 'connected', 'disconnected', 'not-connected', 'conflict', 'error', 'features-loaded', - 'sync-interval-change', 'sync-started', 'sync-req-done', 'sync-done', + 'scope-change-required', 'sync-interval-change', 'sync-started', 'sync-req-done', 'sync-done', 'wire-busy', 'wire-done', 'network-offline', 'network-online' ]); @@ -407,10 +457,22 @@ export class RemoteStorage { } } - // Keep a reference to the orginal `on` function + this._authorizedScope = this._loadAuthorizedScope(); + this._scopeChangeRequired = false; + this._scopeChangeEvent = null; + + // Keep a reference to the original `on` function const origOn = this.on; this.on = function (eventName: string, handler: Function): void { + const registration = origOn.call(this, eventName, handler); + + if (eventName === 'scope-change-required' && this._scopeChangeRequired && this._scopeChangeEvent) { + setTimeout(() => { + handler(this._scopeChangeEvent); + }, 0); + } + if (this._allLoaded) { // check if the handler should be called immediately, because the // event has happened already @@ -436,7 +498,7 @@ export class RemoteStorage { } } - return origOn.call(this, eventName, handler); + return registration; }; // load all features and emit `ready` @@ -461,6 +523,10 @@ export class RemoteStorage { return this.remote.connected; } + get scopeChangeRequired (): boolean { + return this._scopeChangeRequired; + } + static SyncError = SyncError; static Unauthorized = UnauthorizedError; static DiscoveryError = Discover.DiscoveryError; @@ -486,7 +552,7 @@ export class RemoteStorage { options.scope = this.access.scopeParameter; } - if (globalContext.cordova) { + if (globalContext.cordova && typeof config.cordovaRedirectUri === 'string') { options.redirectUri = config.cordovaRedirectUri; } else { const location = Authorize.getLocation(); @@ -592,6 +658,7 @@ export class RemoteStorage { // Token supplied directly by app/developer/user log('Skipping authorization sequence and connecting with known token'); this.remote.configure({ token: token }); + this._rememberAuthorizedScope(this.access.scopeParameter); } else { throw new Error("Supplied bearer token must be a string"); } @@ -625,6 +692,13 @@ export class RemoteStorage { } } + /** + * Alias for {@link reconnect}, intended for permission refresh flows. + */ + reauthorize (): void { + this.reconnect(); + } + /** * "Disconnect" from remote server to terminate current session. * @@ -641,6 +715,8 @@ export class RemoteStorage { properties: null }); } + this._forgetPendingScope(); + this._rememberAuthorizedScope(null); this._setGPD({ get: this._pendingGPD('get'), put: this._pendingGPD('put'), @@ -685,6 +761,127 @@ export class RemoteStorage { localStorage.removeItem('remotestorage:backend'); } } + + this._authorizedScope = this._loadAuthorizedScope(); + this._checkScopeChange(); + } + + _rememberPendingScope (scope?: string): void { + const normalizedScope = normalizeScope(scope); + + if (!hasLocalStorage) { + return; + } + + if (!normalizedScope || !this.backend) { + localStorage.removeItem(PENDING_SCOPE_KEY); + return; + } + + localStorage.setItem(PENDING_SCOPE_KEY, JSON.stringify({ + backend: this.backend, + scope: normalizedScope + })); + } + + _forgetPendingScope (): void { + if (hasLocalStorage) { + localStorage.removeItem(PENDING_SCOPE_KEY); + } + } + + _rememberAuthorizedScope (scope?: string): void { + const normalizedScope = normalizeScope(scope); + + if (!hasLocalStorage) { + this._authorizedScope = normalizedScope; + this._checkScopeChange(); + return; + } + + if (!normalizedScope || !this.backend) { + localStorage.removeItem(AUTHORIZED_SCOPE_KEY); + this._authorizedScope = null; + this._checkScopeChange(); + return; + } + + localStorage.setItem(AUTHORIZED_SCOPE_KEY, JSON.stringify({ + backend: this.backend, + scope: normalizedScope + })); + this._authorizedScope = normalizedScope; + this._checkScopeChange(); + } + + _completeAuthorization (scope?: string): void { + const normalizedScope = this._loadPendingScope() || normalizeScope(scope); + this._forgetPendingScope(); + + if (normalizedScope) { + this._rememberAuthorizedScope(normalizedScope); + } else { + this._checkScopeChange(); + } + } + + _checkScopeChange (): void { + const requestedScope = normalizeScope(this.access.scopeParameter); + const authorizedScope = this._authorizedScope || this._loadAuthorizedScope(); + const scopeChangeRequired = !!(requestedScope && authorizedScope && requestedScope !== authorizedScope); + const shouldEmit = scopeChangeRequired && ( + !this._scopeChangeRequired || + !this._scopeChangeEvent || + this._scopeChangeEvent.requestedScope !== requestedScope || + this._scopeChangeEvent.authorizedScope !== authorizedScope + ); + + this._scopeChangeRequired = scopeChangeRequired; + + if (scopeChangeRequired) { + this._scopeChangeEvent = this._buildScopeChangeEvent(requestedScope, authorizedScope); + if (shouldEmit) { + this._emit('scope-change-required', this._scopeChangeEvent); + } + } else { + this._scopeChangeEvent = null; + } + } + + private _loadAuthorizedScope (): string | null { + if (!hasLocalStorage || !this.backend) { + return null; + } + + const settings = readStoredScopeSettings(AUTHORIZED_SCOPE_KEY); + + if (!settings || settings.backend !== this.backend) { + return null; + } + + return normalizeScope(settings.scope); + } + + private _loadPendingScope (): string | null { + if (!hasLocalStorage || !this.backend) { + return null; + } + + const settings = readStoredScopeSettings(PENDING_SCOPE_KEY); + + if (!settings || settings.backend !== this.backend) { + return null; + } + + return normalizeScope(settings.scope); + } + + private _buildScopeChangeEvent (requestedScope = normalizeScope(this.access.scopeParameter), authorizedScope = this._authorizedScope): ScopeChangeEvent { + return { + requestedScope: requestedScope || '', + authorizedScope: authorizedScope || '', + reauthorize: this.reauthorize.bind(this) + }; } /** @@ -1241,7 +1438,7 @@ export class RemoteStorage { Object.defineProperty(RemoteStorage.prototype, 'access', { configurable: true, get: function() { - const access = new Access(); + const access = new Access(this); Object.defineProperty(this, 'access', { value: access }); return access; }, diff --git a/test/unit/authorize.test.mjs b/test/unit/authorize.test.mjs index 47cfa697d..9884800ab 100644 --- a/test/unit/authorize.test.mjs +++ b/test/unit/authorize.test.mjs @@ -16,6 +16,8 @@ chai.use(chaiAsPromised); const WIRECLIENT_SETTINGS_KEY = 'remotestorage:wireclient'; const DISCOVER_SETTINGS_KEY = 'remotestorage:discover'; +const AUTHORIZED_SCOPE_KEY = 'remotestorage:authorized-scope'; +const PENDING_SCOPE_KEY = 'remotestorage:pending-scope'; const AUTH_URL = 'https://example.com/oauth2/authorize'; const TOKEN_URL = 'https://example.com/oauth2/token'; const REFRESH_TOKEN = '7-_IbSBsp5wAAA'; @@ -72,6 +74,10 @@ describe("Authorize", () => { const expectedUrl = AUTH_URL + '?redirect_uri=https%3A%2F%2Fnote.app.com%2F&scope=notes%3Arw&client_id=opaque&state=CSRF-protection&response_type=token&code_challenge=ABCDEFGHI&code_challenge_method=plain'; expect(document.location.href).to.equal(expectedUrl); + expect(JSON.parse(localStorage.getItem(PENDING_SCOPE_KEY))).to.deep.equal({ + backend: 'remotestorage', + scope: 'notes:rw' + }); }); }); @@ -149,7 +155,11 @@ describe("Authorize", () => { mockRemote._emit = () => {}; const configureSpy = sinon.spy(mockRemote, 'configure'); rs.remote = mockRemote; - rs.setBackend(undefined); + rs.setBackend('remotestorage'); + localStorage.setItem(PENDING_SCOPE_KEY, JSON.stringify({ + backend: 'remotestorage', + scope: 'documents:r notes:rw' + })); rs._handlers['features-loaded'][0](); @@ -173,6 +183,11 @@ describe("Authorize", () => { token: newAccessToken, tokenType: 'bearer' }); + expect(JSON.parse(localStorage.getItem(AUTHORIZED_SCOPE_KEY))).to.deep.equal({ + backend: 'remotestorage', + scope: 'documents:r notes:rw' + }); + expect(localStorage.getItem(PENDING_SCOPE_KEY)).to.be.null; expect(sessionStorage.getItem('remotestorage:codeVerifier')).to.be.null; }); }); diff --git a/test/unit/remotestorage.test.mjs b/test/unit/remotestorage.test.mjs index c312386e3..8516a9a2f 100644 --- a/test/unit/remotestorage.test.mjs +++ b/test/unit/remotestorage.test.mjs @@ -8,9 +8,13 @@ import Dropbox from '../../build/dropbox.js'; import { EventHandling } from '../../build/eventhandling.js'; import { RemoteStorage } from '../../build/remotestorage.js'; import { applyMixins } from '../../build/util.js'; +import { localStorage } from '../helpers/memoryStorage.mjs'; chai.use(chaiAsPromised); +const AUTHORIZED_SCOPE_KEY = 'remotestorage:authorized-scope'; +const WIRECLIENT_SETTINGS_KEY = 'remotestorage:wireclient'; + class FakeRemote { constructor (connected) { this.fakeRemote = true; @@ -227,4 +231,58 @@ describe("RemoteStorage", function() { expect(() => this.rs.setSyncInterval(3600001)).to.throw(/not a valid sync interval/); }); }); + + describe("#scope-change-required", function() { + beforeEach(function() { + this.rs.disconnect(); + localStorage.clear(); + + localStorage.setItem('remotestorage:backend', 'remotestorage'); + localStorage.setItem(WIRECLIENT_SETTINGS_KEY, JSON.stringify({ + userAddress: 'user@example.com', + href: 'https://storage.example.com/users/user/', + storageApi: 'draft-dejong-remotestorage-02', + token: 'sekrit' + })); + }); + + it("emits a sticky event when claimed scope differs from the stored authorized scope", function(done) { + localStorage.setItem(AUTHORIZED_SCOPE_KEY, JSON.stringify({ + backend: 'remotestorage', + scope: 'contacts:rw' + })); + + this.rs = new RemoteStorage({ cache: false }); + this.rs.access.claim('contacts', 'r'); + + setTimeout(() => { + this.rs.on('scope-change-required', (event) => { + expect(event.authorizedScope).to.equal('contacts:rw'); + expect(event.requestedScope).to.equal('contacts:r'); + expect(this.rs.scopeChangeRequired).to.equal(true); + done(); + }); + }, 0); + }); + + it("clears the pending scope-change state after authorization completes with the current scope", function() { + localStorage.setItem(AUTHORIZED_SCOPE_KEY, JSON.stringify({ + backend: 'remotestorage', + scope: 'contacts:rw' + })); + + this.rs = new RemoteStorage({ cache: false }); + this.rs.access.claim('contacts', 'r'); + + expect(this.rs.scopeChangeRequired).to.equal(true); + + this.rs._completeAuthorization('contacts:r'); + + expect(this.rs.scopeChangeRequired).to.equal(false); + expect(JSON.parse(localStorage.getItem(AUTHORIZED_SCOPE_KEY))).to.deep.equal({ + backend: 'remotestorage', + scope: 'contacts:r' + }); + }); + }); });