Skip to content

Allow emails in usernames which facilitates SSO auth - #987

Merged
javuto merged 1 commit into
developfrom
username-allow-emails
Aug 22, 2026
Merged

Allow emails in usernames which facilitates SSO auth#987
javuto merged 1 commit into
developfrom
username-allow-emails

Conversation

@javuto

Copy link
Copy Markdown
Collaborator

Email usernames for SSO, and opt-in linking of existing local accounts

Two related changes to how federated identities become osctrl accounts.

1. Usernames may now be email addresses

AdminUser.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 Jane@corp.com and 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 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: truesaml:
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.gofederatedPolicy, linking branch, audit trail
  • pkg/configLinkLocalAccounts 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 (alice@example.com\nadmin, 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 Alice@Example.com survives the callback as 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 alice@example.com are two users with separate permissions. Documented in the guide.

@javutojavuto added 🔐 security Security related issues ✨ enhancement New feature or request labels Aug 22, 2026
@javuto
javuto merged commit 0bd042c into developAug 22, 2026
8 checks passed
@javuto
javuto deleted the username-allow-emails branch August 22, 2026 19:23
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

✨ enhancementNew feature or request🔐 securitySecurity related issues

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@javuto