Skip to content

feat(auth): OpenID Connect login with team mapping - #214

Merged
jplanckeel merged 20 commits into
BananaOps:mainfrom
TartanLeGrand:feat/auth-oidc
Oct 1, 2026
Merged

jplanckeel merged 20 commits into
BananaOps:mainfrom
TartanLeGrand:feat/auth-oidc

Conversation

@TartanLeGrand

Copy link
Copy Markdown
Contributor

What

PR 3 of the SSO series (#196): OpenID Connect login with team mapping.

  • Routes GET /api/v1alpha1/auth/oidc/login?redirect=/path and GET /api/v1alpha1/auth/oidc/callback, registered only when AUTH_OIDC_ISSUER is set (404 otherwise).
  • Provider client on go-oidc v3 and x/oauth2: lazy discovery with retry (at most every 5 s after a failure), Authorization Code with PKCE S256, confidential client, id_token verification (signature, issuer, audience, expiry, nonce).
  • Login transaction cookie 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.
  • User resolution by (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.
  • Team sync from the groups claim: the user joins every team sharing a group and leaves the other teams that have oidcGroups; teams without oidcGroups are untouched. Groups claim absent with mapped teams: refused with oidc_failed and 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.
  • GetAuthConfig reports oidcEnabled and oidcButtonLabel; tracker_auth_logins_total gains method="oidc".
  • Documentation: docs/AUTHENTICATION.md, section Single Sign-On (OpenID Connect), with recipes for Keycloak, Entra ID, Google, GitLab and Dex.

Configuration

Variable Default
AUTH_OIDC_ISSUER unset (SSO off)
AUTH_OIDC_CLIENT_ID, AUTH_OIDC_CLIENT_SECRET required with an issuer
AUTH_OIDC_SCOPES openid profile email
AUTH_OIDC_GROUPS_CLAIM groups
AUTH_OIDC_USERNAME_CLAIM preferred_username, then email
AUTH_OIDC_USER_PROVISIONING true
AUTH_OIDC_TEAM_SYNC true
AUTH_OIDC_BUTTON_LABEL Single Sign-On
AUTH_PUBLIC_URL required with OIDC, base of the redirect URI

Redirect URI to register with the IdP: <AUTH_PUBLIC_URL>/api/v1alpha1/auth/oidc/callback. Replicas must share AUTH_SESSION_SECRET. Details in docs/AUTHENTICATION.md.

Security

  • PKCE S256 with a confidential client; state bound to the encrypted transaction cookie and compared in constant time; nonce checked in the id_token.
  • Only local absolute paths are accepted as post-login redirect, anything else becomes /.
  • No account linking by username or email: a local account (including admin) cannot be taken over by claiming its name.
  • The last enabled Administrators member is never removed by a sync; the local admin is never removed.
  • Fail closed: errors redirect to /login?error=<constant code>, nothing coming from the IdP is reflected.
  • Logs never contain the client secret, code, verifier or tokens (checked in the manual run).
  • The local admin keeps working when the IdP is down.
  • Known limits: an existing Tracker session survives an IdP-side disable until AUTH_SESSION_TTL; one login transaction per browser (a second tab replaces the first).

Checks

  • go build, go vet, go test ./... -count=1 and go test -race on auth, stores and server: green. gofmt -l lists only internal/utils/utils_test.go, untouched by this branch.
  • golangci-lint: 2 findings, same as the base branch. gosec (G103 and G115 excluded): 0 issues. govulncheck: 0 vulnerabilities reached by the code.
  • Dependencies: go-oidc/v3 v3.21.0, golang.org/x/oauth2 v0.37.0, go-jose/v4 v4.1.4 (indirect).
  • Manual end to end against Dex v2.45.1 (mockCallback) and serv: redirect to Dex, login, callback, tracker_session issued, auth/me shows the OIDC user (source: oidc) with the mapped team; changing the teams' oidcGroups then logging in again moves the membership; tampered or missing state ends on oidc_state, IdP error on oidc_denied, //evil.com redirect becomes /; claim absent gives oidc_failed for a user with a mapped membership and a login with no mapped team otherwise; Dex stopped at startup gives a WARN and oidc_unavailable while 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

  • The web login page does not display ?error= codes yet (depends on feat(web): login page, auth context and admin pages #201, which already contains the SSO button). docs/AUTHENTICATION.md and README.md will conflict with feat(web): login page, auth context and admin pages #201 at rebase.
  • One login transaction per browser.
  • IdP-side disable does not revoke existing sessions.
  • TestOIDCCannotTakeOverAdmin failed once under heavy concurrent MongoDB load (environmental, not reproduced).
  • The last-admin guard is check-then-act, like UpdateUser.
  • Pre-registration of OIDC users when provisioning is disabled.
  • Userinfo fallback for groups if an IdP requires it.
  • Helm and compose values (PR 5).

Refs #196

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
@TartanLeGrand

Copy link
Copy Markdown
Contributor Author

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 go.mod, which this one does (new dependencies for OpenID Connect). The only known advisory on the module is GO-2026-5932, the unmaintained golang.org/x/crypto/openpgp package: golang.org/x/crypto v0.56.0 is already on main at the same version, the code does not call openpgp, and no fixed version exists. Same finding as on #200.

The three added dependencies have no advisory in OSV, and govulncheck ./... reports no vulnerability in called code:

Module Version
github.com/coreos/go-oidc/v3 v3.21.0
golang.org/x/oauth2 v0.37.0
github.com/go-jose/go-jose/v4 (indirect) v4.1.4

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.

@jplanckeel
jplanckeel merged commit 1b85a9a into BananaOps:main Oct 1, 2026
3 of 5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants