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.
Changes
@@@ -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 };@@@ -36,6 +36,7 @@ const REQUIRED = ['issuer', 'authorization_endpoint', 'token_endpoint', 'jwks_urassert.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_urassert.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`);
@@ -30,7 +30,7 @@ function metadata(config) {
@@ -30,7 +30,7 @@ function metadata(config) {
jwks_uri: `${base}/api/.well-known/jwks`,
jwks_uri: `${base}/api/.well-known/jwks`,
// openid adds an id_token to the code grant; profile and theme are the first-party scopes.
// 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).
// 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_types_supported: ['code'],
response_modes_supported: ['query'],
response_modes_supported: ['query'],
grant_types_supported: ['authorization_code', 'refresh_token', 'client_credentials', 'urn:ietf:params:oauth:grant-type:jwt-bearer'],
grant_types_supported: ['authorization_code', 'refresh_token', 'client_credentials', 'urn:ietf:params:oauth:grant-type:jwt-bearer'],
@@ -39,7 +39,7 @@ function metadata(config) {
@@ -39,7 +39,7 @@ function metadata(config) {
// 'none' is for public developer apps, which always use PKCE.
// 'none' is for public developer apps, which always use PKCE.
token_endpoint_auth_methods_supported: ['client_secret_post', 'none'],
token_endpoint_auth_methods_supported: ['client_secret_post', 'none'],
code_challenge_methods_supported: ['S256'],
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_parameter_supported: false,
request_uri_parameter_supported: false,
request_uri_parameter_supported: false,
claims_parameter_supported: false,
claims_parameter_supported: false,
@@ -65,12 +65,19 @@ function cleanNonce(v) {
@@ -65,12 +65,19 @@ function cleanNonce(v) {
return s.length <= NONCE_MAX && /^[\x21-\x7e]+$/.test(s) ? s : undefined; // undefined = refuse
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) {
function profileClaims(user) {
return {
return {
preferred_username: user.username,
preferred_username: user.username,
name: user.display_name || user.username,
name: user.display_name || user.username,
...(user.avatar_url ? { picture: user.avatar_url } : {}),
...(user.avatar_url ? { picture: user.avatar_url } : {}),
...(user.subject_id ? { subject_id: user.subject_id } : {}),
...(user.subject_id ? { subject_id: user.subject_id } : {}),
+ email: noreplyEmail(user),
+ email_verified: true,
};
};
}
}
@@ -99,4 +106,4 @@ function userinfo(req, res) {
@@ -99,4 +106,4 @@ function userinfo(req, res) {
res.json({ sub: String(user.id), ...profileClaims({ ...user, subject_id: subjectId }) });
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 };
@@ -36,6 +36,7 @@ const REQUIRED = ['issuer', 'authorization_endpoint', 'token_endpoint', 'jwks_ur
@@ -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.response_types_supported, ['code']);
assert.deepStrictEqual(root.body.code_challenge_methods_supported, ['S256']);
assert.deepStrictEqual(root.body.code_challenge_methods_supported, ['S256']);
assert.ok(root.body.scopes_supported.includes('openid'));
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.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']);
assert.deepStrictEqual(root.body.id_token_signing_alg_values_supported, ['RS256']);
// The same document at RFC 8414's name and at the older path.
// 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
@@ -115,6 +116,10 @@ const REQUIRED = ['issuer', 'authorization_endpoint', 'token_endpoint', 'jwks_ur
assert.strictEqual(u.body.preferred_username, 'viewer');
assert.strictEqual(u.body.preferred_username, 'viewer');
assert.strictEqual(u.body.name, 'Viewer');
assert.strictEqual(u.body.name, 'Viewer');
assert.match(u.body.subject_id, /^usr_/);
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}` } });
u = await fetch(`${base}/oauth/userinfo`, { method: 'POST', headers: { authorization: `Bearer ${access}` } });
assert.strictEqual(u.status, 200, 'POST works too');
assert.strictEqual(u.status, 200, 'POST works too');
u = await fetch(`${base}/oauth/userinfo`);
u = await fetch(`${base}/oauth/userinfo`);