openvibers/openvibe.codes Commits
Session cookie accepts only Network session tokens (audience openvibe.network, not FedCM/app tokens)
Changes
@@@ -14,6 +14,7 @@ OV_NETWORK_INTERNAL_URL=http://127.0.0.1:4000OV_OAUTH_CLIENT_ID=codesOV_OAUTH_CLIENT_SECRET=# OV_OAUTH_REDIRECT_URI=https://openvibe.codes/auth/callback++# OV_SESSION_AUDIENCE=openvibe.network # audience a Network session token must carry to be a Codes session# Signs the per-person form token (CSRF). Unset: a random per-process key (forms expire on restart).CODES_FORM_SECRET=@@@ -85,6 +85,18 @@ function createSso({ config, keys, fetchImpl = globalThis.fetch, now = () => Datreturn data;}++ /**++ * A Codes session is a Network SESSION token: audience openvibe.network and a person. The Network++ * signs FedCM assertions (audience = any owned-zone origin that asked), app/service tokens and++ * internal tokens with the same key and issuer; none of them may become a session here.++ */++ function verifySession(token) {++ const v = verifyJwt(token, { publicKey: keys.get(), issuer: config.networkIssuer, audience: config.oauth.sessionAudience, now: now() });++ const c = v.claims;++ if (c && (c.typ === 'fedcm' || c.actor_type !== undefined)) return { ok: false, reason: 'not a session token' };++ return v;++ }++function viewerFromClaims(claims, token) {const subject = typeof claims.subject_id === 'string' ? claims.subject_id : null;const role = typeof claims.role === 'string' ? claims.role : 'user';@@@ -112,7 +124,7 @@ function createSso({ config, keys, fetchImpl = globalThis.fetch, now = () => Dattry {const data = await tokenGrant({ grant_type: 'refresh_token', refresh_token: rt });setSession(res, data);-- const v = verifyJwt(data.access_token, { publicKey: keys.get(), issuer: config.networkIssuer, now: now() });++ const v = verifySession(data.access_token);return v.ok ? viewerFromClaims(v.claims, data.access_token) : null;} catch (err) {if (err.status && err.status < 500) clearSession(res);@@@ -127,7 +139,7 @@ function createSso({ config, keys, fetchImpl = globalThis.fetch, now = () => Datconst token = req.cookies && req.cookies[ACCESS_COOKIE];if (!token) return next();await keys.ensure();-- const v = verifyJwt(token, { publicKey: keys.get(), issuer: config.networkIssuer, now: now() });++ const v = verifySession(token);if (v.ok) { req.viewer = viewerFromClaims(v.claims, token); return next(); }if (v.expired) {const fresh = await refresh(req, res);@@@ -47,6 +47,9 @@ function load(env = process.env) {clientSecret: env.OV_OAUTH_CLIENT_SECRET || '',redirectUri: env.OV_OAUTH_REDIRECT_URI || `${baseUrl}/auth/callback`,scope: 'profile',++ // Network session tokens (what /oauth/token hands Codes) carry this audience; FedCM++ // assertions, app and service tokens signed with the same key do not.++ sessionAudience: env.OV_SESSION_AUDIENCE || 'openvibe.network',},cookies: { secure: env.COOKIE_SECURE ? env.COOKIE_SECURE === 'true' : isProduction },// Signs the per-person form token (CSRF). Unset: a random per-process key.nnew file mode 100644@@@ -0,0 +1,51 @@++'use strict';++/**++ * The codes_at session cookie must hold a Network SESSION token (audience openvibe.network, a++ * person), not just anything the Network signed. The Network signs FedCM assertions (audience =++ * the relying party's origin, any https://*.openvibe.* origin), app/service tokens and internal++ * tokens with the same key and issuer; none of them is a Codes session.++ */++const assert = require('assert');++const { boot, check, done } = require('./helpers/boot');++const { signJwt } = require('./helpers/mocks');++++(async () => {++ const s = await boot();++ const staff = s.network.addUser('stafferson');++ s.ctx.config.staffSubjects.push(staff.subject); // as CODES_STAFF_SUBJECTS would++ const APP = 'app_01JABCDEFGHJKMNPQRSTVWXYZ0';++++ const now = () => Math.floor(Date.now() / 1000);++ // What POST /fedcm/assertion hands to ANY page on an owned zone the person continues on.++ const fedcm = () => signJwt({++ iss: s.network.url, sub: staff.id, id: staff.id, subject_id: staff.subject, username: staff.username, display_name: staff.username,++ nonce: null, typ: 'fedcm', jti: 'x', aud: 'https://tenant.openvibe.host', iat: now(), exp: now() + 300,++ }, s.network.privatePem);++++ await check('a FedCM assertion is not a Codes session (cannot act as staff)', async () => {++ const cookie = `codes_at=${fedcm()}`;++ const page = await s.get('/staff', { cookie });++ assert.notStrictEqual(page.status, 200, 'the staff console must not open for an assertion');++ const body = new URLSearchParams({ app_id: APP, tier: 'first-party', note: 'pwned', csrf: s.csrf(staff) }).toString();++ const r = await s.get('/staff/trust', { cookie, body, headers: { 'content-type': 'application/x-www-form-urlencoded' } });++ assert.notStrictEqual(r.status, 303, 'the trust tier must not be set');++ assert.strictEqual(s.ctx.trust.get(APP).tier, 'unreviewed');++ });++++ await check('an app token is not a Codes session', async () => {++ const token = signJwt({ iss: s.network.url, sub: `app:${APP}`, actor_type: 'app', aud: ['openvibe.network'], cap: ['x.y.z'], subject_id: staff.subject, iat: now(), exp: now() + 300 }, s.network.privatePem);++ const me = await s.get('/auth/me', { cookie: `codes_at=${token}` });++ assert.strictEqual(me.status, 401);++ });++++ await check('a real Network session token still signs the staff member in', async () => {++ const page = await s.get('/staff', { as: staff });++ assert.strictEqual(page.status, 200, page.text.slice(0, 200));++ const r = await s.get('/staff/trust', { as: staff, form: { app_id: APP, tier: 'reviewed', note: 'checked' } });++ assert.strictEqual(r.status, 303);++ assert.strictEqual(s.ctx.trust.get(APP).tier, 'reviewed');++ });++++ await s.close();++ done();++})();
@@ -14,6 +14,7 @@ OV_NETWORK_INTERNAL_URL=http://127.0.0.1:4000
@@ -14,6 +14,7 @@ OV_NETWORK_INTERNAL_URL=http://127.0.0.1:4000
OV_OAUTH_CLIENT_ID=codes
OV_OAUTH_CLIENT_ID=codes
OV_OAUTH_CLIENT_SECRET=
OV_OAUTH_CLIENT_SECRET=
# OV_OAUTH_REDIRECT_URI=https://openvibe.codes/auth/callback
# OV_OAUTH_REDIRECT_URI=https://openvibe.codes/auth/callback
+# OV_SESSION_AUDIENCE=openvibe.network # audience a Network session token must carry to be a Codes session
# Signs the per-person form token (CSRF). Unset: a random per-process key (forms expire on restart).
# Signs the per-person form token (CSRF). Unset: a random per-process key (forms expire on restart).
CODES_FORM_SECRET=
CODES_FORM_SECRET=
@@ -85,6 +85,18 @@ function createSso({ config, keys, fetchImpl = globalThis.fetch, now = () => Dat
@@ -85,6 +85,18 @@ function createSso({ config, keys, fetchImpl = globalThis.fetch, now = () => Dat
return data;
return data;
}
}
+ /**
+ * A Codes session is a Network SESSION token: audience openvibe.network and a person. The Network
+ * signs FedCM assertions (audience = any owned-zone origin that asked), app/service tokens and
+ * internal tokens with the same key and issuer; none of them may become a session here.
+ */
+ function verifySession(token) {
+ const v = verifyJwt(token, { publicKey: keys.get(), issuer: config.networkIssuer, audience: config.oauth.sessionAudience, now: now() });
+ const c = v.claims;
+ if (c && (c.typ === 'fedcm' || c.actor_type !== undefined)) return { ok: false, reason: 'not a session token' };
+ return v;
+ }
+
function viewerFromClaims(claims, token) {
function viewerFromClaims(claims, token) {
const subject = typeof claims.subject_id === 'string' ? claims.subject_id : null;
const subject = typeof claims.subject_id === 'string' ? claims.subject_id : null;
const role = typeof claims.role === 'string' ? claims.role : 'user';
const role = typeof claims.role === 'string' ? claims.role : 'user';
@@ -112,7 +124,7 @@ function createSso({ config, keys, fetchImpl = globalThis.fetch, now = () => Dat
@@ -112,7 +124,7 @@ function createSso({ config, keys, fetchImpl = globalThis.fetch, now = () => Dat
try {
try {
const data = await tokenGrant({ grant_type: 'refresh_token', refresh_token: rt });
const data = await tokenGrant({ grant_type: 'refresh_token', refresh_token: rt });
setSession(res, data);
setSession(res, data);
- const v = verifyJwt(data.access_token, { publicKey: keys.get(), issuer: config.networkIssuer, now: now() });
+ const v = verifySession(data.access_token);
return v.ok ? viewerFromClaims(v.claims, data.access_token) : null;
return v.ok ? viewerFromClaims(v.claims, data.access_token) : null;
} catch (err) {
} catch (err) {
if (err.status && err.status < 500) clearSession(res);
if (err.status && err.status < 500) clearSession(res);
@@ -127,7 +139,7 @@ function createSso({ config, keys, fetchImpl = globalThis.fetch, now = () => Dat
@@ -127,7 +139,7 @@ function createSso({ config, keys, fetchImpl = globalThis.fetch, now = () => Dat
const token = req.cookies && req.cookies[ACCESS_COOKIE];
const token = req.cookies && req.cookies[ACCESS_COOKIE];
if (!token) return next();
if (!token) return next();
await keys.ensure();
await keys.ensure();
- const v = verifyJwt(token, { publicKey: keys.get(), issuer: config.networkIssuer, now: now() });
+ const v = verifySession(token);
if (v.ok) { req.viewer = viewerFromClaims(v.claims, token); return next(); }
if (v.ok) { req.viewer = viewerFromClaims(v.claims, token); return next(); }
if (v.expired) {
if (v.expired) {
const fresh = await refresh(req, res);
const fresh = await refresh(req, res);
@@ -47,6 +47,9 @@ function load(env = process.env) {
@@ -47,6 +47,9 @@ function load(env = process.env) {
clientSecret: env.OV_OAUTH_CLIENT_SECRET || '',
clientSecret: env.OV_OAUTH_CLIENT_SECRET || '',
redirectUri: env.OV_OAUTH_REDIRECT_URI || `${baseUrl}/auth/callback`,
redirectUri: env.OV_OAUTH_REDIRECT_URI || `${baseUrl}/auth/callback`,
scope: 'profile',
scope: 'profile',
+ // Network session tokens (what /oauth/token hands Codes) carry this audience; FedCM
+ // assertions, app and service tokens signed with the same key do not.
+ sessionAudience: env.OV_SESSION_AUDIENCE || 'openvibe.network',
},
},
cookies: { secure: env.COOKIE_SECURE ? env.COOKIE_SECURE === 'true' : isProduction },
cookies: { secure: env.COOKIE_SECURE ? env.COOKIE_SECURE === 'true' : isProduction },
// Signs the per-person form token (CSRF). Unset: a random per-process key.
// Signs the per-person form token (CSRF). Unset: a random per-process key.
new file mode 100644
new file mode 100644
@@ -0,0 +1,51 @@
@@ -0,0 +1,51 @@
+'use strict';
+/**
+ * The codes_at session cookie must hold a Network SESSION token (audience openvibe.network, a
+ * person), not just anything the Network signed. The Network signs FedCM assertions (audience =
+ * the relying party's origin, any https://*.openvibe.* origin), app/service tokens and internal
+ * tokens with the same key and issuer; none of them is a Codes session.
+ */
+const assert = require('assert');
+const { boot, check, done } = require('./helpers/boot');
+const { signJwt } = require('./helpers/mocks');
+
+(async () => {
+ const s = await boot();
+ const staff = s.network.addUser('stafferson');
+ s.ctx.config.staffSubjects.push(staff.subject); // as CODES_STAFF_SUBJECTS would
+ const APP = 'app_01JABCDEFGHJKMNPQRSTVWXYZ0';
+
+ const now = () => Math.floor(Date.now() / 1000);
+ // What POST /fedcm/assertion hands to ANY page on an owned zone the person continues on.
+ const fedcm = () => signJwt({
+ iss: s.network.url, sub: staff.id, id: staff.id, subject_id: staff.subject, username: staff.username, display_name: staff.username,
+ nonce: null, typ: 'fedcm', jti: 'x', aud: 'https://tenant.openvibe.host', iat: now(), exp: now() + 300,
+ }, s.network.privatePem);
+
+ await check('a FedCM assertion is not a Codes session (cannot act as staff)', async () => {
+ const cookie = `codes_at=${fedcm()}`;
+ const page = await s.get('/staff', { cookie });
+ assert.notStrictEqual(page.status, 200, 'the staff console must not open for an assertion');
+ const body = new URLSearchParams({ app_id: APP, tier: 'first-party', note: 'pwned', csrf: s.csrf(staff) }).toString();
+ const r = await s.get('/staff/trust', { cookie, body, headers: { 'content-type': 'application/x-www-form-urlencoded' } });
+ assert.notStrictEqual(r.status, 303, 'the trust tier must not be set');
+ assert.strictEqual(s.ctx.trust.get(APP).tier, 'unreviewed');
+ });
+
+ await check('an app token is not a Codes session', async () => {
+ const token = signJwt({ iss: s.network.url, sub: `app:${APP}`, actor_type: 'app', aud: ['openvibe.network'], cap: ['x.y.z'], subject_id: staff.subject, iat: now(), exp: now() + 300 }, s.network.privatePem);
+ const me = await s.get('/auth/me', { cookie: `codes_at=${token}` });
+ assert.strictEqual(me.status, 401);
+ });
+
+ await check('a real Network session token still signs the staff member in', async () => {
+ const page = await s.get('/staff', { as: staff });
+ assert.strictEqual(page.status, 200, page.text.slice(0, 200));
+ const r = await s.get('/staff/trust', { as: staff, form: { app_id: APP, tier: 'reviewed', note: 'checked' } });
+ assert.strictEqual(r.status, 303);
+ assert.strictEqual(s.ctx.trust.get(APP).tier, 'reviewed');
+ });
+
+ await s.close();
+ done();
+})();