diff --git a/CHANGELOG.md b/CHANGELOG.md index 2ccb085..7b72910 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,96 @@ +## [3.5.0] + +Narrows the auth-flow result surface and introduces a new +`pryv.connectFromKey(key, serviceInfoUrl)` helper. Calling apps that +read `state.apiEndpoint` from the `onStateChange` callback on a fresh +auth-flow result must migrate to the new helper; cookie-autologin +keeps working as before. + +### Changed +- `onStateChange(state)` on `state.status === AUTHORIZED` reached + through a **fresh** auth-flow poll (poll → ACCEPTED) now receives + only `{ status, id, key, serviceInfo? }`. Credentials (`username`, + `token`, `apiEndpoint`) stay inside the lib. +- Cookie-autologin AUTHORIZED states (page reload after a prior sign-in) + still carry `apiEndpoint`/`username` so existing pages that build a + Connection on autologin keep working — autologin happens before any + fresh `key` exists. + +### Added +- `pryv.connectFromKey(key, serviceInfoUrl, serviceCustomizations?)` — + module-level helper that builds a `Service`, fetches its info, and + resolves a completed-auth `key` into a `Connection`. +- `Service#connectFromKey(key)` — instance variant for callers that + already hold a `Service`. +- `examples/cli-login.js` — headless polling pattern reference (Node). + Pairs with the auth UI's new `?cli=1` query flag, which renders a + terminal "you can close this window" screen instead of trying to + close a popup or redirect. + +### Migration + +For consumers that today do `new pryv.Connection(state.apiEndpoint)` on +the fresh auth-flow path: + +Before: +```js +onStateChange: (state) => { + if (state.id === pryv.Auth.AuthStates.AUTHORIZED) { + const connection = new pryv.Connection(state.apiEndpoint); + } +} +``` + +After: +```js +onStateChange: async (state) => { + if (state.id === pryv.Auth.AuthStates.AUTHORIZED) { + const connection = state.key + ? await pryv.connectFromKey(state.key, serviceInfoUrl) + : new pryv.Connection(state.apiEndpoint); // cookie-autologin path + } +} +``` + +### Added +- `pryv.connectFromKey(key, serviceInfoUrl, serviceCustomizations?)` — + module-level convenience that builds a `Service`, fetches its info, + and resolves a completed-auth `key` into a `Connection`. +- `Service#connectFromKey(key)` — instance variant for callers that + already hold a `Service`. +- `examples/cli-login.js` — headless polling pattern reference (Node). + Pairs with the auth UI's new `?cli=1` query flag, which renders a + terminal "you can close this window" screen instead of trying to + close a popup or redirect. + +### Migration +Before (`3.x`): +```js +const authSettings = { + onStateChange: (state) => { + if (state.id === pryv.Auth.AuthStates.AUTHORIZED) { + const connection = new pryv.Connection(state.apiEndpoint); + } + }, + ... +}; +``` + +After (`4.0`): +```js +const authSettings = { + onStateChange: async (state) => { + if (state.id === pryv.Auth.AuthStates.AUTHORIZED) { + const connection = await pryv.connectFromKey(state.key, serviceInfoUrl); + } + }, + ... +}; +``` + ## [3.4.1] Lockstep patch of `pryv@3.4.1` + `@pryv/monitor@3.4.1` + diff --git a/components/pryv-monitor/package.json b/components/pryv-monitor/package.json index 47cb066..687cefa 100644 --- a/components/pryv-monitor/package.json +++ b/components/pryv-monitor/package.json @@ -1,6 +1,6 @@ { "name": "@pryv/monitor", - "version": "3.4.1", + "version": "3.5.0", "description": "Extends `pryv` with event-driven notifications for changes on a Pryv.io account", "keywords": [ "Pryv", diff --git a/components/pryv-socket.io/package.json b/components/pryv-socket.io/package.json index 4771ecb..09330b8 100644 --- a/components/pryv-socket.io/package.json +++ b/components/pryv-socket.io/package.json @@ -1,6 +1,6 @@ { "name": "@pryv/socket.io", - "version": "3.4.1", + "version": "3.5.0", "description": "Extends `pryv` with Socket.IO transport", "keywords": [ "Pryv", diff --git a/components/pryv/package.json b/components/pryv/package.json index 6507407..c0e4fe3 100644 --- a/components/pryv/package.json +++ b/components/pryv/package.json @@ -1,6 +1,6 @@ { "name": "pryv", - "version": "3.4.1", + "version": "3.5.0", "description": "Pryv JavaScript library", "keywords": [ "Pryv", diff --git a/components/pryv/src/Auth/AuthController.js b/components/pryv/src/Auth/AuthController.js index 747c459..d0c5100 100644 --- a/components/pryv/src/Auth/AuthController.js +++ b/components/pryv/src/Auth/AuthController.js @@ -22,8 +22,15 @@ class AuthController { validateSettings.call(this, settings); this.stateChangeListeners = []; + // External `onStateChange` callers only see `{ status, id, key, serviceInfo? }` + // on AUTHORIZED — credentials (`username`, `token`, `apiEndpoint`) stay + // inside the lib. Internal listeners (e.g. LoginButton, for cookie + // autologin) get the full unfiltered state. if (this.settings.onStateChange) { - this.stateChangeListeners.push(this.settings.onStateChange); + const externalListener = this.settings.onStateChange; + this.stateChangeListeners.push(function (state) { + externalListener(filterForExternalListener(state)); + }); } this.service = service; @@ -166,6 +173,10 @@ class AuthController { async startAuthRequest () { // @ts-ignore - postAccess uses .call(this) for context this.state = await postAccess.call(this); + // Remember the polling key so listeners on the terminal AUTHORIZED + // state can be handed `{ key, serviceInfo? }` (the polling response + // itself doesn't echo `key` back). + this._authFlowKey = this.state?.key; await doPolling.call(this); @@ -205,6 +216,11 @@ class AuthController { // @ts-ignore - this is bound via .call() setTimeout(await doPolling.bind(this), this.state?.poll_rate_ms); } else { + // Carry the key forward — listeners on the narrow public surface + // need it, and the server doesn't echo it back on ACCEPTED. + if (this._authFlowKey != null && pollResponse.key == null) { + pollResponse.key = this._authFlowKey; + } this.state = pollResponse; } @@ -245,6 +261,38 @@ class AuthController { // ----------- private methods ------------- +/** + * Narrow the state passed to *external* `onStateChange` callers so the + * calling app sees only `{ status, id, key, serviceInfo? }` on the + * terminal AUTHORIZED state reached through the auth-flow polling path. + * `username` / `token` / `apiEndpoint` are kept inside the lib; the + * calling app uses `pryv.connectFromKey(key, serviceInfoUrl)` to obtain + * a `Connection`. + * + * The cookie-autologin path (no fresh `key` available, restored from + * `LoginButton.getAuthorizationData()`) passes through unchanged so + * existing pages that build a `Connection` directly from the restored + * state on page load keep working. + * + * Non-AUTHORIZED states pass through unchanged so error messages / + * loading flags / etc. still reach the listener. + * + * @param {Object} state - full internal state + * @returns {Object} narrowed state + */ +function filterForExternalListener (state) { + if (state == null || state.status !== AuthStates.AUTHORIZED) { + return state; + } + // No key → cookie-autologin path; preserve existing shape. + if (state.key == null) { + return state; + } + const out = { status: state.status, id: state.id, key: state.key }; + if (state.serviceInfo != null) out.serviceInfo = state.serviceInfo; + return out; +} + async function checkAutoLogin (authController) { const loginButton = authController.loginButton; if (loginButton == null) { diff --git a/components/pryv/src/Service.js b/components/pryv/src/Service.js index e8248a8..acaea71 100644 --- a/components/pryv/src/Service.js +++ b/components/pryv/src/Service.js @@ -521,6 +521,40 @@ class Service { return body; } + /** + * Resolve a completed auth-flow polling key into a `Connection`. + * + * Pairs with `Service.startAccessRequest` / `Service.pollAccessRequest` + * and the headless polling pattern: the calling app holds only the + * `key` returned by the auth-flow (not the underlying token / + * apiEndpoint), and uses this method to build a working `Connection`. + * + * The implementation polls `/` once; the call MUST be + * made while the access is still in the ACCEPTED state (which + * persists until expiry — see `expireAfter` on the access request). + * + * @param {string} key - polling key from `startAccessRequest` + * @returns {Promise} + * @throws {PryvError} if the key is not ACCEPTED (NEED_SIGNIN, REFUSED, ERROR) + */ + async connectFromKey (key) { + if (!key) { + throw new PryvError('connectFromKey requires a key'); + } + const body = await this.pollAccessRequest(key); + if (body.status !== 'ACCEPTED') { + throw new PryvError( + 'connectFromKey: access is not ACCEPTED (status=' + body.status + ')' + ); + } + if (!body.apiEndpoint) { + throw new PryvError( + 'connectFromKey: ACCEPTED response missing apiEndpoint' + ); + } + return new Connection(body.apiEndpoint, this); + } + /** * Set a new password using a reset token (from the reset email). * Pre-auth — no login token required. diff --git a/components/pryv/src/index.js b/components/pryv/src/index.js index 87fbe7b..20d156a 100644 --- a/components/pryv/src/index.js +++ b/components/pryv/src/index.js @@ -14,8 +14,10 @@ * @property {pryv.StaleAccessIdError} StaleAccessIdError - Thrown when a Pryv.io server rejects an `accesses.update` / `accesses.delete` with a 409 stale-resource. Refetch + retry. * @property {Object} ERRORS - Catalogue of Pryv API error ids (mirrors open-pryv.io/components/errors) */ +const Service = require('./Service'); + module.exports = { - Service: require('./Service'), + Service, Connection: require('./Connection'), Auth: require('./Auth'), Browser: require('./Browser'), @@ -24,5 +26,25 @@ module.exports = { MfaRequiredError: require('./lib/MfaRequiredError'), StaleAccessIdError: require('./lib/StaleAccessIdError'), ERRORS: require('./lib/errorIds'), - version: require('../package.json').version + version: require('../package.json').version, + connectFromKey }; + +/** + * Module-level convenience over `Service#connectFromKey` — builds a + * `Service` on the fly, fetches its info, and resolves the given + * auth-flow polling key into a working `Connection`. + * + * Mirrors the `pryv.connectFromKey(key, serviceInfoUrl)` shape the + * headless polling pattern documents. + * + * @param {string} key - polling key from `Service.startAccessRequest` + * @param {string} serviceInfoUrl - URL of the platform's `/service/info` + * @param {Object} [serviceCustomizations] - same shape as `new Service(url, customizations)` + * @returns {Promise} + */ +async function connectFromKey (key, serviceInfoUrl, serviceCustomizations) { + const service = new Service(serviceInfoUrl, serviceCustomizations); + await service.info(); + return service.connectFromKey(key); +} diff --git a/components/pryv/test/AuthController.test.js b/components/pryv/test/AuthController.test.js index 824304f..9f7f5a1 100644 --- a/components/pryv/test/AuthController.test.js +++ b/components/pryv/test/AuthController.test.js @@ -190,4 +190,78 @@ describe('[ACNX] AuthController', function () { expect(auth.state.id).to.equal(AuthStates.LOADING); // retro-compatibility }); }); + + describe('[AFLT] External listener filtering', function () { + it('[AFLA] AUTHORIZED state passes only { status, id, key, serviceInfo } to onStateChange', function () { + const seen = []; + const auth = new AuthController({ + authRequest: { + requestingAppId: 'test-app', + requestedPermissions: [] + }, + onStateChange: (state) => seen.push(state) + }, service); + + auth.state = { + status: AuthStates.AUTHORIZED, + key: 'abc123', + serviceInfo: { name: 'Test' }, + apiEndpoint: 'https://token@example.com/', + username: 'alice', + token: 'secret-token' + }; + + const last = seen[seen.length - 1]; + expect(last.status).to.equal(AuthStates.AUTHORIZED); + expect(last.id).to.equal(AuthStates.AUTHORIZED); + expect(last.key).to.equal('abc123'); + expect(last.serviceInfo).to.deep.equal({ name: 'Test' }); + // Credentials must not leak to the calling app. + expect(last.apiEndpoint).to.equal(undefined); + expect(last.username).to.equal(undefined); + expect(last.token).to.equal(undefined); + }); + + it('[AFLC] AUTHORIZED state from cookie-autologin (no key) passes through unchanged', function () { + // Mirrors `checkAutoLogin()`'s state shape: it spreads stored + // credentials into the AUTHORIZED state without a key, since the + // key is not persisted. + const seen = []; + const auth = new AuthController({ + authRequest: { + requestingAppId: 'test-app', + requestedPermissions: [] + }, + onStateChange: (state) => seen.push(state) + }, service); + + auth.state = { + status: AuthStates.AUTHORIZED, + apiEndpoint: 'https://token@example.com/', + username: 'alice' + }; + + const last = seen[seen.length - 1]; + // Backwards-compat: existing pages building Connection directly + // from `state.apiEndpoint` on page reload keep working. + expect(last.apiEndpoint).to.equal('https://token@example.com/'); + expect(last.username).to.equal('alice'); + }); + + it('[AFLB] non-AUTHORIZED states pass through unchanged', function () { + const seen = []; + const auth = new AuthController({ + authRequest: { + requestingAppId: 'test-app', + requestedPermissions: [] + }, + onStateChange: (state) => seen.push(state) + }, service); + + auth.state = { status: AuthStates.ERROR, message: 'boom', error: new Error('e') }; + const last = seen[seen.length - 1]; + expect(last.status).to.equal(AuthStates.ERROR); + expect(last.message).to.equal('boom'); + }); + }); }); diff --git a/components/pryv/test/Service.accessRequest.test.js b/components/pryv/test/Service.accessRequest.test.js index 0e8cf3b..c26f2d8 100644 --- a/components/pryv/test/Service.accessRequest.test.js +++ b/components/pryv/test/Service.accessRequest.test.js @@ -38,6 +38,30 @@ describe('[ARQX] Service access-request init', function () { }); }); + describe('[CFKX] Service.connectFromKey', function () { + it('[CFKA] rejects when key is missing', async function () { + let caught; + try { await service.connectFromKey(); } catch (e) { caught = e; } + expect(caught).to.be.instanceOf(pryv.PryvError); + }); + + it('[CFKB] throws when the access is still NEED_SIGNIN', async function () { + this.timeout(15000); + const env = await service.startAccessRequest({ + requestingAppId: 'jslib-test', + requestedPermissions: [{ + streamId: 'data', + level: 'read', + defaultName: 'Test' + }] + }); + let caught; + try { await service.connectFromKey(env.key); } catch (e) { caught = e; } + expect(caught).to.be.instanceOf(pryv.PryvError); + expect(caught.message).to.include('NEED_SIGNIN'); + }); + }); + describe('[APRX] Service.pollAccessRequest', function () { it('[APRA] rejects when key is missing', async function () { let caught; diff --git a/examples/cli-login.js b/examples/cli-login.js new file mode 100644 index 0000000..8af36c4 --- /dev/null +++ b/examples/cli-login.js @@ -0,0 +1,102 @@ +/** + * @license + * [BSD-3-Clause](https://github.com/pryv/lib-js/blob/master/LICENSE) + * + * Headless (CLI) login pattern — pattern reference. + * + * Run with: `node examples/cli-login.js [serviceInfoUrl]` + * + * This script demonstrates how a non-browser caller (CLI, daemon, bot) can + * drive the Pryv access-request flow without a popup or iframe. The user + * still authorises in a browser tab they open themselves; this process + * polls until the request is accepted or refused. + * + * The script uses only public `Service` methods: + * - `service.startAccessRequest(authRequest)` to issue the request + * - `service.pollAccessRequest(key)` to poll until completion + * + * It is intentionally dependency-free (no `open` package, no extra prompts). + * Adapters that want a richer UX layer their own niceties on top. + */ + +'use strict'; + +const pryv = require('../components/pryv/src'); + +const DEFAULT_SERVICE_INFO_URL = 'https://reg.pryv.me/service/info'; +const APP_ID = 'lib-js-cli-login-example'; + +async function main () { + const serviceInfoUrl = process.argv[2] || DEFAULT_SERVICE_INFO_URL; + + console.log(`Resolving service info from ${serviceInfoUrl} ...`); + const service = new pryv.Service(serviceInfoUrl); + await service.info(); + + const { key, authUrl, pollRateMs } = await service.startAccessRequest({ + requestingAppId: APP_ID, + requestedPermissions: [ + { streamId: 'diary', defaultName: 'Diary', level: 'manage' } + ] + }); + + // The `cli` flag tells the auth UI to render a terminal "you can close + // this window" screen on success instead of trying to redirect or close + // a popup that the CLI never opened. + const authUrlForCli = appendCliFlag(authUrl); + + console.log(); + console.log('Open the following URL in your browser to authorise:'); + console.log(); + console.log(' ' + authUrlForCli); + console.log(); + console.log(`Polling key ${key} every ${pollRateMs}ms ...`); + + const terminalStatus = await waitForTerminalStatus(service, key, pollRateMs); + + if (terminalStatus === 'ACCEPTED') { + // Strict surface: the caller resolves the key to a Connection via + // `service.connectFromKey(key)`. The `apiEndpoint` / `token` stay + // inside the lib. + const connection = await service.connectFromKey(key); + const [eventsRes] = await connection.api([ + { method: 'events.get', params: { limit: 1 } } + ]); + const count = (eventsRes && eventsRes.events) ? eventsRes.events.length : 0; + console.log(`Logged in. Sample events.get returned ${count} event(s).`); + process.exit(0); + } + + if (terminalStatus === 'REFUSED') { + console.error('Access refused by the user.'); + process.exit(2); + } + + console.error('Unexpected terminal state: ' + terminalStatus); + process.exit(3); +} + +async function waitForTerminalStatus (service, key, pollRateMs) { + for (;;) { + const body = await service.pollAccessRequest(key); + if (body.status === 'NEED_SIGNIN') { + await sleep(pollRateMs); + continue; + } + return body.status; + } +} + +function sleep (ms) { + return new Promise(resolve => setTimeout(resolve, ms)); +} + +function appendCliFlag (url) { + if (!url) return url; + return url + (url.includes('?') ? '&' : '?') + 'cli=1'; +} + +main().catch(err => { + console.error(err); + process.exit(1); +}); diff --git a/examples/index.html b/examples/index.html index 09c2a4d..a007ea6 100644 --- a/examples/index.html +++ b/examples/index.html @@ -87,10 +87,16 @@

