feat(auth): OpenID Connect login with team mapping - #214
Conversation
Read the AUTH_OIDC_* variables into auth.Config.OIDC with the defaults of the design (scopes openid profile email, groups claim, preferred_username, provisioning and team sync on, Single Sign-On label). An issuer requires a client id, a client secret and an AUTH_PUBLIC_URL without path, the issuer must be https except on loopback, and a client set without an issuer is refused. Errors never carry the client secret and OIDCConfig.LogValue omits it. Refs BananaOps#196
ssotest serves discovery, JWKS, an authorize endpoint and a token endpoint that enforces client authentication, single use codes and PKCE S256. The id_token claims, the signing mode (RS256, alg none, unknown key) and authorize errors are configurable so that tests can exercise every verification of the relying party. Refs BananaOps#196
Add github.com/coreos/go-oidc/v3 v3.21.0 and golang.org/x/oauth2 v0.37.0 (go-jose/v4 v4.1.4 indirect), checked with govulncheck. OIDCProvider discovers the issuer lazily and retries at most every five seconds after a failure, builds PKCE S256 authorization URLs with a nonce, exchanges the code with the verifier and verifies the id_token signature, issuer, audience, expiry and nonce. Claims extraction reads the configured username claim with an email fallback and a groups claim given as an array or a single string. Refs BananaOps#196
The tracker_oidc cookie carries state, nonce, PKCE verifier, redirect and issue time, sealed with AES-256-GCM under a key derived from the session secret with HKDF-SHA256 and bound to the cookie name. It is HttpOnly, SameSite=Lax so it survives the redirect back from the identity provider, scoped to the callback path, and refused after ten minutes. SafeRedirect only keeps local absolute paths. Refs BananaOps#196
Encode no longer HTML-escapes the payload, which inflated a long redirect of <, > or & past the cookie size limit, and returns ErrTransactionTooLarge when the value still exceeds it so the login handler can fall back to a root redirect. Decode now requires nonce plus AEAD overhead, and SafeRedirect rejects every Unicode control character. Refs BananaOps#196
Users are found by (issuer, subject) and created on first login when provisioning is on, with the username claim suffixed by a counter on collision. An existing account is never bound by username or email, a disabled user is refused, and email, display name and last login are refreshed at every login while the username stays stable. OIDC users have no password and never need to change one. Refs BananaOps#196
An empty display name claim no longer erases the stored one, an identity without issuer or subject is rejected, and a user found by (issuer, subject) that is not an OpenID Connect account is refused and left untouched. Refs BananaOps#196
On an OIDC login the user joins every team whose oidcGroups intersect the groups claim and leaves the other mapped teams, with an exact case sensitive comparison. Teams without oidcGroups, including Administrators when it has none, are never touched, and the last enabled administrator is never removed. Memberships change through targeted pull and addToSet updates so concurrent admin edits are kept. Refs BananaOps#196
When teams are mapped to OIDC groups and the identity provider sends no groups claim, team synchronisation now fails without any write instead of stripping memberships. A present but empty claim still means no group. Removal pulls every mapped team that does not match the claim, including memberships added since the user was loaded, and non OIDC users are refused. Refs BananaOps#196
Team synchronisation now adds every mapped team matching the groups claim through an idempotent addToSet instead of diffing against the loaded user, so a membership removed elsewhere since login lookup is restored. Added still reports only real changes. Refs BananaOps#196
GET /api/v1alpha1/auth/oidc/login stores an encrypted transaction and redirects to the identity provider, GET /api/v1alpha1/auth/oidc/callback checks the state, exchanges the code, resolves or provisions the user, syncs its teams and sets the usual session cookie before redirecting to a safe local path. Failures redirect to /login with a fixed error code, unknown or disabled users get a 403 page. The routes exist only when AUTH_OIDC_ISSUER is set, GetAuthConfig now reports oidcEnabled and the button label, and tracker_auth_logins_total gains method=oidc. Refs BananaOps#196
A missing groups claim with mapped teams is now refused before the user is resolved, so a refused login no longer creates the user or refreshes its profile and last login. The precondition is exposed as identity.CheckOIDCGroupsClaim and shared with SyncOIDCTeams. Discovery and id_token verification failures are logged with a fixed reason only, since go-oidc embeds the raw identity provider response in its errors. Refs BananaOps#196
Run the real routes against the in-process provider for state mismatch and login CSRF, missing, tampered, foreign and expired transactions, replayed callbacks, nonce, audience, issuer, expiry, alg none and unknown key rejections, provider errors that are never reflected, open redirects, username collisions including admin, disabled and unprovisioned users, group mapping rules, the last administrator guard, metrics and secret free logs. Refs BananaOps#196
Describe the OIDC variables, the redirect URI, account and team mapping rules, the transaction cookie, error codes and troubleshooting, with recipes for Keycloak, Microsoft Entra ID, Google, GitLab and Dex, and list the new variables in CONFIGURATION.md and .env.example. Refs BananaOps#196
…s omitted Several identity providers omit the groups claim for a user who has no group. Refusing every login when a team is mapped locked those users out. With an absent claim, the login is now refused (nothing written) only when the user already holds a mapped team membership. A user without mapped membership, a new subject included, is treated as having no groups. The check still runs before any write, using a read-only lookup by issuer and subject, and the rule lives in identity only. Refs BananaOps#196
An absent groups claim now refuses only users who already hold a mapped team membership; other users are treated as having no group. Update the Teams section, the recommendation to emit the claim, the error table and the troubleshooting row. Also rewrap an over-long line and soften the GitLab claim format statement. Refs BananaOps#196
… absent An absent claim says nothing about the user's groups, so the sync now returns right after the precondition check: it neither adds nor removes any membership, and no longer issues a useless write at each login. Refs BananaOps#196
When team sync is on, teams are mapped to OIDC groups and the groups claim is absent, the login is accepted for a user holding no mapped membership. Log a WARN naming the claim and the username so a broken identity provider mapper is visible. Refs BananaOps#196
A groups claim that is neither a string nor an array (object, number, bool, null) used to count as present with no group, which removed every mapped team. It now has absent semantics and the callback logs a WARN naming the claim. Refs BananaOps#196
An unreachable token endpoint ends on oidc_failed; oidc_unavailable is only for a failed discovery. No path creates OIDC accounts beforehand. Note that the SSO button ships with the web PR, and document the new groups claim warnings and the unexpected type rule. Refs BananaOps#196
|
Notes on the two red checks, both unrelated to the code in this PR: security/snyk: Snyk only tests the Go project when a PR changes The three added dependencies have no advisory in OSV, and
I cannot open the Snyk report itself, so if it points at something else, tell me and I will look. validate-pr-title: the label step fails on every fork PR (read-only token); the title is valid. #207 fixes the workflow. |
What
PR 3 of the SSO series (#196): OpenID Connect login with team mapping.
GET /api/v1alpha1/auth/oidc/login?redirect=/pathandGET /api/v1alpha1/auth/oidc/callback, registered only whenAUTH_OIDC_ISSUERis set (404 otherwise).id_tokenverification (signature, issuer, audience, expiry, nonce).tracker_oidc: state, nonce, PKCE verifier, redirect and issue time sealed with AES-256-GCM (key derived from the session secret with HKDF-SHA256, bound to the cookie name), HttpOnly, SameSite=Lax, scoped to the callback path, valid 10 minutes, deleted on every callback.(issuer, subject), never linked to an existing account by name or email. Provisioning at first login with a collision suffix (alice,alice-2...), disabled or unknown users get a 403 page. OIDC accounts have no local password.oidcGroups; teams withoutoidcGroupsare untouched. Groups claim absent with mapped teams: refused withoidc_failedand no write when the user holds a mapped membership, otherwise treated as no group with a WARN. A groups claim of an unexpected type (object, number, bool, null) is treated as absent, with a WARN. A present but empty claim is a normal sync.GetAuthConfigreportsoidcEnabledandoidcButtonLabel;tracker_auth_logins_totalgainsmethod="oidc".docs/AUTHENTICATION.md, section Single Sign-On (OpenID Connect), with recipes for Keycloak, Entra ID, Google, GitLab and Dex.Configuration
AUTH_OIDC_ISSUERAUTH_OIDC_CLIENT_ID,AUTH_OIDC_CLIENT_SECRETAUTH_OIDC_SCOPESopenid profile emailAUTH_OIDC_GROUPS_CLAIMgroupsAUTH_OIDC_USERNAME_CLAIMpreferred_username, thenemailAUTH_OIDC_USER_PROVISIONINGtrueAUTH_OIDC_TEAM_SYNCtrueAUTH_OIDC_BUTTON_LABELSingle Sign-OnAUTH_PUBLIC_URLRedirect URI to register with the IdP:
<AUTH_PUBLIC_URL>/api/v1alpha1/auth/oidc/callback. Replicas must shareAUTH_SESSION_SECRET. Details indocs/AUTHENTICATION.md.Security
id_token./.admin) cannot be taken over by claiming its name./login?error=<constant code>, nothing coming from the IdP is reflected.AUTH_SESSION_TTL; one login transaction per browser (a second tab replaces the first).Checks
go build,go vet,go test ./... -count=1andgo test -raceon auth, stores and server: green.gofmt -llists onlyinternal/utils/utils_test.go, untouched by this branch.serv: redirect to Dex, login, callback,tracker_sessionissued,auth/meshows the OIDC user (source: oidc) with the mapped team; changing the teams'oidcGroupsthen logging in again moves the membership; tampered or missing state ends onoidc_state, IdP error onoidc_denied,//evil.comredirect becomes/; claim absent givesoidc_failedfor a user with a mapped membership and a login with no mapped team otherwise; Dex stopped at startup gives a WARN andoidc_unavailablewhile the local admin logs in, and SSO works again after Dex returns without restarting Tracker; logs without secrets,tracker_auth_logins_total{method="oidc"}incremented.Follow-ups
?error=codes yet (depends on feat(web): login page, auth context and admin pages #201, which already contains the SSO button).docs/AUTHENTICATION.mdandREADME.mdwill conflict with feat(web): login page, auth context and admin pages #201 at rebase.TestOIDCCannotTakeOverAdminfailed once under heavy concurrent MongoDB load (environmental, not reproduced).UpdateUser.Refs #196