openvibers/openvibe.network Commits

OpenID Connect: an email claim that is a no-reply alias (roadmap WS-X12). Clients that need an address per account, first git.openvibe.codes (Forgejo, signed in with OpenVibe accounts), now get email = <subject>@noreply.openvibe.network with email_verified true, in the id_token and userinfo. It is built from the stable subject id, so it survives a rename, receives nothing, and never reveals the person's real address. Discovery lists the email scope and claims. test/oidc-discovery.test.js checks the alias and that no real address leaks. npm test passes.

OP OpenVibers committed 28 Sept 2026 · 795021a29a · 2 files, +15 −3


Changes

ddiff --git a/server/auth/oidc.js b/server/auth/oidc.js
iindex 202b6220..df9243fe 100644
---- a/server/auth/oidc.js
++++ b/server/auth/oidc.js
@@@ -30,7 +30,7 @@ function metadata(config) {
jwks_uri: `${base}/api/.well-known/jwks`,
// openid adds an id_token to the code grant; profile and theme are the first-party scopes.
// Developer apps (app_<ULID>) ask for capability ids instead (docs/developer-projects.md).
-- scopes_supported: ['openid', 'profile', 'theme'],
++ scopes_supported: ['openid', 'profile', 'email', 'theme'],
response_types_supported: ['code'],
response_modes_supported: ['query'],
grant_types_supported: ['authorization_code', 'refresh_token', 'client_credentials', 'urn:ietf:params:oauth:grant-type:jwt-bearer'],
@@@ -39,7 +39,7 @@ function metadata(config) {
// 'none' is for public developer apps, which always use PKCE.
token_endpoint_auth_methods_supported: ['client_secret_post', 'none'],
code_challenge_methods_supported: ['S256'],
-- claims_supported: ['iss', 'sub', 'aud', 'exp', 'iat', 'nonce', 'name', 'preferred_username', 'picture', 'subject_id'],
++ claims_supported: ['iss', 'sub', 'aud', 'exp', 'iat', 'nonce', 'name', 'preferred_username', 'picture', 'subject_id', 'email', 'email_verified'],
request_parameter_supported: false,
request_uri_parameter_supported: false,
claims_parameter_supported: false,
@@@ -65,12 +65,19 @@ function cleanNonce(v) {
return s.length <= NONCE_MAX && /^[\x21-\x7e]+$/.test(s) ? s : undefined; // undefined = refuse
}
++// Clients that need an address per account (git.openvibe.codes, WS-X12) get a no-reply alias built from the stable
++// subject, never the person's real email: it identifies the account, receives nothing, and survives a rename.
++const NOREPLY_DOMAIN = 'noreply.openvibe.network';
++const noreplyEmail = (user) => `${user.subject_id || `u${user.id}`}@${NOREPLY_DOMAIN}`.toLowerCase();
++
function profileClaims(user) {
return {
preferred_username: user.username,
name: user.display_name || user.username,
...(user.avatar_url ? { picture: user.avatar_url } : {}),
...(user.subject_id ? { subject_id: user.subject_id } : {}),
++ email: noreplyEmail(user),
++ email_verified: true,
};
}
@@@ -99,4 +106,4 @@ function userinfo(req, res) {
res.json({ sub: String(user.id), ...profileClaims({ ...user, subject_id: subjectId }) });
}
--module.exports = { metadata, mount, sendMetadata, idToken, userinfo, wantsOpenid, cleanNonce, KID, ID_TOKEN_TTL_S };
++module.exports = { metadata, mount, sendMetadata, idToken, userinfo, wantsOpenid, cleanNonce, noreplyEmail, KID, ID_TOKEN_TTL_S };
ddiff --git a/test/oidc-discovery.test.js b/test/oidc-discovery.test.js
iindex 32dcc923..900ae67e 100644
---- a/test/oidc-discovery.test.js
++++ b/test/oidc-discovery.test.js
@@@ -36,6 +36,7 @@ const REQUIRED = ['issuer', 'authorization_endpoint', 'token_endpoint', 'jwks_ur
assert.deepStrictEqual(root.body.response_types_supported, ['code']);
assert.deepStrictEqual(root.body.code_challenge_methods_supported, ['S256']);
assert.ok(root.body.scopes_supported.includes('openid'));
++ assert.ok(root.body.scopes_supported.includes('email') && root.body.claims_supported.includes('email_verified'));
assert.ok(root.body.grant_types_supported.includes('authorization_code') && root.body.grant_types_supported.includes('refresh_token'));
assert.deepStrictEqual(root.body.id_token_signing_alg_values_supported, ['RS256']);
// The same document at RFC 8414's name and at the older path.
@@@ -115,6 +116,10 @@ const REQUIRED = ['issuer', 'authorization_endpoint', 'token_endpoint', 'jwks_ur
assert.strictEqual(u.body.preferred_username, 'viewer');
assert.strictEqual(u.body.name, 'Viewer');
assert.match(u.body.subject_id, /^usr_/);
++ // A no-reply alias from the stable subject, never the real address (git.openvibe.codes needs one per account).
++ assert.strictEqual(u.body.email, `${u.body.subject_id.toLowerCase()}@noreply.openvibe.network`);
++ assert.strictEqual(u.body.email_verified, true);
++ assert.ok(!JSON.stringify(u.body).includes('@example'), 'no real address leaks');
u = await fetch(`${base}/oauth/userinfo`, { method: 'POST', headers: { authorization: `Bearer ${access}` } });
assert.strictEqual(u.status, 200, 'POST works too');
u = await fetch(`${base}/oauth/userinfo`);