Skip to content

Oauth-registration flow#2

Open
danlkv wants to merge 12 commits into
masterfrom
x2-login
Open

Oauth-registration flow#2
danlkv wants to merge 12 commits into
masterfrom
x2-login

Conversation

@danlkv
Copy link
Copy Markdown
Owner

@danlkv danlkv commented Jun 7, 2026

No description provided.

danlkv and others added 12 commits June 7, 2026 00:12
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>
Comment thread server/src/index.js
const app = express();
app.set('trust proxy', true);
app.use(express.json());
app.use(cookieParser());
Comment thread server/src/x2/register.js
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 thread server/src/x2/register.js
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 thread server/src/x2/register.js
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))
);
});
Comment thread tests/start-test-env.js
jkt: keypair.jkt,
privateKeyJose: keypair.privateKeyJose,
});
console.log(`[test-env] X2 registration succeeded for ${USERNAME}`);
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants