Conversation
Add X2 auth flow section to auth.spec.md, registration REST API to protocol.spec.md, and rewrite Install+Activation in main.spec.md. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Add keys.js (auto-generate ES256 id-key.pem in X2_DATA_DIR), jti-cache.js (in-memory dedup with TTL eviction), and idtoken.js (sign trial id_tokens; verify self-issued tokens; stub for external IdPs). Also install cookie-parser server dependency. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…roof.js owners.js: username-owners.json with byName/byPubkey dual-index, atomic claimBinding with both uniqueness checks. trial.js: per-IP sliding-window rate limiter (lifted from oauth-login branch, paths updated for X2_DATA_DIR). csrf.js: double-submit cookie CSRF helper. proof.js: verifyHostProof validates JWT signature, audience, iat freshness, iss==jkt, and jti dedup. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Implement GET /register/start, POST /register/finish-trial, GET /register/callback, GET /register/status, and GET /auth/username-available/:name. Pending state lives in-memory (5min TTL). Consent/done/error HTML match oauth-login branch design tokens. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
… replaces /host validation ws-auth.js: verifyHandshakeProof validates signature, iat freshness, SERVER_START_TIME kill-switch, and jti dedup. index.js: drop HOST_KEY/ HOST_TOKEN_TTL mechanism; add cookie-parser; mount register routes; replace /host WS validateToken with verifyHandshakeProof. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…ke signer auth.js: loadKeyMaterial (caches importPKCS8 + exportJWK + thumbprint), signHostProof (for /register/start), signHandshakeProof (for /host WS). Strips private key fields from exported JWK before sharing. login.js: prompt username/password, sign host_proof, open browser, poll /register/status until 'claimed' or 5-min timeout, write credentials.json. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Replace HOST_TOKEN static token with signHandshakeProof() from auth.js — a fresh signed JWT per connect attempt. Add 'codette login' subcommand dispatch (lazy-imports login.js). Remove HOST_TOKEN fail-fast; gate on username being configured instead. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…pdate oauth-flow.js: headless X2 registration helper (generates keypair, signs host_proof, drives /register/start→/finish-trial→/callback, polls status). e2e-register.spec.js: browser consent click + WS handshake integration test. start-test-env.js: drop HOST_KEY; generate test keypair + headless register before spawning host. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
docker-compose: drop HOST_KEY/HOST_TOKEN_TTL, add X2_DATA_DIR + PUBLIC_URL. init.sh: drop COOKIE_SECRET/HOST_KEY, keep SERVER_HOSTNAME/PUBLIC_URL; server auto-generates id-key.pem on first run. README: update env var tables. run_dev.sh: generate isolated test keypairs for alice+bob via headless helper. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…ette login' Drop HOST_KEY prompt and credentials.json write from install.sh. The installer now only writes config.json with the server URL; the user runs 'codette login' as the next step to bind their identity. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…WK; add jose+ws to test deps jose v6 renamed setJWTId to setJti. importPKCS8 needs extractable:true to allow exportJWK. Root package.json adds jose+ws for test helper imports. Also fix WS rejection test to understand open+close:1008 is correct behavior. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
| const app = express(); | ||
| app.set('trust proxy', true); | ||
| app.use(express.json()); | ||
| app.use(cookieParser()); |
Comment on lines
+76
to
+173
| app.get('/register/start', async (req, res) => { | ||
| const { state, username, jwk: jwkB64, host_proof, idp } = req.query; | ||
|
|
||
| if (!state || !username || !jwkB64 || !host_proof || !idp) { | ||
| return renderError(res, { | ||
| title: 'Missing parameters', | ||
| message: 'state, username, jwk, host_proof, and idp are all required.', | ||
| hint: 'Run <kbd>codette login</kbd> to start again.', | ||
| }); | ||
| } | ||
|
|
||
| // Decode JWK | ||
| let jwk; | ||
| try { | ||
| jwk = JSON.parse(Buffer.from(jwkB64, 'base64url').toString()); | ||
| } catch { | ||
| return renderError(res, { | ||
| title: 'Invalid JWK', | ||
| message: 'Could not decode the supplied JWK.', | ||
| hint: 'Run <kbd>codette login</kbd> to start again.', | ||
| }); | ||
| } | ||
|
|
||
| // Validate JWK is importable | ||
| try { | ||
| await importJWK(jwk, 'ES256'); | ||
| } catch (e) { | ||
| return renderError(res, { | ||
| title: 'Invalid JWK', | ||
| message: e.message, | ||
| hint: 'Run <kbd>codette login</kbd> to start again.', | ||
| }); | ||
| } | ||
|
|
||
| // Verify host_proof | ||
| let jkt; | ||
| try { | ||
| ({ jkt } = await verifyHostProof({ | ||
| proofJwt: host_proof, | ||
| jwk, | ||
| expectedAud: serverIssuer + '/register', | ||
| expectedUsername: String(username), | ||
| jtiCache, | ||
| })); | ||
| } catch (e) { | ||
| return renderError(res, { | ||
| title: 'Invalid host proof', | ||
| message: e.message, | ||
| hint: 'Run <kbd>codette login</kbd> to start again.', | ||
| }); | ||
| } | ||
|
|
||
| // Username validation | ||
| const name = String(username).toLowerCase(); | ||
| if (!isValidUsername(name)) { | ||
| return renderError(res, { | ||
| title: 'Invalid username', | ||
| message: `"${escapeHtml(name)}" is not a valid username.`, | ||
| hint: 'Lowercase, start with a letter, 2–32 chars from [a-z0-9_-].', | ||
| }); | ||
| } | ||
| if (isUsernameClaimed(name)) { | ||
| return renderError(res, { | ||
| title: 'Username taken', | ||
| message: `"${escapeHtml(name)}" is already registered.`, | ||
| hint: 'Run <kbd>codette login</kbd> and choose a different username.', | ||
| }); | ||
| } | ||
|
|
||
| // Store pending | ||
| pending.set(state, { | ||
| username: name, | ||
| jwk, | ||
| jkt, | ||
| idp: String(idp), | ||
| expires: Date.now() + 5 * 60 * 1000, | ||
| ip: req.ip, | ||
| }); | ||
| statusMap.set(state, 'pending'); | ||
|
|
||
| // Branch by idp | ||
| if (idp === 'trial') { | ||
| const csrf = issueCsrfCookie(res, req.secure); | ||
| return res.type('html').send( | ||
| CONSENT_HTML | ||
| .replace('__USERNAME__', escapeHtml(name)) | ||
| .replace('__STATE__', escapeHtml(state)) | ||
| .replace('__CSRF__', escapeHtml(csrf)) | ||
| ); | ||
| } | ||
|
|
||
| // TODO: external IdP redirect | ||
| return renderError(res, { | ||
| title: 'IdP not supported', | ||
| message: `idp="${escapeHtml(idp)}" is not implemented yet.`, | ||
| hint: 'Use <kbd>idp=trial</kbd> or wait for a future release.', | ||
| }); | ||
| }); |
Comment on lines
+176
to
+243
| app.post('/register/finish-trial', express.urlencoded({ extended: false }), async (req, res) => { | ||
| // CSRF check | ||
| if (!verifyCsrf(req)) { | ||
| return renderError(res, { | ||
| title: 'CSRF validation failed', | ||
| message: 'Your session may have expired. Please try again.', | ||
| hint: 'Run <kbd>codette login</kbd> to start a fresh registration.', | ||
| }); | ||
| } | ||
|
|
||
| const { state } = req.body || {}; | ||
| if (!state) { | ||
| return renderError(res, { | ||
| title: 'Missing state', | ||
| message: 'No state parameter in the form submission.', | ||
| hint: 'Run <kbd>codette login</kbd> to start again.', | ||
| }); | ||
| } | ||
|
|
||
| const entry = pending.get(state); | ||
| if (!entry || Date.now() > entry.expires) { | ||
| pending.delete(state); | ||
| return renderError(res, { | ||
| title: 'Session expired', | ||
| message: 'The registration session has expired.', | ||
| hint: 'Run <kbd>codette login</kbd> to start again.', | ||
| }); | ||
| } | ||
|
|
||
| if (entry.idp !== 'trial') { | ||
| return renderError(res, { | ||
| title: 'IdP mismatch', | ||
| message: 'This endpoint is only for trial registrations.', | ||
| hint: '', | ||
| }); | ||
| } | ||
|
|
||
| // Rate limit check | ||
| const ip = req.ip; | ||
| if (!claimIfAllowed(ip)) { | ||
| return renderError(res, { | ||
| title: 'Rate limit exceeded', | ||
| message: 'Too many trial registrations from this IP address.', | ||
| hint: 'Wait before trying again.', | ||
| }); | ||
| } | ||
|
|
||
| // Issue self id_token and redirect to callback | ||
| let idToken; | ||
| try { | ||
| idToken = await issueSelfTrialIdToken({ | ||
| jkt: entry.jkt, | ||
| username: entry.username, | ||
| serverIssuer, | ||
| }); | ||
| } catch (e) { | ||
| revokeTrialClaim(ip); | ||
| return renderError(res, { | ||
| title: 'Token issuance failed', | ||
| message: e.message, | ||
| hint: 'Try again in a moment.', | ||
| }); | ||
| } | ||
|
|
||
| return res.redirect( | ||
| `/register/callback?state=${encodeURIComponent(state)}&id_token=${encodeURIComponent(idToken)}` | ||
| ); | ||
| }); |
Comment on lines
+246
to
+336
| app.get('/register/callback', async (req, res) => { | ||
| const { state, id_token } = req.query; | ||
|
|
||
| if (!state || !id_token) { | ||
| return renderError(res, { | ||
| title: 'Missing parameters', | ||
| message: 'state and id_token are required.', | ||
| hint: 'Run <kbd>codette login</kbd> to start again.', | ||
| }); | ||
| } | ||
|
|
||
| const entry = pending.get(state); | ||
| if (!entry || Date.now() > entry.expires) { | ||
| pending.delete(state); | ||
| return renderError(res, { | ||
| title: 'Session expired', | ||
| message: 'The registration session has expired.', | ||
| hint: 'Run <kbd>codette login</kbd> to start again.', | ||
| }); | ||
| } | ||
|
|
||
| // Verify id_token | ||
| let tokenPayload; | ||
| try { | ||
| tokenPayload = await verifyAnyIdToken({ | ||
| idToken: id_token, | ||
| expectedAud: serverIssuer + '/register/callback', | ||
| knownIssuers: { self: serverIssuer }, | ||
| }); | ||
| } catch (e) { | ||
| statusMap.set(state, 'error'); | ||
| return renderError(res, { | ||
| title: 'Invalid id_token', | ||
| message: e.message, | ||
| hint: 'Run <kbd>codette login</kbd> to start again.', | ||
| }); | ||
| } | ||
|
|
||
| // Assert sub matches jkt and username matches | ||
| if (tokenPayload.sub !== entry.jkt) { | ||
| statusMap.set(state, 'error'); | ||
| return renderError(res, { | ||
| title: 'Identity mismatch', | ||
| message: 'id_token subject does not match host key fingerprint.', | ||
| hint: 'Run <kbd>codette login</kbd> to start again.', | ||
| }); | ||
| } | ||
| if (tokenPayload.username !== entry.username) { | ||
| statusMap.set(state, 'error'); | ||
| return renderError(res, { | ||
| title: 'Username mismatch', | ||
| message: 'id_token username does not match pending registration.', | ||
| hint: 'Run <kbd>codette login</kbd> to start again.', | ||
| }); | ||
| } | ||
|
|
||
| // Atomic binding claim | ||
| const result = claimBinding(entry.username, entry.jkt, entry.jwk, { | ||
| idp: tokenPayload.iss_idp, | ||
| idp_sub: tokenPayload.sub, | ||
| }); | ||
|
|
||
| if (result === 'name-taken') { | ||
| if (entry.idp === 'trial') revokeTrialClaim(entry.ip); | ||
| statusMap.set(state, 'error'); | ||
| pending.delete(state); | ||
| return renderError(res, { | ||
| title: 'Username taken', | ||
| message: `"${escapeHtml(entry.username)}" was claimed by someone else.`, | ||
| hint: 'Run <kbd>codette login</kbd> and choose a different username.', | ||
| }); | ||
| } | ||
| if (result === 'pubkey-taken') { | ||
| if (entry.idp === 'trial') revokeTrialClaim(entry.ip); | ||
| statusMap.set(state, 'error'); | ||
| pending.delete(state); | ||
| return renderError(res, { | ||
| title: 'Key already registered', | ||
| message: 'This host key is already bound to another username.', | ||
| hint: 'Delete host-key.pem and run <kbd>codette login</kbd> again to generate a fresh key.', | ||
| }); | ||
| } | ||
|
|
||
| // Success | ||
| pending.delete(state); | ||
| statusMap.set(state, 'claimed'); | ||
|
|
||
| return res.type('html').send( | ||
| DONE_HTML.replace(/__USERNAME__/g, escapeHtml(entry.username)) | ||
| ); | ||
| }); |
| jkt: keypair.jkt, | ||
| privateKeyJose: keypair.privateKeyJose, | ||
| }); | ||
| console.log(`[test-env] X2 registration succeeded for ${USERNAME}`); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
No description provided.