Fix #370: OIDC usernames identity #373

Merged
crueber merged 2 commits from fix/issue-370 into main 2026-09-12 13:21:00 +00:00
Owner

Fixes #370 — usernames, not emails, as the OIDC identity key.

A. Usernames as identity key: principalFromEmail (internal/server/auth.go) returns Principal{Name: <username>, Email: <email>} — derived at first login (email local-part via auth.DeriveUsername, collision-uniquified crueber/crueber2/…, immutable, stable), stored on users/<username>/user.json ↔ users/by-email/<enc>/ref.json alias (CAS on creation, law 4; repeat logins = 1 alias GET, law 6). Wired via AuthService.UsernameResolver/EmailLookup hooks (law 8 — server never imports identity); nil falls back to the pure base (still no @). Session/token wires keep the email (existing sessions valid, law 5). PrincipalForName resolves usernames via lookup, fail-closed otherwise.
Leak audit: every Principal.Name surface now renders usernames; GET /users/{u} fills email only for self; TestEmailLeakAudit + auth-surface tests assert emails absent (deny messages, access.json, invites, rosters, org-hook titles, me/check/tokens).
Migration/alias: ValidPrincipal accepts both spellings; matchPrincipal (pure, push-path safe) keeps rosters/access/invites/inboxes/teams resolving; grant sites bind user namespaces only via isUserNamespace (registry-backed username or legacy email — never orgs/synthetics/unclaimed; the #346 writeless-self pin holds). Affected keys enumerated in docs/features/01 §Decisions (users/*, members, access subjects, teams, invites, issue/PR authors, notify recipients, ssh-keys; policy.json email spellings fail closed → manual migration).
B. Import plumbing: owner dropdown (allowedOwners + isValidOwnerPart) holds usernames+orgs only; username-self admission + email-owner 400 pinned (ownerbind matrix, PostValidationMatrix).
C. Back-to-form moved to the done/outcome view; removed from the inline-error form.
Tests: identity 95.9%, auth 100%, server 98.3%, repoimport 95.8%, api 95.3%; -race clean; node 721 pass (2 pre-existing dist failures, same as main); gofmt/vet clean; esbuild syntax OK. No new deps.

Fixes #370 — usernames, not emails, as the OIDC identity key. **A. Usernames as identity key:** `principalFromEmail` (internal/server/auth.go) returns `Principal{Name: <username>, Email: <email>}` — derived at first login (email local-part via `auth.DeriveUsername`, collision-uniquified crueber/crueber2/…, immutable, stable), stored on `users/<username>/user.json` ↔ `users/by-email/<enc>/ref.json` alias (CAS on creation, law 4; repeat logins = 1 alias GET, law 6). Wired via `AuthService.UsernameResolver/EmailLookup` hooks (law 8 — server never imports identity); nil falls back to the pure base (still no @). Session/token wires keep the email (existing sessions valid, law 5). `PrincipalForName` resolves usernames via lookup, fail-closed otherwise. **Leak audit:** every `Principal.Name` surface now renders usernames; `GET /users/{u}` fills `email` only for self; `TestEmailLeakAudit` + auth-surface tests assert emails absent (deny messages, access.json, invites, rosters, org-hook titles, me/check/tokens). **Migration/alias:** `ValidPrincipal` accepts both spellings; `matchPrincipal` (pure, push-path safe) keeps rosters/access/invites/inboxes/teams resolving; grant sites bind user namespaces only via `isUserNamespace` (registry-backed username or legacy email — never orgs/synthetics/unclaimed; the #346 writeless-self pin holds). Affected keys enumerated in docs/features/01 §Decisions (users/\*, members, access subjects, teams, invites, issue/PR authors, notify recipients, ssh-keys; policy.json email spellings fail closed → manual migration). **B. Import plumbing:** owner dropdown (`allowedOwners` + `isValidOwnerPart`) holds usernames+orgs only; username-self admission + email-owner 400 pinned (ownerbind matrix, PostValidationMatrix). **C. Back-to-form** moved to the done/outcome view; removed from the inline-error form. Tests: identity 95.9%, auth 100%, server 98.3%, repoimport 95.8%, api 95.3%; -race clean; node 721 pass (2 pre-existing dist failures, same as main); gofmt/vet clean; esbuild syntax OK. No new deps.
principalFromEmail resolves verified emails to immutable usernames
(derived local-part, collision-uniquified via users/<username>/user.json
CAS + by-email alias); Principal carries Email for alias matching while
Name is the only user-facing identity. Grant sites bind user namespaces
only (never orgs/synthetics/unclaimed names); invites/inboxes resolve
both spellings; owner dropdown filters to ID parts; back-to-form moves
to the outcome view.
On PutCreate 412 the loser moved to the next suffix without reading the
winner, so two sessions racing first-login for one email claimed two
usernames (base + base2, proven 2/50 in a barrier probe). Re-read the
candidate in the same pass: same email adopts + repairs the alias,
foreign email moves on. Matches the documented behavior in the
Concurrency section. Adds TestResolveUsernameConcurrentFirstLogin.
Sign in to join this conversation.
No description provided.