openvibers/openvibe.watch Commits

Watch: accept a person's Network session token on the API (#153)

OP OpenVibers committed 12 days ago · 0e7ae9e822 · 4 files, +52 −14

* Watch: accept a person's Network session token on the API

* A person token owns watches only under a canonical usr_/agt_ id: a session token naming a bare account number is refused (review of #153)

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex 6e4cbc5..0c12305 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -3,6 +3,14 @@
What shipped on OpenVibe.Watch, newest first. The plan is T18 ("Watch complete"); each entry names
the step it landed.
++## 2026-10-31 — a person's Network session token on the API
++
++- The watch API accepts a signed-in person's Network session token (`aud: openvibe.network`) as a Bearer, the same
++ token the site's SSO verifies, as well as a person/agent token minted for `openvibe.watch`. A first-party service
++ such as Shoes can therefore create a watch with the person's own token instead of a service token. The watch is
++ owned by the token's canonical `usr_…`/`agt_…` `subject_id` (a Network session token's `sub` is the numeric
++ account id), so project delegation and the person-only actions keep working. `server/auth.js`, `test/api.test.js`.
++
## 2026-10-13 — check-operation usage readings (plan T5 step 14, Watch slice)
- A completed check queues one `platform.usage-sample@1` reading (metric `check-operation`, quantity 1, unit `check`,
ddiff --git a/README.md b/README.md
iindex 4983b5b..689965f 100644
---- a/README.md
++++ b/README.md
@@@ -313,11 +313,12 @@ retention.
## API (bearer tokens, one capability per route)
--Callers present an OpenVibe.Network token for audience `openvibe.watch`: a service's
--client-credentials token (judged by its `cap` claim with the contracts grant rule) or a signed-in
--person's or agent's token (`usr_…`, `agt_…` — a watch is theirs, and ownership scoping is the
--authority). A first-party service (`svc:…`) may present `X-OV-Subject: usr_…` to act for a person;
--an app or module may not.
++Callers present an OpenVibe.Network token: a service's client-credentials token for audience
++`openvibe.watch` (judged by its `cap` claim with the contracts grant rule), or a signed-in person's
++or agent's token — either a token minted for `openvibe.watch` or the Network session token the
++signed-in site holds (`aud: openvibe.network`); a watch is theirs, owned by the canonical
++`usr_…`/`agt_…` `subject_id`, and ownership scoping is the authority. A first-party service
++(`svc:…`) may present `X-OV-Subject: usr_…` to act for a person; an app or module may not.
| Capability | Routes |
|---|---|
ddiff --git a/server/auth.js b/server/auth.js
iindex 6999199..d0680d1 100644
---- a/server/auth.js
++++ b/server/auth.js
@@@ -11,11 +11,15 @@
* watch.* capabilities are released, so the library decides every one of them.
*
* a person the bearer token of a signed-in user (sub usr_…) or agent (sub agt_…), verified
-- * with openvibe-sdk/auth verifyUserToken for the same issuer and audience. Such a
-- * token carries no capability list: watches are user-defined resources and the guard
-- * that matters is ownership — every route scopes to the acting subject, and another
-- * owner's watch answers 404 (registry.js). A person may therefore use all four watch
-- * capabilities, on their own watches only.
++ * with openvibe-sdk/auth verifyUserToken for the same issuer. A token may be minted
++ * for this service's audience (openvibe.watch, as a CLI or app token is) or be the
++ * Network session token a signed-in site holds (openvibe.network); both prove the
++ * same person. The canonical usr_…/agt_… subject_id is the owner (a Network session
++ * token's `sub` is the numeric account id). Such a token carries no capability list:
++ * watches are user-defined resources and the guard that matters is ownership — every
++ * route scopes to the acting subject, and another owner's watch answers 404
++ * (registry.js). A person may therefore use all four watch capabilities, on their
++ * own watches only.
*
* X-OV-Subject, presented by a first-party service (svc:…) that acts for a person (the public site
* going through Network/Services, or an agent's runtime), names the subject a route acts for; the
@@@ -68,8 +72,10 @@ function createAuth({ config, log = console }) {
/** { ok, claims } or { ok: false, code, reason, status }. Every token rule is the SDK's verifyUserToken. */
async function verifyUser(token) {
++ // A person's token is minted either for this service or by Network sign-in (aud openvibe.network).
++ const audience = [...new Set([config.audience, config.oauth.sessionAudience].filter(Boolean))];
try {
-- const claims = await verifyUserToken(token, { jwks: config.jwksUrl, issuer: config.issuer, audience: config.audience });
++ const claims = await verifyUserToken(token, { jwks: config.jwksUrl, issuer: config.issuer, audience });
return { ok: true, claims };
} catch (err) {
const code = (err && err.code) || 'token.malformed';
@@@ -89,9 +95,11 @@ function createAuth({ config, log = console }) {
if (!h.startsWith('Bearer ')) return http.sendProblem(res, 401, 'token.missing', { detail: 'a service or user token is required', ctx });
const token = h.slice(7).trim();
const payload = tokenPayload(token);
-- if (!payload || typeof payload.sub !== 'string') return http.sendProblem(res, 401, 'token.malformed', { detail: 'not a signed JWT', ctx });
++ // A service principal's sub is svc:…/app:…/mod:…; a Network session token's is the numeric account id,
++ // and its canonical usr_…/agt_… subject_id is the person. Anything else is not a token this API knows.
++ if (!payload || (typeof payload.sub !== 'string' && typeof payload.sub !== 'number')) return http.sendProblem(res, 401, 'token.malformed', { detail: 'not a signed JWT', ctx });
-- if (SERVICE_SUB.test(payload.sub)) {
++ if (typeof payload.sub === 'string' && SERVICE_SUB.test(payload.sub)) {
// The keys come from the SDK's JWKS client (one per URL, started at boot: the last good keys through a
// Network outage, a rotation honoured on an unknown kid); every token rule is openvibe-contracts'
// verifyServiceToken (identity.service-token-claims@1, sandbox refused, issuer, audience, expiry).
@@@ -108,7 +116,12 @@ function createAuth({ config, log = console }) {
const r = await verifyUser(token);
if (!r.ok) return http.sendProblem(res, r.status === 503 ? 503 : 401, r.code, { detail: r.reason, ctx });
const claims = r.claims;
-- req.principal = { sub: claims.sub, kind: 'person', role: claims.role || null, cap: null, jti: claims.jti || null, subject: null };
++ // Network session tokens name the account in `sub` (numeric) and the person in `subject_id`.
++ const subject = PERSON_SUB.test(String(claims.subject_id || '')) ? String(claims.subject_id) : String(claims.sub);
++ // The owner is always a canonical usr_…/agt_… id: a token naming only a bare account number (or anything
++ // else) owns nothing here, so no watch is ever filed under a name another sign-in could also carry.
++ if (!PERSON_SUB.test(subject)) return http.sendProblem(res, 401, 'token.subject_unknown', { detail: 'the token names no usr_… or agt_… subject', ctx });
++ req.principal = { sub: subject, kind: 'person', role: claims.role || null, cap: null, jti: claims.jti || null, subject: null };
}
// X-OV-Subject: only a first-party service may name the person it acts for, and only a
ddiff --git a/test/api.test.js b/test/api.test.js
iindex 01d9396..4a3ca9b 100644
---- a/test/api.test.js
++++ b/test/api.test.js
@@@ -53,6 +53,22 @@ t('every route needs a token, and the one capability it serves', async () => {
assert.strictEqual((await api('GET', `/api/v1/watches/${ghost}/checks`, { token: OBSERVER })).status, 404);
});
++t('a person\'s Network session token works, and owns the watch by its canonical subject', async () => {
++ const who = newUser();
++ // A Network sign-in token: aud openvibe.network, `sub` the numeric account id, `subject_id` the usr_ subject.
++ const session = userToken(who, { aud: 'openvibe.network', extra: { sub: 4242 } });
++ const created = await api('POST', '/api/v1/watches', { token: session, body: mine({ name: 'a session-token watch' }) });
++ assert.strictEqual(created.status, 201, created.text);
++ assert.strictEqual(created.body.watch.owner, who, 'the canonical usr_ subject is the owner, not the numeric account id');
++ const list = await api('GET', '/api/v1/watches', { token: session });
++ assert.ok(list.body.watches.some((w) => w.id === created.body.watch.id), 'the watch is listed for that session');
++ assert.strictEqual((await api('GET', `/api/v1/watches/${created.body.watch.id}`, { token: userToken(other, { aud: 'openvibe.network' }) })).status, 404, 'another person cannot read it');
++ assert.strictEqual((await api('GET', '/api/v1/watches', { token: userToken(who, { aud: 'openvibe.deals' }) })).status, 401, 'a token for another service is still refused');
++ const bare = userToken(who, { aud: 'openvibe.network', extra: { sub: 4242, subject_id: null } });
++ const refused = await api('GET', '/api/v1/watches', { token: bare });
++ assert.strictEqual(refused.status, 401, 'a session token with no usr_ subject owns nothing');
++});
++
t('create, read, list: the released contracts on both sides, and the view is the contract', async () => {
const created = await api('POST', '/api/v1/watches', { token: ME, body: mine({ labels: { room: 'kitchen' } }) });
assert.strictEqual(created.status, 201, created.text);