Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
90 changes: 90 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,96 @@

<!-- Format based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) -->

## [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` +
Expand Down
2 changes: 1 addition & 1 deletion components/pryv-monitor/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
2 changes: 1 addition & 1 deletion components/pryv-socket.io/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
2 changes: 1 addition & 1 deletion components/pryv/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "pryv",
"version": "3.4.1",
"version": "3.5.0",
"description": "Pryv JavaScript library",
"keywords": [
"Pryv",
Expand Down
50 changes: 49 additions & 1 deletion components/pryv/src/Auth/AuthController.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -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) {
Expand Down
34 changes: 34 additions & 0 deletions components/pryv/src/Service.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<access>/<key>` 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<Connection>}
* @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.
Expand Down
26 changes: 24 additions & 2 deletions components/pryv/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
Expand All @@ -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<import('./Connection')>}
*/
async function connectFromKey (key, serviceInfoUrl, serviceCustomizations) {
const service = new Service(serviceInfoUrl, serviceCustomizations);
await service.info();
return service.connectFromKey(key);
}
74 changes: 74 additions & 0 deletions components/pryv/test/AuthController.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});
});
});
24 changes: 24 additions & 0 deletions components/pryv/test/Service.accessRequest.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading
Loading