@bongos/core 1.19.1017 → 1.19.1019
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.bongos-core.json +45 -25
- package/docs/adr/0208-application-lifecycle-the-handle-waits-and-the-vouch-has-no-minter.md +1 -0
- package/docs/adr/0332-offboarding-secret-scrub-semantics.md +59 -0
- package/docs/adr/0333-the-application-vouch-is-a-third-token-spent-once-and-bound-to-its-login.md +86 -0
- package/docs/adr/README.md +2 -0
- package/docs/module-api-changelog.md +9 -0
- package/docs/modules-contract.md +1 -0
- package/migrations/core_254_access_requests_applicant_handle.sql +23 -0
- package/modules/onboarding/routes/access-requests.js +31 -4
- package/modules/platform-identity/hub-keys.js +51 -32
- package/modules/platform-identity/routes/my-projects.js +33 -1
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/src/bongos/auth-idp.js +93 -48
- package/src/bongos/auth.js +2 -1
- package/src/bongos/db.js +22 -0
- package/src/module-api.js +7 -1
- package/tests/application_echo.mjs +100 -1
- package/tests/application_lifecycle.mjs +145 -6
- package/tests/idp_device_exchange.mjs +174 -0
- package/tests/module_api.mjs +1 -0
- package/tests/offboarding_secret_scrub.mjs +115 -0
|
@@ -14,14 +14,18 @@
|
|
|
14
14
|
// no PKI service.
|
|
15
15
|
//
|
|
16
16
|
// The assertion is a compact JWT (alg=EdDSA). The wire format is mirrored, by
|
|
17
|
-
// necessity, in the instance-side
|
|
18
|
-
//
|
|
19
|
-
//
|
|
17
|
+
// necessity, in the instance-side verifiers (src/bongos/auth-idp.js) — the two
|
|
18
|
+
// sides live across the core<->module boundary and cannot share code (ADR 0091
|
|
19
|
+
// §2 boundary-forced duplication). Keep them in lockstep:
|
|
20
20
|
// token = base64url(JSON(header)) "." base64url(JSON(payload)) "." base64url(sig)
|
|
21
21
|
// header = { alg: "EdDSA", typ: "JWT" }
|
|
22
|
-
// payload = { iss:"cloudbongos-hub", aud:<client_id>, sub:<github_id>,
|
|
23
|
-
// github_login, display_name, avatar_url, iat, exp, jti }
|
|
22
|
+
// payload = { iss:"cloudbongos-hub", aud:<client_id>, sub:<github_id>, …, iat, exp, jti }
|
|
24
23
|
// sig = Ed25519(privkey, utf8(`${b64u(header)}.${b64u(payload)}`))
|
|
24
|
+
// Three token kinds share that envelope, and each verifier demands exactly what
|
|
25
|
+
// the other two lack, so none is ever accepted as another:
|
|
26
|
+
// login assertion … = github_login, display_name, avatar_url (no purpose)
|
|
27
|
+
// logout token … = purpose:'backchannel-logout' (no identity)
|
|
28
|
+
// application vouch … = applicant_login, handle, purpose:'application'
|
|
25
29
|
'use strict';
|
|
26
30
|
|
|
27
31
|
const crypto = require('node:crypto');
|
|
@@ -75,24 +79,17 @@ function hubPublicKeyB64() {
|
|
|
75
79
|
return pub.export({ type: 'spki', format: 'der' }).toString('base64');
|
|
76
80
|
}
|
|
77
81
|
|
|
78
|
-
//
|
|
79
|
-
//
|
|
80
|
-
//
|
|
81
|
-
function
|
|
82
|
-
{ clientId, githubId, githubLogin, displayName, avatarUrl },
|
|
83
|
-
{ signingKey, nowMs, jti } = {},
|
|
84
|
-
) {
|
|
82
|
+
// signHubToken — the ONE envelope every hub token is minted in: issuer, the
|
|
83
|
+
// caller's claims, then iat/exp (~120s) and a fresh jti. The three minters below
|
|
84
|
+
// differ only in the claims they pass. Throws when no signing key is configured.
|
|
85
|
+
function signHubToken(claims, { signingKey, nowMs, jti } = {}) {
|
|
85
86
|
const key = signingKey || loadHubSigningKey();
|
|
86
87
|
if (!key) throw new Error('hub signing key not configured');
|
|
87
88
|
const iat = Math.floor((nowMs ?? Date.now()) / 1000);
|
|
88
89
|
const header = { alg: 'EdDSA', typ: 'JWT' };
|
|
89
90
|
const payload = {
|
|
90
91
|
iss: ISSUER,
|
|
91
|
-
|
|
92
|
-
sub: String(githubId),
|
|
93
|
-
github_login: githubLogin,
|
|
94
|
-
display_name: displayName ?? null,
|
|
95
|
-
avatar_url: avatarUrl ?? null,
|
|
92
|
+
...claims,
|
|
96
93
|
iat,
|
|
97
94
|
exp: iat + Math.floor(ASSERTION_TTL_MS / 1000),
|
|
98
95
|
jti: jti || crypto.randomBytes(12).toString('base64url'),
|
|
@@ -102,6 +99,22 @@ function mintHubAssertion(
|
|
|
102
99
|
return `${signingInput}.${sig}`;
|
|
103
100
|
}
|
|
104
101
|
|
|
102
|
+
// Mint an audience-scoped, short-lived EdDSA assertion for a federated client. The
|
|
103
|
+
// payload carries IDENTITY ONLY (never a rank). `nowMs`/`jti` are injectable for
|
|
104
|
+
// deterministic tests. Returns the compact token string.
|
|
105
|
+
function mintHubAssertion(
|
|
106
|
+
{ clientId, githubId, githubLogin, displayName, avatarUrl },
|
|
107
|
+
opts = {},
|
|
108
|
+
) {
|
|
109
|
+
return signHubToken({
|
|
110
|
+
aud: String(clientId),
|
|
111
|
+
sub: String(githubId),
|
|
112
|
+
github_login: githubLogin,
|
|
113
|
+
display_name: displayName ?? null,
|
|
114
|
+
avatar_url: avatarUrl ?? null,
|
|
115
|
+
}, opts);
|
|
116
|
+
}
|
|
117
|
+
|
|
105
118
|
// The purpose marker that distinguishes a back-channel LOGOUT token from a login
|
|
106
119
|
// assertion. Both are hub-signed EdDSA JWTs, but a logout token carries `purpose`
|
|
107
120
|
// + NO identity fields, and a login assertion carries identity + NO `purpose` —
|
|
@@ -119,23 +132,28 @@ const LOGOUT_PURPOSE = 'backchannel-logout';
|
|
|
119
132
|
// Same asymmetric signing + ~120s TTL + aud-binding as mintHubAssertion, so a
|
|
120
133
|
// token minted for one instance can't be replayed against another. nowMs/jti
|
|
121
134
|
// injectable for deterministic tests.
|
|
122
|
-
function mintHubLogoutToken({ clientId, githubId },
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
135
|
+
function mintHubLogoutToken({ clientId, githubId }, opts = {}) {
|
|
136
|
+
return signHubToken({ aud: String(clientId), sub: String(githubId), purpose: LOGOUT_PURPOSE }, opts);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// The purpose marker on an APPLICATION VOUCH — kept in lockstep with the instance
|
|
140
|
+
// verifier (src/bongos/auth-idp.js HUB_APPLICATION_PURPOSE).
|
|
141
|
+
const APPLICATION_PURPOSE = 'application';
|
|
142
|
+
|
|
143
|
+
// Mint the APPLICATION VOUCH the join relay sends a project (ADR 0201 D2, ADR 0333):
|
|
144
|
+
// "this request comes from this account" — identity + handle, never a rank or record.
|
|
145
|
+
// The login rides as `applicant_login`, NOT `github_login`: every login verifier any
|
|
146
|
+
// core ever shipped demands `github_login`, so even a core that predates the purpose
|
|
147
|
+
// refusal cannot accept a vouch as a device sign-in. `handle` is null unless the
|
|
148
|
+
// caller cleared the consent floor.
|
|
149
|
+
function mintHubApplicationVouch({ clientId, githubId, githubLogin, handle }, opts = {}) {
|
|
150
|
+
return signHubToken({
|
|
129
151
|
aud: String(clientId),
|
|
130
152
|
sub: String(githubId),
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
};
|
|
136
|
-
const signingInput = `${b64uJson(header)}.${b64uJson(payload)}`;
|
|
137
|
-
const sig = crypto.sign(null, Buffer.from(signingInput, 'utf8'), key).toString('base64url');
|
|
138
|
-
return `${signingInput}.${sig}`;
|
|
153
|
+
applicant_login: githubLogin,
|
|
154
|
+
handle: handle ?? null,
|
|
155
|
+
purpose: APPLICATION_PURPOSE,
|
|
156
|
+
}, opts);
|
|
139
157
|
}
|
|
140
158
|
|
|
141
159
|
module.exports = {
|
|
@@ -146,6 +164,7 @@ module.exports = {
|
|
|
146
164
|
loadHubSigningKey,
|
|
147
165
|
hubSigningConfigured,
|
|
148
166
|
hubPublicKeyB64,
|
|
167
|
+
mintHubApplicationVouch,
|
|
149
168
|
mintHubAssertion,
|
|
150
169
|
mintHubLogoutToken,
|
|
151
170
|
};
|
|
@@ -25,6 +25,8 @@ const api = require('../../../src/module-api');
|
|
|
25
25
|
const pi = require('../platform-identity');
|
|
26
26
|
const projectStats = require('../project-stats');
|
|
27
27
|
const applicationEcho = require('../application-echo');
|
|
28
|
+
const hubKeys = require('../hub-keys');
|
|
29
|
+
const { isAccountActive } = require('../account-visibility');
|
|
28
30
|
const { projectPublishability } = require('../publish-gate');
|
|
29
31
|
|
|
30
32
|
const { pool, requireBuilder, validateOrRespond, userAgent } = api;
|
|
@@ -116,6 +118,35 @@ function relayApplicantName(displayName) {
|
|
|
116
118
|
return name || null;
|
|
117
119
|
}
|
|
118
120
|
|
|
121
|
+
// Where the relay carries the vouch — mirrored by the project's public POST
|
|
122
|
+
// (modules/onboarding/routes/access-requests.js; boundary-forced, ADR 0091 §2).
|
|
123
|
+
const APPLICATION_VOUCH_HEADER = 'X-Bongos-Platform-Assertion';
|
|
124
|
+
|
|
125
|
+
// applicationVouchHeaders — the signed vouch for the HUB SESSION's own account
|
|
126
|
+
// (never a body field), as headers to add, or {} (ADR 0333). Best-effort, never a
|
|
127
|
+
// gate. The handle rides only past isAccountActive — the floor the applicant view
|
|
128
|
+
// itself applies, so the vouch shows no handle the reviewer's queue would not.
|
|
129
|
+
async function applicationVouchHeaders(client, builder) {
|
|
130
|
+
const signingKey = hubKeys.loadHubSigningKey();
|
|
131
|
+
if (!signingKey || !pi.isNumericGithubId(builder.github_id)) return {};
|
|
132
|
+
let handle = null;
|
|
133
|
+
try {
|
|
134
|
+
const account = await pi.getAccount(builder.github_id, { pool });
|
|
135
|
+
if (account && isAccountActive(account)) handle = account.handle || null;
|
|
136
|
+
} catch (e) {
|
|
137
|
+
api.logger('platform-identity').warn(`vouch handle read failed, vouching without one (non-fatal): ${e && e.message}`);
|
|
138
|
+
}
|
|
139
|
+
try {
|
|
140
|
+
const vouch = hubKeys.mintHubApplicationVouch({
|
|
141
|
+
clientId: client.client_id, githubId: builder.github_id, githubLogin: builder.github_login, handle,
|
|
142
|
+
}, { signingKey });
|
|
143
|
+
return { [APPLICATION_VOUCH_HEADER]: vouch };
|
|
144
|
+
} catch (e) {
|
|
145
|
+
api.logger('platform-identity').warn(`application vouch mint failed, relaying unvouched (non-fatal): ${e && e.message}`);
|
|
146
|
+
return {};
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
119
150
|
module.exports = function myProjectsRoutes() {
|
|
120
151
|
const router = express.Router();
|
|
121
152
|
|
|
@@ -202,10 +233,11 @@ module.exports = function myProjectsRoutes() {
|
|
|
202
233
|
// the project decides (open-join, or request/invite per ADR 0112). We post to
|
|
203
234
|
// the registry-trusted origin, never the caller's raw input.
|
|
204
235
|
let outcome = null;
|
|
236
|
+
const vouchHeaders = await applicationVouchHeaders(client, req.builder);
|
|
205
237
|
try {
|
|
206
238
|
const r = await fetch(`${client.origin}/api/bongos/access-requests`, {
|
|
207
239
|
method: 'POST',
|
|
208
|
-
headers: { 'Content-Type': 'application/json', Accept: 'application/json', 'User-Agent': userAgent() },
|
|
240
|
+
headers: { 'Content-Type': 'application/json', Accept: 'application/json', 'User-Agent': userAgent(), ...vouchHeaders },
|
|
209
241
|
body: JSON.stringify({
|
|
210
242
|
github_login: req.builder.github_login,
|
|
211
243
|
display_name: relayApplicantName(req.builder.display_name),
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.1019",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@bongos/core",
|
|
9
|
-
"version": "1.19.
|
|
9
|
+
"version": "1.19.1019",
|
|
10
10
|
"license": "AGPL-3.0-or-later",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"express": "^4.21.2",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.1019",
|
|
4
4
|
"description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
|
|
5
5
|
"license": "AGPL-3.0-or-later",
|
|
6
6
|
"main": "src/platform-server.js",
|
package/src/bongos/auth-idp.js
CHANGED
|
@@ -29,6 +29,7 @@ const { branding, userAgent } = require('../branding');
|
|
|
29
29
|
const { firstTimeAdmissionBlocked, maybeSeatFirstAdmin, openEnrollmentEnabled, reportActivityRollup, reportMembershipCheckIn, safeSyncRankRole } = require('./auth-admission.js');
|
|
30
30
|
const { loadIdpConfig } = require('./auth-config.js');
|
|
31
31
|
const { newSessionToken } = require('./auth-github.js');
|
|
32
|
+
const log = require('./logger').logger.child({ src: 'auth-idp' });
|
|
32
33
|
|
|
33
34
|
function idpAuthorizeUrl(origin, clientId, state, redirectUri) {
|
|
34
35
|
const u = new URL(`${String(origin).replace(/\/+$/, '')}/api/bongos/sso/authorize`);
|
|
@@ -149,32 +150,38 @@ function loadHubPublicKey() {
|
|
|
149
150
|
}
|
|
150
151
|
}
|
|
151
152
|
|
|
152
|
-
//
|
|
153
|
-
//
|
|
154
|
-
//
|
|
155
|
-
//
|
|
156
|
-
|
|
157
|
-
// regardless of the header's `alg` field, so there is no algorithm-confusion attack.
|
|
158
|
-
// Returns the validated payload, or throws. Pure over its inputs (testable).
|
|
159
|
-
function verifyHubAssertion(token, { publicKey, clientId, nowMs = Date.now() } = {}) {
|
|
153
|
+
// verifyHubToken — the envelope every hub token shares (hub-keys.js signHubToken,
|
|
154
|
+
// lockstep across the boundary, ADR 0091 §2): Ed25519 signature FIRST, alg hardcoded
|
|
155
|
+
// so there is no algorithm confusion, then issuer, audience (when given) and expiry.
|
|
156
|
+
// Which KIND of token it is, each verifier below decides from the claims.
|
|
157
|
+
function verifyHubToken(token, { publicKey, clientId, nowMs = Date.now(), label }) {
|
|
160
158
|
if (!publicKey) throw new Error('hub public key not configured');
|
|
161
159
|
const parts = String(token || '').split('.');
|
|
162
|
-
if (parts.length !== 3) throw new Error(
|
|
160
|
+
if (parts.length !== 3) throw new Error(`malformed ${label}`);
|
|
163
161
|
const [h, p, s] = parts;
|
|
164
162
|
let header;
|
|
165
163
|
try { header = JSON.parse(Buffer.from(h, 'base64url').toString('utf8')); }
|
|
166
|
-
catch { throw new Error(
|
|
167
|
-
if (!header || header.alg !== 'EdDSA') throw new Error(
|
|
164
|
+
catch { throw new Error(`malformed ${label} header`); }
|
|
165
|
+
if (!header || header.alg !== 'EdDSA') throw new Error(`unexpected ${label} alg`);
|
|
168
166
|
// Signature over the exact `header.payload` bytes — verify BEFORE trusting claims.
|
|
169
167
|
const ok = crypto.verify(null, Buffer.from(`${h}.${p}`, 'utf8'), publicKey, Buffer.from(s, 'base64url'));
|
|
170
|
-
if (!ok) throw new Error(
|
|
168
|
+
if (!ok) throw new Error(`${label} signature invalid`);
|
|
171
169
|
let payload;
|
|
172
170
|
try { payload = JSON.parse(Buffer.from(p, 'base64url').toString('utf8')); }
|
|
173
|
-
catch { throw new Error(
|
|
174
|
-
if (payload.iss !== HUB_ASSERTION_ISSUER) throw new Error(
|
|
175
|
-
if (clientId != null && String(payload.aud) !== String(clientId)) throw new Error(
|
|
176
|
-
if (typeof payload.exp !== 'number' || payload.exp < Math.floor(nowMs / 1000)) throw new Error(
|
|
171
|
+
catch { throw new Error(`malformed ${label} payload`); }
|
|
172
|
+
if (payload.iss !== HUB_ASSERTION_ISSUER) throw new Error(`unexpected ${label} issuer`);
|
|
173
|
+
if (clientId != null && String(payload.aud) !== String(clientId)) throw new Error(`${label} audience mismatch`);
|
|
174
|
+
if (typeof payload.exp !== 'number' || payload.exp < Math.floor(nowMs / 1000)) throw new Error(`${label} expired`);
|
|
175
|
+
return payload;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Verify a hub-signed LOGIN assertion: the envelope, identity, and NO `purpose` —
|
|
179
|
+
// any purpose, not a list, so no identity-bearing token kind can pass as a device
|
|
180
|
+
// sign-in (ADR 0208 D2, ADR 0333 D2).
|
|
181
|
+
function verifyHubAssertion(token, { publicKey, clientId, nowMs = Date.now() } = {}) {
|
|
182
|
+
const payload = verifyHubToken(token, { publicKey, clientId, nowMs, label: 'assertion' });
|
|
177
183
|
if (payload.sub == null || !payload.github_login) throw new Error('assertion missing identity');
|
|
184
|
+
if (Object.prototype.hasOwnProperty.call(payload, 'purpose')) throw new Error('assertion carries a purpose — not a login assertion');
|
|
178
185
|
return payload;
|
|
179
186
|
}
|
|
180
187
|
|
|
@@ -195,41 +202,46 @@ const HUB_LOGOUT_PURPOSE = 'backchannel-logout';
|
|
|
195
202
|
// github_login (rejected by verifyHubAssertion). Returns the validated payload
|
|
196
203
|
// ({ sub: github_id, ... }) or throws. Pure over its inputs (testable, no I/O).
|
|
197
204
|
function verifyHubLogoutToken(token, { publicKey, clientId, nowMs = Date.now() } = {}) {
|
|
198
|
-
|
|
199
|
-
const parts = String(token || '').split('.');
|
|
200
|
-
if (parts.length !== 3) throw new Error('malformed logout token');
|
|
201
|
-
const [h, p, s] = parts;
|
|
202
|
-
let header;
|
|
203
|
-
try { header = JSON.parse(Buffer.from(h, 'base64url').toString('utf8')); }
|
|
204
|
-
catch { throw new Error('malformed logout token header'); }
|
|
205
|
-
if (!header || header.alg !== 'EdDSA') throw new Error('unexpected logout token alg');
|
|
206
|
-
// Signature over the exact `header.payload` bytes — verify BEFORE trusting claims.
|
|
207
|
-
const ok = crypto.verify(null, Buffer.from(`${h}.${p}`, 'utf8'), publicKey, Buffer.from(s, 'base64url'));
|
|
208
|
-
if (!ok) throw new Error('logout token signature invalid');
|
|
209
|
-
let payload;
|
|
210
|
-
try { payload = JSON.parse(Buffer.from(p, 'base64url').toString('utf8')); }
|
|
211
|
-
catch { throw new Error('malformed logout token payload'); }
|
|
212
|
-
if (payload.iss !== HUB_ASSERTION_ISSUER) throw new Error('unexpected logout token issuer');
|
|
213
|
-
if (clientId != null && String(payload.aud) !== String(clientId)) throw new Error('logout token audience mismatch');
|
|
214
|
-
if (typeof payload.exp !== 'number' || payload.exp < Math.floor(nowMs / 1000)) throw new Error('logout token expired');
|
|
205
|
+
const payload = verifyHubToken(token, { publicKey, clientId, nowMs, label: 'logout token' });
|
|
215
206
|
if (payload.purpose !== HUB_LOGOUT_PURPOSE) throw new Error('not a back-channel logout token');
|
|
216
207
|
if (payload.sub == null) throw new Error('logout token missing subject');
|
|
217
208
|
return payload;
|
|
218
209
|
}
|
|
219
210
|
|
|
211
|
+
// The purpose marker on a hub APPLICATION VOUCH — lockstep with hub-keys.js.
|
|
212
|
+
const HUB_APPLICATION_PURPOSE = 'application';
|
|
213
|
+
|
|
214
|
+
// Verify a hub-signed APPLICATION VOUCH (ADR 0333): purpose 'application', identity
|
|
215
|
+
// (sub + applicant_login), and — stricter than its siblings — a required audience
|
|
216
|
+
// (the envelope skips the check without one) and a jti (single use needs one).
|
|
217
|
+
function verifyHubApplicationVouch(token, { publicKey, clientId, nowMs = Date.now() } = {}) {
|
|
218
|
+
if (clientId == null || clientId === '') throw new Error('application vouch audience not configured');
|
|
219
|
+
const payload = verifyHubToken(token, { publicKey, clientId, nowMs, label: 'application vouch' });
|
|
220
|
+
if (payload.purpose !== HUB_APPLICATION_PURPOSE) throw new Error('not an application vouch');
|
|
221
|
+
if (payload.sub == null || !payload.applicant_login) throw new Error('application vouch missing identity');
|
|
222
|
+
if (!payload.jti) throw new Error('application vouch missing jti');
|
|
223
|
+
return payload;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// jtiStore — a single-use jti register. One per token kind, so no two kinds share
|
|
227
|
+
// dedupe state; entries expire with their token. In-memory per process.
|
|
228
|
+
function jtiStore() {
|
|
229
|
+
const seen = new Map(); // jti -> expiry ms
|
|
230
|
+
return function seenAndRecord(jti, expSeconds, nowMs = Date.now()) {
|
|
231
|
+
for (const [k, exp] of seen) if (exp < nowMs) seen.delete(k);
|
|
232
|
+
if (!jti) return false; // no jti → cannot dedupe (aud + TTL remain the guards)
|
|
233
|
+
if (seen.has(jti)) return true;
|
|
234
|
+
seen.set(jti, expSeconds ? expSeconds * 1000 : nowMs + 120000);
|
|
235
|
+
return false;
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
|
|
220
239
|
// Anti-replay for CLI assertions (ADR 0141 §1, defense-in-depth on top of the
|
|
221
240
|
// aud-scoping fix): a jti may be exchanged ONCE. In-memory per process, TTL = the
|
|
222
241
|
// assertion's own expiry, so a captured assertion can't be replayed into multiple
|
|
223
242
|
// sessions even inside its ~120s life. A restart clears it — acceptable given the
|
|
224
243
|
// short window and that the aud binding is the primary control.
|
|
225
|
-
const
|
|
226
|
-
function _assertionJtiSeenAndRecord(jti, expSeconds, nowMs = Date.now()) {
|
|
227
|
-
for (const [k, exp] of _seenAssertionJti) if (exp < nowMs) _seenAssertionJti.delete(k);
|
|
228
|
-
if (!jti) return false; // no jti → cannot dedupe (aud + TTL remain the guards)
|
|
229
|
-
if (_seenAssertionJti.has(jti)) return true;
|
|
230
|
-
_seenAssertionJti.set(jti, expSeconds ? expSeconds * 1000 : nowMs + 120000);
|
|
231
|
-
return false;
|
|
232
|
-
}
|
|
244
|
+
const _assertionJtiSeenAndRecord = jtiStore();
|
|
233
245
|
|
|
234
246
|
// Anti-replay for back-channel LOGOUT tokens (task 1002238, symmetric with the CLI
|
|
235
247
|
// assertion path above). Honor a logout token's jti ONCE: the FIRST use revokes,
|
|
@@ -241,13 +253,43 @@ function _assertionJtiSeenAndRecord(jti, expSeconds, nowMs = Date.now()) {
|
|
|
241
253
|
// share dedupe state. In-memory per process; a restart clears it (acceptable — the
|
|
242
254
|
// window is short and the aud binding is the primary control). Exported for the
|
|
243
255
|
// route (routes/auth.js) since the DB revoke lives there, not in the kernel.
|
|
244
|
-
const
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
256
|
+
const logoutTokenJtiSeenAndRecord = jtiStore();
|
|
257
|
+
|
|
258
|
+
// Anti-replay for APPLICATION VOUCHES — chosen, not inherited (ADR 0333 D3): one
|
|
259
|
+
// vouch vouches one application.
|
|
260
|
+
const vouchJtiSeenAndRecord = jtiStore();
|
|
261
|
+
|
|
262
|
+
// The hub's handle charset (platform-identity.js normalizeHandle — boundary-forced
|
|
263
|
+
// mirror, pinned by tests/idp_device_exchange.mjs). Anything else is stored as null.
|
|
264
|
+
const HUB_HANDLE_RE = /^[a-z0-9](?:[a-z0-9-]{0,37}[a-z0-9])?$/;
|
|
265
|
+
|
|
266
|
+
// applicationVouchIdentity — { githubId, handle } from a verified, unspent vouch that
|
|
267
|
+
// names the request's own login, else null; never throws. Null is never a refusal
|
|
268
|
+
// (ADR 0201 D2.3): the route writes the unvouched row. Key + audience injected.
|
|
269
|
+
function applicationVouchIdentity(token, { githubLogin, publicKey, clientId, nowMs = Date.now() } = {}) {
|
|
270
|
+
if (!token || !publicKey || !clientId) return null;
|
|
271
|
+
let payload;
|
|
272
|
+
try {
|
|
273
|
+
payload = verifyHubApplicationVouch(token, { publicKey, clientId, nowMs });
|
|
274
|
+
} catch (err) {
|
|
275
|
+
log.info({ reason: err.message }, 'application vouch not accepted — the request is written unvouched');
|
|
276
|
+
return null;
|
|
277
|
+
}
|
|
278
|
+
// The body's login is a string anyone may type: a vouch for another account is no vouch.
|
|
279
|
+
if (String(payload.applicant_login).toLowerCase() !== String(githubLogin || '').toLowerCase()) return null;
|
|
280
|
+
if (vouchJtiSeenAndRecord(payload.jti, payload.exp, nowMs)) return null;
|
|
281
|
+
return {
|
|
282
|
+
githubId: String(payload.sub),
|
|
283
|
+
handle: typeof payload.handle === 'string' && HUB_HANDLE_RE.test(payload.handle) ? payload.handle : null,
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// readApplicationVouch — the doorway port: supplies THIS instance's hub key and
|
|
288
|
+
// client_id itself, so a module can neither swap the key nor re-address the check.
|
|
289
|
+
function readApplicationVouch(token, { githubLogin } = {}) {
|
|
290
|
+
if (!token) return null;
|
|
291
|
+
const idp = loadIdpConfig();
|
|
292
|
+
return applicationVouchIdentity(token, { githubLogin, publicKey: idp && loadHubPublicKey(), clientId: idp && idp.clientId });
|
|
251
293
|
}
|
|
252
294
|
|
|
253
295
|
// Complete a CLI federation exchange: verify the hub assertion, then run the SAME
|
|
@@ -318,6 +360,7 @@ async function completeDeviceExchange({ assertion, userAgent: ua }) {
|
|
|
318
360
|
// without creds or network.
|
|
319
361
|
|
|
320
362
|
module.exports = {
|
|
363
|
+
applicationVouchIdentity,
|
|
321
364
|
buildIdpAuthorizeUrl,
|
|
322
365
|
completeDeviceExchange,
|
|
323
366
|
completeIdpFlow,
|
|
@@ -325,6 +368,8 @@ module.exports = {
|
|
|
325
368
|
idpAuthorizeUrl,
|
|
326
369
|
loadHubPublicKey,
|
|
327
370
|
logoutTokenJtiSeenAndRecord,
|
|
371
|
+
readApplicationVouch,
|
|
372
|
+
verifyHubApplicationVouch,
|
|
328
373
|
verifyHubAssertion,
|
|
329
374
|
verifyHubLogoutToken,
|
|
330
375
|
};
|
package/src/bongos/auth.js
CHANGED
|
@@ -57,7 +57,7 @@ const { branding, userAgent } = require('../branding');
|
|
|
57
57
|
const { SCOPE, authConfigured, grantedScopeCoversRepos, idpConfigured, idpEnabled, loadIdpConfig, scopeForWebFlow } = require('./auth-config.js');
|
|
58
58
|
const { firstAdminLogin, firstTimeAdmissionBlocked, instanceNeedsFirstAdmin, joinabilityMode, maybeSeatFirstAdmin, membershipKind, openEnrollmentEnabled, readApplicantProfileFromHub, webAdmissionStatus } = require('./auth-admission.js');
|
|
59
59
|
const { buildAuthorizeUrl, completeWebFlow, pollDeviceFlow, startDeviceFlow } = require('./auth-github.js');
|
|
60
|
-
const { buildIdpAuthorizeUrl, completeDeviceExchange, completeIdpFlow, idpAuthorizeUrl, loadHubPublicKey, logoutTokenJtiSeenAndRecord, verifyHubAssertion, verifyHubLogoutToken } = require('./auth-idp.js');
|
|
60
|
+
const { buildIdpAuthorizeUrl, completeDeviceExchange, completeIdpFlow, idpAuthorizeUrl, loadHubPublicKey, logoutTokenJtiSeenAndRecord, readApplicationVouch, verifyHubAssertion, verifyHubLogoutToken } = require('./auth-idp.js');
|
|
61
61
|
const { addDiscordGuildMember, buildDiscordAuthorizeUrl, discordAuthorizeUrl, discordBotConfigured, discordConfigured, exchangeDiscordCode, fetchDiscordUser, isValidDiscordUserId, loadDiscordBotToken, loadDiscordCreds, removeDiscordGuildMember } = require('./auth-discord.js');
|
|
62
62
|
|
|
63
63
|
const MAX_AUTH_TOKEN_CANDIDATES = 6;
|
|
@@ -901,6 +901,7 @@ module.exports = {
|
|
|
901
901
|
openEnrollmentEnabled,
|
|
902
902
|
joinabilityMode,
|
|
903
903
|
readApplicantProfileFromHub,
|
|
904
|
+
readApplicationVouch,
|
|
904
905
|
firstTimeAdmissionBlocked,
|
|
905
906
|
instanceNeedsFirstAdmin,
|
|
906
907
|
webAdmissionStatus,
|
package/src/bongos/db.js
CHANGED
|
@@ -605,6 +605,12 @@ async function deactivateBuilder({ builderId, reason, actorBuilderId }) {
|
|
|
605
605
|
WHERE builder_id = $1 AND revoked_at IS NULL`,
|
|
606
606
|
[builderId]
|
|
607
607
|
);
|
|
608
|
+
// PRIVACY (task 1003338 / ADR 0332): scrub the stored GitHub OAuth repo
|
|
609
|
+
// token in the SAME transaction — github_oauth_tokens is core's own table
|
|
610
|
+
// (not module-owned), so it gets the direct treatment the session revoke
|
|
611
|
+
// above gets. deleteGithubToken is a plain DELETE; safe to call even when
|
|
612
|
+
// no row exists.
|
|
613
|
+
await deleteGithubToken({ builderId, p: client });
|
|
608
614
|
return { updated, activeClaims };
|
|
609
615
|
});
|
|
610
616
|
// Source-access clawback (ADR 0031 §6 / #600): a deactivated builder loses
|
|
@@ -616,6 +622,16 @@ async function deactivateBuilder({ builderId, reason, actorBuilderId }) {
|
|
|
616
622
|
await seams.emitAsync('builder.box-reconcile-needed', {
|
|
617
623
|
builderId, rank: 'xenos', status: 'inactive', actor: `builder-exit:builder#${actorBuilderId}`,
|
|
618
624
|
});
|
|
625
|
+
// PRIVACY (task 1003338 / ADR 0332): teardown port for a MODULE-owned
|
|
626
|
+
// builder-keyed secret (today: art-pipeline's Gemini BYOK key,
|
|
627
|
+
// gemini_key_enc/last4/set_at — CRUD moved out of core under ADR 0102, so
|
|
628
|
+
// core does not touch those columns directly). Best-effort, same as
|
|
629
|
+
// box-reconcile-needed above: a module that stores such a secret listens
|
|
630
|
+
// for this event and clears its own store; on a vanilla instance (no
|
|
631
|
+
// listener registered) it is a no-op.
|
|
632
|
+
await seams.emitAsync('builder.secrets-scrub-needed', {
|
|
633
|
+
builderId, actor: `builder-exit:builder#${actorBuilderId}`,
|
|
634
|
+
});
|
|
619
635
|
return {
|
|
620
636
|
builder: updated[0],
|
|
621
637
|
claims_released: activeClaims.map((c) => c.task_id),
|
|
@@ -646,6 +662,12 @@ async function reactivateBuilder({ builderId, actorBuilderId }) {
|
|
|
646
662
|
// Rank is intentionally left at whatever it is (xenos after a deactivation):
|
|
647
663
|
// an Archon re-promotes deliberately via PATCH /builders/:id/rank.
|
|
648
664
|
//
|
|
665
|
+
// Same posture for the secrets deactivateBuilder scrubs (task 1003338 /
|
|
666
|
+
// ADR 0332): NEITHER the deleted github_oauth_tokens row NOR the
|
|
667
|
+
// module-owned Gemini BYOK key is restored here, deliberately. A
|
|
668
|
+
// returning builder re-enters their own key; do not "helpfully" resurrect
|
|
669
|
+
// a stored credential on reactivation.
|
|
670
|
+
//
|
|
649
671
|
// REACTIVATION IS THE EXPOSURE (task 1003193). Rank does not move here, but
|
|
650
672
|
// status flipping back to 'active' makes whatever seeded rank-role the builder
|
|
651
673
|
// still holds live again — getActiveBuildersWithRank, builderHoldsRankActive,
|
package/src/module-api.js
CHANGED
|
@@ -71,7 +71,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
|
|
|
71
71
|
// there. scripts/gds/bump-version.js still rewrites the literal below; it appends
|
|
72
72
|
// the entry to that file. Look for a version's history there, not here.
|
|
73
73
|
// ---------------------------------------------------------------------------
|
|
74
|
-
const CORE_VERSION = '1.19.
|
|
74
|
+
const CORE_VERSION = '1.19.1019'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
|
|
75
75
|
|
|
76
76
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
77
77
|
// Usage: const log = api.logger('dev-box'); log.info('mounted');
|
|
@@ -346,6 +346,12 @@ module.exports = {
|
|
|
346
346
|
// re-address it. Fails soft to null on every error, so a hub outage costs
|
|
347
347
|
// the profile block and never the queue.
|
|
348
348
|
get readApplicantProfileFromHub() { return require('./bongos/auth').readApplicantProfileFromHub; },
|
|
349
|
+
// readApplicationVouch(token, { githubLogin }) — the write-side twin (task
|
|
350
|
+
// 1002285, ADR 0201 D2): verify the hub's application vouch on a public
|
|
351
|
+
// access request → { githubId, handle } or null. Core supplies this
|
|
352
|
+
// instance's hub key and client_id itself, so a module can neither
|
|
353
|
+
// substitute a key nor re-address the check. Null is never a refusal.
|
|
354
|
+
get readApplicationVouch() { return require('./bongos/auth').readApplicationVouch; },
|
|
349
355
|
// userAgent — the instance's HTTP User-Agent string (branding-derived). The
|
|
350
356
|
// carved lifecycle module's github-push.js stamps every GitHub API request with
|
|
351
357
|
// it instead of reaching into src/branding.js directly (BV1.R86).
|