Skip to content

feat(auth): generic OIDC (Entra ID) with group-based RBAC - #14

Open
tanguyfalconnet wants to merge 4 commits into
BananaOps:mainfrom
tanguyfalconnet:feat/oidc-entra-group-rbac
Open

tanguyfalconnet wants to merge 4 commits into
BananaOps:mainfrom
tanguyfalconnet:feat/oidc-entra-group-rbac

Conversation

@tanguyfalconnet

@tanguyfalconnet tanguyfalconnet commented Oct 1, 2026 •

Copy link
Copy Markdown

Description

The built-in SSO only worked with the bundled Dex setup running on localhost, which made Offly impossible to deploy behind a real identity provider. This PR turns it into a generic OIDC integration, validated against Microsoft Entra ID, and adds group-based RBAC. It also fixes a few security issues in the token verification and login flow.

Why it did not work outside local Dex

  • redirect_uri (frontend and callback.go) and the post-login redirect were hardcoded to http://localhost:8080 / http://localhost:3000.
  • Provider endpoints were built Dex-style (<issuer>/auth, /token, /keys) instead of using OIDC discovery. Entra ID uses /oauth2/v2.0/authorize, /oauth2/v2.0/token and /discovery/v2.0/keys.
  • The groups scope was always requested, and Entra ID rejects it (AADSTS70011).
  • Admins could only be defined by email: the groups claim was extracted but never used.

Changes

OIDC, provider-agnostic

  • Endpoints come from <issuer>/.well-known/openid-configuration, with optional overrides (AUTH_AUTHORIZATION_URL, AUTH_TOKEN_URL, AUTH_JWKS_URL). If discovery is unavailable, the Dex layout is used as before.
  • New settings: AUTH_REDIRECT_URL, AUTH_POST_LOGIN_REDIRECT_URL, AUTH_SCOPES, AUTH_GROUPS_CLAIM. Defaults match the current local Dex setup.
  • The email is read from email, then from preferred_username / upn. By default, Entra ID only emits email when the optional claim is configured.

Group-based RBAC

  • AUTH_ADMIN_GROUPS grants the admin role (Entra ID: group object IDs). AUTH_ADMIN_GROUP, as documented in the previous README, is accepted as an alias. AUTH_ADMIN_EMAILS still works.
  • AUTH_ALLOWED_GROUPS restricts who can log in: users in none of these groups get a 403. Admins are always allowed.
  • AUTH_GROUPS_CLAIM=roles uses Entra ID app roles instead of groups.
  • The Entra ID groups overage (_claim_names, more than 200 groups) is detected and logged.

Security fixes

  • Audience was not checked for string aud. Entra ID sends aud as a string, and the code only checked the array form, then fell back to azp, which is absent from v2 ID tokens. As a result, an ID token issued for any other application of the same tenant was accepted. Verification now uses jwt.WithAudience and covers both forms.
  • Login CSRF and replay: added state, nonce and PKCE (S256). The login is now started by the backend (GET /api/v1/auth/login), so the frontend no longer builds provider URLs.
  • Signing algorithms: only asymmetric algorithms are accepted, which rules out HS256 / none confusion. exp is now required, with a 30s leeway.
  • /api/v1/auth/config is now JSON-encoded instead of built by string concatenation.

Helm chart

  • New auth: section. The client secret comes from existingSecret, or from an inline value for which the chart creates the Secret.
  • New extraEnv.
  • Nothing is rendered while auth.enabled: false, so existing installs are unaffected.

Docs

  • SSO-README.md is rewritten. It now describes the actual confidential flow; the previous version documented a browser-side PKCE flow that the code did not implement.
  • Adds a step-by-step Entra ID guide (app registration, groups claim, optional email claim, group assignment), the full variable reference, and a troubleshooting table.

Type of change

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • This change requires a documentation update

Behaviour changes to be aware of (not breaking for a standard setup):

  • Tokens whose aud does not contain AUTH_CLIENT_ID are now rejected. This was the intended behaviour, and Dex already emits the right audience.
  • Tokens without exp are rejected.
  • When AUTH_CLIENT_ID is unset, its default is now offly instead of wirety, which matches the bundled Dex config.

How Has This Been Tested?

  • Unit tests: 22 new tests in backend/internal/auth:
    • token verification: Entra-style string aud, array aud, foreign audience rejected (regression test for the bug above), wrong issuer, expired or missing exp, HS256 rejected, nonce check;
    • claims: email fallbacks, groups as array or string, custom claim, overage;
    • RBAC: admin by group or email, AUTH_ADMIN_GROUP alias, allowed groups, admin bypass.
  • End-to-end login flow against a fake OIDC token endpoint (httptest) issuing Entra-style ID tokens: the authorization request carries state, nonce and an S256 challenge; the callback checks the code exchange (client secret, PKCE verifier, redirect URI), sets the session cookie, provisions the user and redirects. The test also covers rejection of a forged state, a replayed nonce and a user outside the allowed groups.
  • go test ./..., go vet and golangci-lint run (0 issues).
  • Frontend: tsc --noEmit and eslint.
  • Helm: helm lint. With auth disabled, no AUTH_* variable is rendered. With Entra values (existingSecret), the env vars are correct, and the trailing / of publicUrl is handled. The Secret is created only for an inline clientSecret, and rendering fails with a clear message when neither secret option is set.

To reproduce with Entra ID, follow the Microsoft Entra ID section of SSO-README.md and set the variables shown there.

Checklist:

  • My code follows the style guidelines of this project
  • I have performed a self-review of my code
  • I have commented my code, particularly in hard-to-understand areas
  • I have made corresponding changes to the documentation
  • My changes generate no new warnings
  • I have added tests that prove my fix is effective or that my feature works
  • New and existing unit tests pass locally with my changes
  • Any dependent changes have been merged and published in downstream modules (N/A)

🤖 Generated with Claude Code

tanguyfalconnet and others added 4 commits October 1, 2026 11:27
The PR branch was cut before teams, countries and events landed. Merging main
in compiles and passes, but nothing covered the place where the two halves
actually meet: group-based roles deciding the new write paths.

- add cmd/server/rbac_test.go — a fake OIDC provider (discovery + JWKS) and
  real signed tokens, exercising rbacMiddleware end to end: events writable by
  any authorized member, teams and holidays still admin-only (now by group),
  and an identity outside AUTH_ALLOWED_GROUPS kept read-only
- SSO-README: the roles table predated events; state that /events is the one
  write open to every authorized account, and why
- README: untouched by the PR, so it still documented the Dex-only flow —
  document the provider-agnostic login and the six new AUTH_* variables
- CLAUDE.md: same, plus the default-deny ordering the events rule depends on

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
staticcheck QF1008: rsa.PrivateKey embeds rsa.PublicKey, so key.N and key.E
name the same fields as key.PublicKey.N / key.PublicKey.E. The report quoted
only the modulus line; the exponent one line below had the same defect and is
fixed too, otherwise CI would have gone red again on the next run.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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