<#987 Allow emails in usernames which facilitates ...
# osctrl
g
#987 Allow emails in usernames which facilitates SSO auth Pull request opened by javuto 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
<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/osctrl