Console

(await service.assets()).setAllDefaults(); })(); - function authStateChanged(state) { + async function authStateChanged(state) { console.log('# Auth state changed:', state); if (state.id === pryv.Auth.AuthStates.AUTHORIZED) { - connection = new pryv.Connection(state.apiEndpoint); + // Fresh auth-flow result carries only `state.key` — resolve it + // to a working Connection. Cookie-autologin still hands the + // listener `state.apiEndpoint` (no key available at restore + // time), so support both paths. + connection = state.key + ? await pryv.connectFromKey(state.key, serviceInfoUrl) + : new pryv.Connection(state.apiEndpoint); logToConsole('# Browser succeeded for user ' + connection.apiEndpoint); } if (state.id === pryv.Auth.AuthStates.SIGNOUT) { diff --git a/examples/monitor.html b/examples/monitor.html index 5509ed3..2118a8b 100644 --- a/examples/monitor.html +++ b/examples/monitor.html @@ -102,10 +102,14 @@

Monitor Events

} }; - function authStateChanged(state) { // called each time the authentication state changed + async function authStateChanged(state) { // called each time the authentication state changed logToConsole('# Auth state changed: ' + state.id); if (state.id === pryv.Browser.AuthStates.AUTHORIZED) { - connection = new pryv.Connection(state.apiEndpoint); + // Fresh auth-flow result carries `state.key`; cookie-autologin + // still carries `state.apiEndpoint`. Support both paths. + connection = state.key + ? await pryv.connectFromKey(state.key, serviceInfoUrl) + : new pryv.Connection(state.apiEndpoint); logToConsole('# Auth succeeded for user ' + connection.apiEndpoint); initializeMonitor(); } diff --git a/examples/socket.io.html b/examples/socket.io.html index a6bf210..5ab6864 100644 --- a/examples/socket.io.html +++ b/examples/socket.io.html @@ -64,10 +64,14 @@

Console

} }; - function authStateChanged(state) { // called each time the authentication state changed + async function authStateChanged(state) { // called each time the authentication state changed logToConsole('# Auth state changed: ' + state.id); if (state.id === pryv.Browser.AuthStates.AUTHORIZED) { - connection = new pryv.Connection(state.apiEndpoint); + // Fresh auth-flow result carries `state.key`; cookie-autologin + // still carries `state.apiEndpoint`. Support both paths. + connection = state.key + ? await pryv.connectFromKey(state.key, serviceInfoUrl) + : new pryv.Connection(state.apiEndpoint); logToConsole('# Auth succeeded for user ' + connection.apiEndpoint); initializeSocket(connection); } diff --git a/package-lock.json b/package-lock.json index 8c461e9..7914f5d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "lib-js", - "version": "3.4.1", + "version": "4.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "lib-js", - "version": "3.4.1", + "version": "4.0.0", "license": "BSD-3-Clause", "workspaces": [ "components/*" diff --git a/package.json b/package.json index 5d5bff4..04c3e87 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "lib-js", - "version": "3.4.1", + "version": "3.5.0", "private": false, "description": "Pryv JavaScript library and add-ons", "keywords": [