GitHub
08/22/2026, 7:15 PMAdminUser.Username was constrained to ^[a-zA-Z0-9_-]{1,64}$, which rejects the value most IdPs actually identify people by. Entra ID, Okta and Google Workspace all emit a mailbox as preferred_username / NameID, so operators had to point the config at a short handle instead — and oidc.usernameClaim = email was self-defeating: pickUsername selected the email, then the sanitizer rejected it.
A username may now be either the original plain shape or an email address.
One validator, not three. pkg/auth/oidc and pkg/auth/saml each carried a byte-identical copy of the regex and sanitizeUsername. Both now delegate to pkg/utils.SanitizeUsername, so the accepted shape is defined once (and SCIM provisioning can reuse it).
The email pattern is deliberately narrower than RFC 5322. The RFC permits quotes, slashes and backticks — precisely what the existing T23/T26 threat model exists to keep out, and nothing real emits them. The dot rules are encoded structurally rather than checked separately, so a leading dot, a trailing dot and .. are all unmatchable; .. in a username would otherwise be a path-traversal primitive once interpolated into /api/v1/users/{username}.
Email usernames are stored lowercased. Get() does a raw username = ?, so without canonicalization <mailto:Jane@corp.com|Jane@corp.com> and <mailto:jane@corp.com|jane@corp.com> are two accounts on PostgreSQL but collide on MySQL's default collation — identity semantics differing by database backend. Plain handles are deliberately not folded: that would stop existing mixed-case accounts from matching their row on the next login.
Removed LegacyPermissiveUsername. It bypassed validation entirely — raw IdP value, newlines and NULs included — and existed only for the deleted `cmd/admin`'s email usernames. Nothing set it true anymore, and proper email support is what made it obsolete. Deleting it closes a one-config-field bypass of the boundary this PR hardens.
The email_verified gate on the email claim is unchanged and now load-bearing: an unverified address falls back to sub, so a user who hasn't proven control of a mailbox can't claim the account belonging to whoever owns it.
2. Federated login can link an existing local account
A federated login whose username matched an existing local password account was refused outright. With emails now usable as usernames, this is easy to trip over: an admin pre-creates <mailto:jane@corp.com|jane@corp.com>, and her SSO login is then blocked by the account she was given.
Linking is now available, opt-in per provider, default off:
oidc:
linkLocalAccounts: true
saml:
linkLocalAccounts: true
(OIDC_LINK_LOCAL_ACCOUNTS, SAML_LINK_LOCAL_ACCOUNTS, --oidc-link-local-accounts, --saml-link-local-accounts.)
Why not automatic. Matching the IdP's email against the local account's email was the obvious candidate and doesn't hold up: ResolvedIdentity.Email is documented as untrusted (mutable, spoofable — threat T24), carries no email_verified, and SAML has no equivalent signal. Auto-linking on same-name match is exactly the takeover vector the original block existed to stop — make the IdP assert admin, inherit the local admin row. The authorization for linking is therefore operator intent, expressed once in config.
Behavior:
• Off — still refused, but the error now names the knob that fixes it rather than just saying the account "cannot be claimed".
• On — the row is stamped with the claiming protocol, logged at WARN with the admin flag, and written to the audit log with the client IP. The stored password is untouched, and linking grants nothing: a non-admin stays a non-admin, environment permissions unchanged.
• Because the stamp persists, the flag is only needed for that first login — turn it back off and linked accounts keep working.
Rows already carrying an auth source were created by federated login in the first place, so cross-protocol re-matching (OIDC↔️SAML, same IdP) stays unconditional as before.
Refactor
resolveFederatedUser(identity, jitProvision bool, authSource string) became (identity, federatedPolicy, clientIP). The call was about to grow to (identity, true, false, "oidc"), where transposing two adjacent bools would silently disable a security control.
Changes
• pkg/utils/username.go (new) — SanitizeUsername, IsEmailUsername
• pkg/auth/{oidc,saml} — delegate to it; LegacyPermissiveUsername removed
• cmd/api/handlers/auth_resolve.go — federatedPolicy, linking branch, audit trail
• pkg/config — LinkLocalAccounts on both providers, flags and env vars
• frontend/src/features/users/UsersPage.tsx — the create-user form enforced the old regex; now mirrors the backend, lowercasing included, so a locally created account matches what SSO resolves to
• Docs — Username rules rewritten, new Linking existing local accounts section, both env-var tables, sample YAML, service-config field help
Testing
• New pkg/utils suite: accept cases, lowercase canonicalization, and ~25 rejects including disguised-email attacks (<mailto:alice@example.com|alice@example.com>\nadmin, <mailto:alice@example.com|alice@example.com>/../root, al..ice@example.com, alice'@example.com).
• The OIDC T23 injection test kept its teeth — the plain email moved out, those disguised variants moved in — plus an end-to-end test that <mailto:Alice@Example.com|Alice@Example.com> survives the callback as <mailto:alice@example.com|alice@example.com>.
• Linking: default-reject (asserting the rejected row is not restamped), link-when-allowed (persists, doesn't promote), and resolves-after-flag-disabled.
• Full suite green: `go build`/`go vet`/`go test`, make openapi-check, frontend 255 tests, tsc.
Upgrade notes
No migration. Existing accounts and IdP configurations validate exactly as before — the plain shape is unchanged and plain handles are not case-folded. Anyone who was setting LegacyPermissiveUsername in Go code (nothing in-tree did) loses that field.
Changing usernameClaim on a live deployment creates new accounts rather than renaming existing ones — the username is the identity, so alice and <mailto:alice@example.com|alice@example.com> are two users with separate permissions. Documented in the guide.
jmpsec/osctrlGitHub
08/22/2026, 7:23 PM