From dbe22371fb3312e8dccb082c9676787a8947224d Mon Sep 17 00:00:00 2001 From: Tanguy Falconnet Date: Thu, 1 Oct 2026 11:27:35 +0200 Subject: [PATCH 1/3] feat(auth): generic OIDC (Entra ID) with group-based RBAC --- SSO-README.md | 295 ++++++++++---------------- backend/cmd/server/main.go | 18 +- backend/internal/auth/callback.go | 209 ++++++++---------- backend/internal/auth/claims.go | 68 ++++++ backend/internal/auth/claims_test.go | 116 ++++++++++ backend/internal/auth/config.go | 114 ++++++++++ backend/internal/auth/handler.go | 77 +++---- backend/internal/auth/login.go | 95 +++++++++ backend/internal/auth/login_test.go | 211 ++++++++++++++++++ backend/internal/auth/middleware.go | 46 ++-- backend/internal/auth/oidc.go | 157 +++++++++----- backend/internal/auth/oidc_test.go | 126 +++++++++++ frontend/src/App.tsx | 1 + frontend/src/auth.ts | 20 +- helm/offly/templates/_helpers.tpl | 12 ++ helm/offly/templates/auth-secret.yaml | 11 + helm/offly/templates/deployment.yaml | 33 +++ helm/offly/values.yaml | 30 +++ 18 files changed, 1193 insertions(+), 446 deletions(-) create mode 100644 backend/internal/auth/claims.go create mode 100644 backend/internal/auth/claims_test.go create mode 100644 backend/internal/auth/config.go create mode 100644 backend/internal/auth/login.go create mode 100644 backend/internal/auth/login_test.go create mode 100644 backend/internal/auth/oidc_test.go create mode 100644 helm/offly/templates/auth-secret.yaml diff --git a/SSO-README.md b/SSO-README.md index 89f75a0..e8b9ea8 100644 --- a/SSO-README.md +++ b/SSO-README.md @@ -1,119 +1,121 @@ -# SSO Integration avec Dex +# Intégration SSO (OIDC) ## Vue d'ensemble -Offly intègre l'authentification SSO via Dex (OIDC provider) avec gestion automatique des utilisateurs et contrôle d'accès basé sur les rôles (RBAC). +Offly s'authentifie auprès de n'importe quel fournisseur **OpenID Connect** : +Dex (environnement de dev fourni), **Microsoft Entra ID**, Keycloak… Les +utilisateurs sont créés automatiquement à la première connexion, et les droits +(RBAC) sont attribués **par groupe** et/ou par email. ## Architecture ``` -┌─────────────┐ ┌──────────┐ ┌──────────────┐ ┌──────────┐ -│ Browser │─────▶│ Dex │─────▶│ Backend │─────▶│ SQLite │ -│ (Frontend) │◀─────│ (OIDC) │◀─────│ (Go + gRPC) │◀─────│ DB │ -└─────────────┘ └──────────┘ └──────────────┘ └──────────┘ - PKCE Flow ID Token JWT Verify User Storage +┌──────────┐ 1. /api/v1/auth/login ┌──────────────┐ 2. authorize (state, nonce, PKCE) ┌────────────┐ +│ Browser │─────────────────────────▶│ Backend │────────────────────────────────────▶│ Fournisseur│ +│(Frontend)│◀─────────────────────────│ (Go + gRPC) │◀────────────────────────────────────│ OIDC │ +└──────────┘ 5. cookie HttpOnly └──────────────┘ 3-4. callback → échange du code └────────────┘ + auth_token (client secret + PKCE) ``` -## Flux d'authentification +Flux *authorization code* **confidentiel**, entièrement piloté par le backend : -1. **Login**: L'utilisateur clique sur "Login" → redirection vers Dex -2. **Authentification Dex**: Saisie des credentials (vincent.team@bananaops.tech / test) -3. **Callback PKCE**: Échange du code contre un ID token + access token -4. **Auto-création**: Le frontend appelle `/api/v1/auth/ensure-user` avec le token -5. **Vérification JWT**: Le backend vérifie le token via JWKS de Dex -6. **Création utilisateur**: Si l'utilisateur n'existe pas, création automatique avec : - - Extraction du nom, email depuis les claims JWT - - Assignation au département `bananaops.tech` - - Assignation à la team `admin` ou `user` selon le groupe +1. Le bouton « Login » envoie le navigateur sur `GET /api/v1/auth/login`. +2. Le backend génère `state`, `nonce` et un vérificateur **PKCE (S256)**, les + garde dans des cookies HttpOnly éphémères, puis redirige vers l'endpoint + d'autorisation du fournisseur (obtenu par **discovery OIDC**). +3. Le fournisseur rappelle `GET /api/v1/auth/callback?code=…&state=…`. +4. Le backend vérifie le `state` (CSRF), échange le code (client secret + + `code_verifier`), puis vérifie l'ID token : signature (JWKS, algorithmes + asymétriques uniquement), `iss`, `aud`, `exp` et `nonce`. +5. Il applique `AUTH_ALLOWED_GROUPS`, crée/met à jour l'utilisateur, pose le + cookie de session `auth_token` (HttpOnly, Secure) et redirige vers + `AUTH_POST_LOGIN_REDIRECT_URL`. -## Groupes et rôles +Le frontend ne connaît ni l'issuer ni le client : il appelle seulement +`/api/v1/auth/login`, `/api/v1/auth/me` et `/api/v1/auth/logout`. -| Groupe Dex | Team Offly | Permissions | -|------------|------------|-------------| -| `admin` | admin | Tout (organisation, holidays, users, absences) | -| `user` | user | Son profil + ses absences uniquement | -| (aucun) | user | Son profil + ses absences uniquement | +## Rôles et RBAC -## Permissions RBAC +| Identité | Rôle Offly | Droits | +|----------|-----------|--------| +| Membre d'un groupe de `AUTH_ADMIN_GROUPS`, ou email dans `AUTH_ADMIN_EMAILS` | `admin` | Tout (organisation, jours fériés, utilisateurs, absences) | +| Autre utilisateur autorisé | `user` | Lecture de tout ; écriture de son profil et de ses absences uniquement | +| Hors `AUTH_ALLOWED_GROUPS` (si défini) | — | Connexion refusée (403) | +| Non connecté | — | Lecture seule (GET) | -### Utilisateurs non connectés -- ✅ Lecture seule (GET) -- ❌ Modification/Création/Suppression - -### Utilisateurs connectés (role: user) -- ✅ GET : Toutes les données -- ✅ PUT : Son profil uniquement (`/api/v1/users/{son_id}`) -- ✅ POST/PUT/DELETE : Ses absences uniquement -- ❌ Modification de l'organisation (departments, teams) -- ❌ Modification des holidays - -### Administrateurs (role: admin) -- ✅ Accès complet à toutes les routes +Les groupes sont lus dans le claim `AUTH_GROUPS_CLAIM` (`groups` par défaut ; +`roles` pour s'appuyer sur les *app roles* Entra ID). ## Configuration -### Variables d'environnement (.env) +| Variable | Défaut | Description | +|----------|--------|-------------| +| `AUTH_ENABLED` | `false` | Active le SSO | +| `AUTH_ISSUER_URL` | `http://localhost:5556/dex` | Issuer OIDC | +| `AUTH_CLIENT_ID` | `offly` | Client ID | +| `AUTH_CLIENT_SECRET` | — | Client secret (obligatoire) | +| `AUTH_REDIRECT_URL` | `http://localhost:8080/api/v1/auth/callback` | Redirect URI déclarée chez le fournisseur | +| `AUTH_POST_LOGIN_REDIRECT_URL` | `http://localhost:3000/` | Page d'arrivée après connexion | +| `AUTH_SCOPES` | `openid profile email groups` | Scopes demandés (Entra ID : `openid profile email`) | +| `AUTH_GROUPS_CLAIM` | `groups` | Claim portant les groupes | +| `AUTH_ADMIN_GROUPS` | — | Groupes administrateurs, séparés par des virgules (`AUTH_ADMIN_GROUP` accepté) | +| `AUTH_ALLOWED_GROUPS` | — | Groupes autorisés à se connecter ; vide = tout utilisateur authentifié | +| `AUTH_ADMIN_EMAILS` | — | Emails administrateurs (`ADMIN_EMAILS` accepté) | +| `AUTH_AUTHORIZATION_URL` / `AUTH_TOKEN_URL` / `AUTH_JWKS_URL` | discovery | Surcharges des endpoints (sinon `/.well-known/openid-configuration`, puis convention Dex) | +| `AUTH_JWKS_CACHE_TTL` | `3600` | Rafraîchissement du JWKS (secondes) | + +## Microsoft Entra ID + +### 1. App registration + +Dans **Entra ID → App registrations → New registration** : + +- **Supported account types** : *Single tenant*. +- **Redirect URI** : plateforme **Web**, `https:///api/v1/auth/callback`. +- **Certificates & secrets** : créer un *client secret* → `AUTH_CLIENT_SECRET`. +- **Token configuration** : + - **Add groups claim** → *Groups assigned to the application*, format + **Group ID** pour l'ID token. Limiter aux groupes assignés évite le + dépassement (*overage*) au-delà de 200 groupes, où Entra ne liste plus les + groupes dans le token. + - **Add optional claim** → ID token → `email` (sinon Offly utilise + `preferred_username`, l'UPN). +- **Enterprise applications → Offly → Users and groups** : assigner les groupes + (admins et utilisateurs). Avec *Assignment required = Yes*, Entra refuse + lui-même la connexion aux non-membres. + +### 2. Variables ```bash -# Activer le SSO AUTH_ENABLED=true - -# Configuration Dex -AUTH_ISSUER_URL=http://localhost:5556/dex -AUTH_CLIENT_ID=offly -AUTH_JWKS_CACHE_TTL=3600 - -# Règles d'assignation des groupes -AUTH_DOMAIN_DEPARTMENT=bananaops.tech -AUTH_ADMIN_EMAILS=elie.copter@bananaops.tech -AUTH_ADMIN_GROUP=admin -AUTH_GROUP_ADMIN=admin -AUTH_GROUP_USER=user -``` - -### Dex Configuration (dex/config.yaml) - -```yaml -staticClients: -- id: offly - name: 'Offly Application' - public: true - redirectURIs: - - 'http://localhost:3000/' - -connectors: -- type: mockCallback - id: mock-elie-admin - name: Elie (Admin) - config: - username: "elie.copter" - email: "elie.copter@bananaops.tech" - groups: ["admin"] - -- type: mockCallback - id: mock-vincent-user - name: Vincent (User) - config: - username: "vincent.team" - email: "vincent.team@bananaops.tech" - groups: ["user"] +AUTH_ISSUER_URL=https://login.microsoftonline.com//v2.0 +AUTH_CLIENT_ID= +AUTH_CLIENT_SECRET= +AUTH_REDIRECT_URL=https://offly.example.com/api/v1/auth/callback +AUTH_POST_LOGIN_REDIRECT_URL=https://offly.example.com/ +AUTH_SCOPES="openid profile email" +AUTH_ADMIN_GROUPS= +AUTH_ALLOWED_GROUPS= ``` -## Utilisateurs de test - -| Email | Mot de passe | Rôle | Groupe | -|-------|-------------|------|--------| -| vincent.team@bananaops.tech | test | User | user | -| elie.copter@bananaops.tech | test | Admin | admin | +Les endpoints (`/oauth2/v2.0/authorize`, `/oauth2/v2.0/token`, +`/discovery/v2.0/keys`) sont découverts automatiquement depuis l'issuer. -## Démarrage +### 3. Helm -### Option 1: Script automatique -```bash -./start-sso.sh +```yaml +auth: + enabled: true + issuerUrl: https://login.microsoftonline.com//v2.0 + clientId: + existingSecret: offly-oidc # clé "client-secret" + publicUrl: https://offly.example.com + scopes: "openid profile email" + adminGroups: [] + allowedGroups: [] ``` -### Option 2: Manuel +## Dex (développement local) ```bash # Terminal 1 - Dex @@ -124,104 +126,35 @@ cd backend export AUTH_ENABLED=true export AUTH_ISSUER_URL=http://localhost:5556/dex export AUTH_CLIENT_ID=offly -export AUTH_JWKS_CACHE_TTL=3600 +export AUTH_CLIENT_SECRET= +export AUTH_ADMIN_GROUPS=admin export STORAGE_TYPE=sqlite export SQLITE_DB_PATH=./offly.db go run ./cmd/server # Terminal 3 - Frontend -cd frontend -npm run dev +cd frontend && npm run dev ``` -## Test du flux complet - -1. Ouvrir http://localhost:3000 -2. Cliquer sur "Login" dans la navbar -3. Sélectionner "Elie (Admin)" ou "Vincent (User)" -4. Entrer le mot de passe: `test` -5. Vérifier l'affichage du nom + badge "Admin" dans la navbar -6. Vérifier que l'utilisateur apparaît dans l'onglet "Users" -7. Tester les permissions selon le rôle - -### Test permissions Admin (Elie) -- ✅ Créer/modifier departments et teams -- ✅ Créer/modifier holidays -- ✅ Créer/modifier users -- ✅ Créer/modifier absences - -### Test permissions User (Vincent) -- ✅ Voir toutes les données -- ✅ Modifier son profil uniquement -- ✅ Créer/modifier ses absences -- ❌ Modifier l'organisation → 403 Forbidden -- ❌ Modifier les holidays → 403 Forbidden - -## Endpoints API - -### Auth -- `GET /api/v1/auth/config` - Configuration SSO (public) -- `POST /api/v1/auth/ensure-user` - Création automatique utilisateur (Bearer token) - -### Protected (RBAC) -- `GET /api/v1/*` - Lecture seule pour tous -- `POST/PUT/DELETE /api/v1/users/{id}` - Propriétaire ou admin -- `POST/PUT/DELETE /api/v1/absences` - Propriétaire ou admin -- `POST/PUT/DELETE /api/v1/departments` - Admin uniquement -- `POST/PUT/DELETE /api/v1/teams` - Admin uniquement -- `POST/PUT/DELETE /api/v1/holidays` - Admin uniquement - -## Sécurité - -### PKCE (Proof Key for Code Exchange) -- Protection contre les attaques d'interception de code -- Code verifier stocké en sessionStorage -- Code challenge envoyé à Dex - -### JWT Verification -- Vérification de la signature via JWKS de Dex -- Validation issuer, audience, expiration -- Cache JWKS pour performance - -### Token Storage -- ID Token: localStorage (utilisé pour auth backend) -- Access Token: localStorage (optionnel) -- Code Verifier: sessionStorage (temporaire pour PKCE) +Les valeurs par défaut (`AUTH_REDIRECT_URL`, `AUTH_POST_LOGIN_REDIRECT_URL`, +`AUTH_SCOPES` avec `groups`) correspondent à cette configuration. + +## Endpoints + +- `GET /api/v1/auth/config` — configuration SSO (public) +- `GET /api/v1/auth/login` — démarre la connexion +- `GET /api/v1/auth/callback` — retour du fournisseur +- `GET /api/v1/auth/me` — utilisateur courant et rôle +- `POST /api/v1/auth/logout` — supprime la session locale +- `POST /api/v1/auth/ensure-user` — provisionne l'utilisateur d'un Bearer token ## Dépannage -### "Unregistered redirect_uri" -- Vérifier que `redirectURIs` dans dex/config.yaml contient `http://localhost:3000/` -- Rebuild Dex: `cd dex && docker-compose build --no-cache && docker-compose up` - -### "Invalid client_id" -- Vérifier que `AUTH_CLIENT_ID=offly` correspond au client dans dex/config.yaml -- Rebuild Dex après modification de la config - -### "Failed to verify token" -- Vérifier que `AUTH_ISSUER_URL=http://localhost:5556/dex` est correct -- Vérifier que Dex est démarré et accessible -- Vérifier les logs backend pour plus de détails - -### Utilisateur non créé automatiquement -- Vérifier les logs backend après login -- Vérifier que `/api/v1/auth/ensure-user` est appelé (DevTools Network) -- Vérifier que le token contient `email` claim - -## Architecture technique - -### Frontend (React + TypeScript) -- `src/auth.ts`: Gestion PKCE, tokens, décodage JWT -- `src/components/Login.tsx`: UI login/logout avec affichage utilisateur/rôle -- `src/api.ts`: Injection du Bearer token dans les requêtes - -### Backend (Go + gRPC) -- `internal/auth/oidc.go`: Vérificateur OIDC avec JWKS -- `internal/auth/handler.go`: Création automatique utilisateur + assignation groupes -- `internal/auth/middleware.go`: RBAC middleware -- `cmd/server/main.go`: Application du middleware selon AUTH_ENABLED - -### Dex (OIDC Provider) -- Port 5556 -- Mock connectors pour émission de groupes -- Configuration statique pour dev/test +| Symptôme | Cause probable | +|----------|----------------| +| `AADSTS50011` (redirect URI mismatch) | `AUTH_REDIRECT_URL` différente de la redirect URI enregistrée (plateforme **Web**) | +| `AADSTS70011` (invalid scope) | `groups` présent dans `AUTH_SCOPES` : le retirer pour Entra ID | +| « Invalid login state » | Cookies bloqués, ou plus de 10 min entre `/login` et le retour | +| « Email claim missing » | Ni `email` ni `preferred_username` au format email dans le token | +| Utilisateur toujours `user` | Groupe absent du token (groups claim non configuré, overage) ou `AUTH_ADMIN_GROUPS` ne contient pas l'**object ID** | +| 403 « not a member of an allowed group » | Utilisateur hors `AUTH_ALLOWED_GROUPS` | diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index 4b09b20..204fe70 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -170,18 +170,18 @@ func startRESTGateway(store storage.Storage, grpcAddr, httpAddr string) error { // Auth config endpoint (always available for the frontend to know if SSO is enabled) mainHandler.HandleFunc("/api/v1/auth/config", func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") - issuer := os.Getenv("AUTH_ISSUER_URL") - clientID := os.Getenv("AUTH_CLIENT_ID") - if issuer == "" { - issuer = "" - } - if clientID == "" { - clientID = "" - } - _, _ = w.Write([]byte("{\"enabled\":" + map[bool]string{true: "true", false: "false"}[authEnabled] + ",\"issuerUrl\":\"" + issuer + "\",\"clientId\":\"" + clientID + "\"}")) + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "enabled": authEnabled, + "issuerUrl": os.Getenv("AUTH_ISSUER_URL"), + "clientId": os.Getenv("AUTH_CLIENT_ID"), + // The browser starts the login here; the backend builds the provider + // URL (state, nonce, PKCE) from the discovered endpoints. + "loginUrl": "/api/v1/auth/login", + }) }) if authEnabled { + mainHandler.Handle("/api/v1/auth/login", auth.LoginHandler(v)) mainHandler.Handle("/api/v1/auth/callback", auth.CallbackHandler(store, v)) mainHandler.Handle("/api/v1/auth/me", corsMiddleware(auth.MeHandler(v))) mainHandler.Handle("/api/v1/auth/logout", corsMiddleware(auth.LogoutHandler())) diff --git a/backend/internal/auth/callback.go b/backend/internal/auth/callback.go index 99226be..de82ad9 100644 --- a/backend/internal/auth/callback.go +++ b/backend/internal/auth/callback.go @@ -1,213 +1,192 @@ package auth import ( + "crypto/subtle" "encoding/json" "fmt" "io" + "log" "net/http" "net/url" "os" - "strings" "time" "absence-management/internal/storage" - - "github.com/google/uuid" ) -// CallbackHandler handles the OAuth2 callback from Dex -// It exchanges the authorization code for tokens using the client secret +// CallbackHandler handles the OAuth2 callback from the OIDC provider: it checks +// the state, exchanges the authorization code (client secret + PKCE verifier) +// for tokens, verifies the ID token (signature, iss, aud, exp, nonce), enforces +// AUTH_ALLOWED_GROUPS, provisions the user and sets the session cookie. func CallbackHandler(store storage.Storage, v *Verifier) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - // Get authorization code from query params + if v == nil || v.tokenURL == "" { + http.Error(w, "Auth not configured", http.StatusServiceUnavailable) + return + } + + // Provider-side errors (e.g. user cancelled, missing consent). + if e := r.URL.Query().Get("error"); e != "" { + http.Error(w, fmt.Sprintf("Login failed: %s %s", e, r.URL.Query().Get("error_description")), http.StatusUnauthorized) + return + } + code := r.URL.Query().Get("code") if code == "" { http.Error(w, "Missing authorization code", http.StatusBadRequest) return } - // Exchange code for tokens - issuerURL := os.Getenv("AUTH_ISSUER_URL") - clientID := os.Getenv("AUTH_CLIENT_ID") - clientSecret := os.Getenv("AUTH_CLIENT_SECRET") - - if issuerURL == "" || clientID == "" || clientSecret == "" { - http.Error(w, "Auth not configured", http.StatusInternalServerError) + // CSRF protection: the state returned by the provider must match ours. + expectedState := readFlowCookie(r, stateCookieName) + gotState := r.URL.Query().Get("state") + if expectedState == "" || subtle.ConstantTimeCompare([]byte(expectedState), []byte(gotState)) != 1 { + http.Error(w, "Invalid login state — please retry", http.StatusBadRequest) return } + nonce := readFlowCookie(r, nonceCookieName) + pkceVerifier := readFlowCookie(r, pkceVerifierCookieName) + for _, name := range []string{stateCookieName, nonceCookieName, pkceVerifierCookieName} { + clearFlowCookie(w, name) + } - // Validate issuerURL is a proper http/https URL before use - parsedIssuer, err := url.Parse(issuerURL) - if err != nil || (parsedIssuer.Scheme != "http" && parsedIssuer.Scheme != "https") || parsedIssuer.Host == "" { - http.Error(w, "Invalid auth issuer URL", http.StatusInternalServerError) + clientSecret := os.Getenv("AUTH_CLIENT_SECRET") + if clientSecret == "" { + http.Error(w, "Auth not configured", http.StatusInternalServerError) return } - // Prepare token request - tokenURL := strings.TrimRight(issuerURL, "/") + "/token" data := url.Values{} data.Set("grant_type", "authorization_code") data.Set("code", code) - data.Set("client_id", clientID) + data.Set("client_id", v.clientID) data.Set("client_secret", clientSecret) - data.Set("redirect_uri", "http://localhost:8080/api/v1/auth/callback") + data.Set("redirect_uri", redirectURL()) + if pkceVerifier != "" { + data.Set("code_verifier", pkceVerifier) + } - // Make token request using a client with timeout client := &http.Client{Timeout: 10 * time.Second} - resp, err := client.PostForm(tokenURL, data) //nolint:gosec // URL is validated above and comes from operator config + resp, err := client.PostForm(v.tokenURL, data) //nolint:gosec // token URL comes from operator config / provider discovery if err != nil { - http.Error(w, fmt.Sprintf("Failed to exchange token: %v", err), http.StatusInternalServerError) + http.Error(w, "Failed to exchange token", http.StatusBadGateway) + log.Printf("auth: token exchange failed: %v", err) return } defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { - body, _ := io.ReadAll(resp.Body) - http.Error(w, fmt.Sprintf("Token exchange failed: %s", string(body)), http.StatusUnauthorized) + body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) + log.Printf("auth: token exchange returned HTTP %d: %s", resp.StatusCode, string(body)) + http.Error(w, "Token exchange failed", http.StatusUnauthorized) return } - // Parse token response var tokenResp struct { - IDToken string `json:"id_token"` - AccessToken string `json:"access_token"` - RefreshToken string `json:"refresh_token"` - ExpiresIn int `json:"expires_in"` + IDToken string `json:"id_token"` + ExpiresIn int `json:"expires_in"` } if err := json.NewDecoder(resp.Body).Decode(&tokenResp); err != nil { http.Error(w, "Failed to parse token response", http.StatusInternalServerError) return } - if tokenResp.IDToken == "" { http.Error(w, "No ID token in response", http.StatusInternalServerError) return } - // Verify the ID token - claims, err := v.VerifyToken(tokenResp.IDToken) + claims, err := v.VerifyIDToken(tokenResp.IDToken, nonce) if err != nil { - http.Error(w, fmt.Sprintf("Invalid token: %v", err), http.StatusUnauthorized) + log.Printf("auth: invalid ID token: %v", err) + http.Error(w, "Invalid token", http.StatusUnauthorized) return } - // Extract user info from claims - email, _ := claims["email"].(string) + email := EmailFromClaims(claims) if email == "" { http.Error(w, "Email claim missing", http.StatusBadRequest) return } + groups := GroupsFromClaims(claims) + if !IsAllowedIdentity(email, groups) { + log.Printf("auth: access denied for %s (not in AUTH_ALLOWED_GROUPS)", email) + http.Error(w, "Access denied: you are not a member of a group allowed to use Offly", http.StatusForbidden) + return + } - // Use name from claims, fallback to username from email - name, _ := claims["name"].(string) - if name == "" { - // Try preferred_username - if username, ok := claims["preferred_username"].(string); ok && username != "" { - name = username - } else { - // Extract username from email (e.g., vincent.team@bananaops.tech -> vincent.team) - if atIndex := strings.IndexByte(email, '@'); atIndex > 0 { - name = email[:atIndex] - } else { - name = email - } - } - } - - // Check if user exists - users, _ := store.GetUsers() - var existing *storage.User - for _, u := range users { - if u.Email == email { - existing = u - break - } - } - - if existing == nil { - // Create new user without department or team assignment - newUser := &storage.User{ - ID: uuid.New().String(), - Name: name, - Email: email, - } - if err := store.CreateUser(newUser); err != nil { - http.Error(w, "Failed to create user", http.StatusInternalServerError) - return - } - } else { - // Update existing user's name in case it changed - existing.Name = name - _ = store.UpdateUser(existing) - } - - // Set secure HTTP-only cookie with the ID token + if _, err := upsertUser(store, email, NameFromClaims(claims, email)); err != nil { + http.Error(w, "Failed to create user", http.StatusInternalServerError) + return + } + + maxAge := tokenResp.ExpiresIn + if maxAge <= 0 { + maxAge = 3600 + } // Requires HTTPS in production (Secure: true enforces TLS) http.SetCookie(w, &http.Cookie{ - Name: "auth_token", + Name: authTokenCookieName, Value: tokenResp.IDToken, Path: "/", - MaxAge: tokenResp.ExpiresIn, + MaxAge: maxAge, HttpOnly: true, Secure: true, SameSite: http.SameSiteLaxMode, }) - // Redirect to frontend - http.Redirect(w, r, "http://localhost:3000/?logged_in=true", http.StatusFound) + http.Redirect(w, r, postLoginRedirect(), http.StatusFound) + } +} + +// postLoginRedirect appends logged_in=true to AUTH_POST_LOGIN_REDIRECT_URL. +func postLoginRedirect() string { + target := postLoginURL() + u, err := url.Parse(target) + if err != nil { + return target } + q := u.Query() + q.Set("logged_in", "true") + u.RawQuery = q.Encode() + return u.String() } -// MeHandler returns the current user info from the cookie +// MeHandler returns the current user info from the session cookie. func MeHandler(v *Verifier) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") - - // Get token from cookie - cookie, err := r.Cookie("auth_token") - if err != nil || cookie.Value == "" { + unauthenticated := func() { w.WriteHeader(http.StatusUnauthorized) _ = json.NewEncoder(w).Encode(map[string]interface{}{"authenticated": false}) - return } - // Verify token + cookie, err := r.Cookie(authTokenCookieName) + if err != nil || cookie.Value == "" || v == nil { + unauthenticated() + return + } claims, err := v.VerifyToken(cookie.Value) if err != nil { - w.WriteHeader(http.StatusUnauthorized) - _ = json.NewEncoder(w).Encode(map[string]interface{}{"authenticated": false}) + unauthenticated() return } - email, _ := claims["email"].(string) + email := EmailFromClaims(claims) + groups := GroupsFromClaims(claims) + if email == "" || !IsAllowedIdentity(email, groups) { + unauthenticated() + return + } name, _ := claims["name"].(string) if name == "" { name = email } - role := "user" - - // Check if user is admin based on AUTH_ADMIN_EMAILS env var - adminEmails := os.Getenv("AUTH_ADMIN_EMAILS") - if adminEmails == "" { - adminEmails = os.Getenv("ADMIN_EMAILS") // Fallback - } - - if adminEmails != "" { - adminList := strings.Split(adminEmails, ",") - for _, admin := range adminList { - if strings.TrimSpace(admin) == email { - role = "admin" - break - } - } - } - _ = json.NewEncoder(w).Encode(map[string]interface{}{ "authenticated": true, "email": email, "name": name, - "role": role, + "role": RoleFor(email, groups), }) } } @@ -216,7 +195,7 @@ func MeHandler(v *Verifier) http.HandlerFunc { func LogoutHandler() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { http.SetCookie(w, &http.Cookie{ - Name: "auth_token", + Name: authTokenCookieName, Value: "", Path: "/", MaxAge: -1, diff --git a/backend/internal/auth/claims.go b/backend/internal/auth/claims.go new file mode 100644 index 0000000..c4551b5 --- /dev/null +++ b/backend/internal/auth/claims.go @@ -0,0 +1,68 @@ +package auth + +import ( + "log" + "strings" +) + +// EmailFromClaims returns the user's email. Providers do not all emit the +// "email" claim (Entra ID only does when the optional claim is configured and +// the account has a mail attribute), so fall back to preferred_username / upn +// when they look like an email address. +func EmailFromClaims(claims map[string]interface{}) string { + for _, key := range []string{"email", "preferred_username", "upn"} { + if v, _ := claims[key].(string); strings.Contains(v, "@") { + return v + } + } + return "" +} + +// NameFromClaims returns a display name: "name", then "preferred_username", +// then the local part of the email. +func NameFromClaims(claims map[string]interface{}, email string) string { + if name, _ := claims["name"].(string); name != "" { + return name + } + if username, _ := claims["preferred_username"].(string); username != "" { + return username + } + if at := strings.IndexByte(email, '@'); at > 0 { + return email[:at] + } + return email +} + +// GroupsFromClaims returns the groups carried by the configured groups claim +// (AUTH_GROUPS_CLAIM, default "groups"). Accepts a JSON array or a single string. +// +// Entra ID: when a user belongs to too many groups the token carries a +// "_claim_names" overage pointer instead of the list; this is logged and the +// user is treated as having no group — configure the app registration to emit +// only the groups assigned to the application to avoid it. +func GroupsFromClaims(claims map[string]interface{}) []string { + claim := groupsClaim() + var groups []string + switch raw := claims[claim].(type) { + case []interface{}: + for _, g := range raw { + if s, ok := g.(string); ok && s != "" { + groups = append(groups, s) + } + } + case []string: + groups = append(groups, raw...) + case string: + if raw != "" { + groups = append(groups, raw) + } + } + if groups == nil { + if names, ok := claims["_claim_names"].(map[string]interface{}); ok { + if _, overage := names[claim]; overage { + log.Printf("auth: %q claim overage (too many groups) — no groups taken into account", claim) + } + } + } + return groups +} diff --git a/backend/internal/auth/claims_test.go b/backend/internal/auth/claims_test.go new file mode 100644 index 0000000..b1f52ca --- /dev/null +++ b/backend/internal/auth/claims_test.go @@ -0,0 +1,116 @@ +package auth + +import ( + "reflect" + "testing" +) + +func TestEmailFromClaims(t *testing.T) { + tests := []struct { + name string + claims map[string]interface{} + want string + }{ + {"email claim", map[string]interface{}{"email": "a@x.io", "preferred_username": "b@x.io"}, "a@x.io"}, + {"entra without email: preferred_username", map[string]interface{}{"preferred_username": "jane@contoso.com"}, "jane@contoso.com"}, + {"upn fallback", map[string]interface{}{"upn": "bob@contoso.com"}, "bob@contoso.com"}, + {"non-email username ignored", map[string]interface{}{"preferred_username": "jane"}, ""}, + {"none", map[string]interface{}{}, ""}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := EmailFromClaims(tt.claims); got != tt.want { + t.Errorf("EmailFromClaims() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestNameFromClaims(t *testing.T) { + if got := NameFromClaims(map[string]interface{}{"name": "Jane Doe"}, "jane@x.io"); got != "Jane Doe" { + t.Errorf("got %q", got) + } + if got := NameFromClaims(map[string]interface{}{}, "jane@x.io"); got != "jane" { + t.Errorf("got %q", got) + } +} + +func TestGroupsFromClaims(t *testing.T) { + t.Run("array", func(t *testing.T) { + got := GroupsFromClaims(map[string]interface{}{"groups": []interface{}{"g1", "g2", 3}}) + if !reflect.DeepEqual(got, []string{"g1", "g2"}) { + t.Errorf("got %v", got) + } + }) + t.Run("single string", func(t *testing.T) { + got := GroupsFromClaims(map[string]interface{}{"groups": "g1"}) + if !reflect.DeepEqual(got, []string{"g1"}) { + t.Errorf("got %v", got) + } + }) + t.Run("custom claim (e.g. Entra app roles)", func(t *testing.T) { + t.Setenv("AUTH_GROUPS_CLAIM", "roles") + got := GroupsFromClaims(map[string]interface{}{"roles": []interface{}{"Offly.Admin"}, "groups": []interface{}{"ignored"}}) + if !reflect.DeepEqual(got, []string{"Offly.Admin"}) { + t.Errorf("got %v", got) + } + }) + t.Run("entra overage yields no group", func(t *testing.T) { + got := GroupsFromClaims(map[string]interface{}{"_claim_names": map[string]interface{}{"groups": "src1"}}) + if got != nil { + t.Errorf("got %v, want nil", got) + } + }) +} + +func TestIsAdminIdentity(t *testing.T) { + t.Setenv("AUTH_ADMIN_GROUPS", "admins-oid, other-admins") + t.Setenv("AUTH_ADMIN_EMAILS", "Boss@X.io") + + if !IsAdminIdentity("someone@x.io", []string{"users", "admins-oid"}) { + t.Error("member of an admin group must be admin") + } + if !IsAdminIdentity("boss@x.io", nil) { + t.Error("admin email (case-insensitive) must be admin") + } + if IsAdminIdentity("someone@x.io", []string{"users"}) { + t.Error("non admin must not be admin") + } +} + +func TestIsAdminIdentity_SingularAlias(t *testing.T) { + t.Setenv("AUTH_ADMIN_GROUPS", "") + t.Setenv("AUTH_ADMIN_GROUP", "admin") + if !IsAdminIdentity("a@x.io", []string{"admin"}) { + t.Error("AUTH_ADMIN_GROUP alias must be honoured") + } +} + +func TestIsAllowedIdentity(t *testing.T) { + t.Run("no restriction", func(t *testing.T) { + t.Setenv("AUTH_ALLOWED_GROUPS", "") + if !IsAllowedIdentity("a@x.io", nil) { + t.Error("everyone allowed when AUTH_ALLOWED_GROUPS is empty") + } + }) + t.Run("restricted", func(t *testing.T) { + t.Setenv("AUTH_ALLOWED_GROUPS", "users-oid") + t.Setenv("AUTH_ADMIN_GROUPS", "admins-oid") + if !IsAllowedIdentity("a@x.io", []string{"users-oid"}) { + t.Error("member of an allowed group must be allowed") + } + if !IsAllowedIdentity("a@x.io", []string{"admins-oid"}) { + t.Error("admins are always allowed") + } + if IsAllowedIdentity("a@x.io", []string{"guests"}) { + t.Error("non member must be denied") + } + }) +} + +func TestRoleFor(t *testing.T) { + t.Setenv("AUTH_ADMIN_GROUPS", "admins-oid") + if RoleFor("a@x.io", []string{"admins-oid"}) != "admin" || RoleFor("a@x.io", nil) != "user" { + t.Error("unexpected role mapping") + } +} diff --git a/backend/internal/auth/config.go b/backend/internal/auth/config.go new file mode 100644 index 0000000..30457be --- /dev/null +++ b/backend/internal/auth/config.go @@ -0,0 +1,114 @@ +package auth + +import ( + "os" + "strings" +) + +// Auth settings are read from the environment at call time (same convention as +// the rest of the package), which keeps them trivially overridable in tests. +// +// Provider-agnostic: the defaults match the bundled Dex setup, every value can +// be overridden to target any OIDC provider (Microsoft Entra ID, Keycloak, …). +// +// AUTH_REDIRECT_URL OIDC redirect URI registered at the provider +// (default http://localhost:8080/api/v1/auth/callback) +// AUTH_POST_LOGIN_REDIRECT_URL where the browser lands after login +// (default http://localhost:3000/) +// AUTH_SCOPES space-separated scopes (default "openid profile email groups"; +// Entra ID: "openid profile email" — "groups" is not a valid Entra scope) +// AUTH_GROUPS_CLAIM claim holding the user's groups (default "groups") +// AUTH_ADMIN_GROUPS comma-separated groups granted the admin role +// (Entra ID: group object IDs). AUTH_ADMIN_GROUP is accepted as an alias. +// AUTH_ALLOWED_GROUPS comma-separated groups allowed to log in; empty = any authenticated user +// AUTH_ADMIN_EMAILS comma-separated admin emails (ADMIN_EMAILS fallback) + +const ( + defaultRedirectURL = "http://localhost:8080/api/v1/auth/callback" + defaultPostLoginURL = "http://localhost:3000/" + defaultScopes = "openid profile email groups" + defaultGroupsClaim = "groups" + callbackCookiePath = "/api/v1/auth" + stateCookieName = "oidc_state" + nonceCookieName = "oidc_nonce" + pkceVerifierCookieName = "oidc_pkce" + authTokenCookieName = "auth_token" + loginFlowCookieLifetime = 600 // seconds allowed to complete the provider login +) + +func envOr(name, def string) string { + if v := strings.TrimSpace(os.Getenv(name)); v != "" { + return v + } + return def +} + +// csvEnv returns the trimmed, non-empty values of the first non-empty variable. +func csvEnv(names ...string) []string { + for _, name := range names { + raw := os.Getenv(name) + if strings.TrimSpace(raw) == "" { + continue + } + var out []string + for _, v := range strings.Split(raw, ",") { + if v = strings.TrimSpace(v); v != "" { + out = append(out, v) + } + } + return out + } + return nil +} + +func redirectURL() string { return envOr("AUTH_REDIRECT_URL", defaultRedirectURL) } +func postLoginURL() string { return envOr("AUTH_POST_LOGIN_REDIRECT_URL", defaultPostLoginURL) } +func scopes() string { return envOr("AUTH_SCOPES", defaultScopes) } +func groupsClaim() string { return envOr("AUTH_GROUPS_CLAIM", defaultGroupsClaim) } + +func adminGroups() []string { return csvEnv("AUTH_ADMIN_GROUPS", "AUTH_ADMIN_GROUP") } +func allowedGroups() []string { return csvEnv("AUTH_ALLOWED_GROUPS") } +func adminEmails() []string { return csvEnv("AUTH_ADMIN_EMAILS", "ADMIN_EMAILS") } + +func intersects(a, b []string) bool { + for _, x := range a { + for _, y := range b { + if x == y { + return true + } + } + } + return false +} + +// IsAdminIdentity reports whether the identity holds the admin role: its email +// is listed in AUTH_ADMIN_EMAILS, or it belongs to one of AUTH_ADMIN_GROUPS. +func IsAdminIdentity(email string, groups []string) bool { + if email != "" { + for _, a := range adminEmails() { + if strings.EqualFold(a, email) { + return true + } + } + } + return intersects(groups, adminGroups()) +} + +// IsAllowedIdentity reports whether the identity may use the application. +// With AUTH_ALLOWED_GROUPS unset every authenticated user is allowed; otherwise +// the user must belong to one of them (admins are always allowed). +func IsAllowedIdentity(email string, groups []string) bool { + allowed := allowedGroups() + if len(allowed) == 0 { + return true + } + return intersects(groups, allowed) || IsAdminIdentity(email, groups) +} + +// RoleFor returns the Offly role ("admin" or "user") of an identity. +func RoleFor(email string, groups []string) string { + if IsAdminIdentity(email, groups) { + return "admin" + } + return "user" +} diff --git a/backend/internal/auth/handler.go b/backend/internal/auth/handler.go index 23c3354..706be39 100644 --- a/backend/internal/auth/handler.go +++ b/backend/internal/auth/handler.go @@ -36,60 +36,47 @@ func EnsureUserHandler(store storage.Storage, v *Verifier) http.HandlerFunc { return } - email, _ := claims["email"].(string) + email := EmailFromClaims(claims) if email == "" { - // Dex may include email in ID token when scope includes email w.WriteHeader(http.StatusBadRequest) _ = json.NewEncoder(w).Encode(map[string]string{"error": "email claim missing"}) return } - - // Use name from claims, fallback to username from email - name, _ := claims["name"].(string) - if name == "" { - // Try preferred_username - if username, ok := claims["preferred_username"].(string); ok && username != "" { - name = username - } else { - // Extract username from email (e.g., vincent.team@bananaops.tech -> vincent.team) - if atIndex := strings.IndexByte(email, '@'); atIndex > 0 { - name = email[:atIndex] - } else { - name = email - } - } - } - - // Check if user exists by email - users, _ := store.GetUsers() - var existing *storage.User - for _, u := range users { - if u.Email == email { - existing = u - break - } + if !IsAllowedIdentity(email, GroupsFromClaims(claims)) { + w.WriteHeader(http.StatusForbidden) + _ = json.NewEncoder(w).Encode(map[string]string{"error": "not a member of an allowed group"}) + return } - var u *storage.User - if existing == nil { - // Create new user without department or team assignment - u = &storage.User{ - ID: uuid.New().String(), - Name: name, - Email: email, - } - if err := store.CreateUser(u); err != nil { - w.WriteHeader(http.StatusInternalServerError) - _ = json.NewEncoder(w).Encode(map[string]string{"error": "failed to create user"}) - return - } - } else { - // Update existing user's name in case it changed - existing.Name = name - _ = store.UpdateUser(existing) - u = existing + u, err := upsertUser(store, email, NameFromClaims(claims, email)) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + _ = json.NewEncoder(w).Encode(map[string]string{"error": "failed to create user"}) + return } _ = json.NewEncoder(w).Encode(resp{ID: u.ID, Name: u.Name, Email: u.Email, Country: u.Country}) } } + +// upsertUser returns the user matching email (case-insensitive), creating it +// without department/team on first login and refreshing its name otherwise. +func upsertUser(store storage.Storage, email, name string) (*storage.User, error) { + users, _ := store.GetUsers() + for _, u := range users { + if strings.EqualFold(u.Email, email) { + u.Name = name + _ = store.UpdateUser(u) + return u, nil + } + } + u := &storage.User{ + ID: uuid.New().String(), + Name: name, + Email: email, + } + if err := store.CreateUser(u); err != nil { + return nil, err + } + return u, nil +} diff --git a/backend/internal/auth/login.go b/backend/internal/auth/login.go new file mode 100644 index 0000000..bc23aa5 --- /dev/null +++ b/backend/internal/auth/login.go @@ -0,0 +1,95 @@ +package auth + +import ( + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "net/http" + "net/url" + "strings" +) + +// LoginHandler starts the OIDC authorization code flow: it generates state, +// nonce and a PKCE verifier, keeps them in short-lived HttpOnly cookies, and +// redirects the browser to the provider's authorization endpoint. +func LoginHandler(v *Verifier) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if v == nil || v.authorizationURL == "" { + http.Error(w, "Auth not configured", http.StatusServiceUnavailable) + return + } + + state, err1 := randomToken(32) + nonce, err2 := randomToken(32) + verifier, err3 := randomToken(48) + if err1 != nil || err2 != nil || err3 != nil { + http.Error(w, "Failed to start login", http.StatusInternalServerError) + return + } + + setFlowCookie(w, stateCookieName, state) + setFlowCookie(w, nonceCookieName, nonce) + setFlowCookie(w, pkceVerifierCookieName, verifier) + + challenge := sha256.Sum256([]byte(verifier)) + params := url.Values{} + params.Set("client_id", v.clientID) + params.Set("redirect_uri", redirectURL()) + params.Set("response_type", "code") + params.Set("response_mode", "query") + params.Set("scope", scopes()) + params.Set("state", state) + params.Set("nonce", nonce) + params.Set("code_challenge", base64.RawURLEncoding.EncodeToString(challenge[:])) + params.Set("code_challenge_method", "S256") + + sep := "?" + if strings.Contains(v.authorizationURL, "?") { + sep = "&" + } + http.Redirect(w, r, v.authorizationURL+sep+params.Encode(), http.StatusFound) + } +} + +func randomToken(n int) (string, error) { + b := make([]byte, n) + if _, err := rand.Read(b); err != nil { + return "", err + } + return base64.RawURLEncoding.EncodeToString(b), nil +} + +// Login-flow cookies are scoped to the auth endpoints and live only long enough +// to complete the provider login. SameSite=Lax lets them ride along the +// top-level GET redirect back from the provider. +func setFlowCookie(w http.ResponseWriter, name, value string) { + http.SetCookie(w, &http.Cookie{ + Name: name, + Value: value, + Path: callbackCookiePath, + MaxAge: loginFlowCookieLifetime, + HttpOnly: true, + Secure: true, + SameSite: http.SameSiteLaxMode, + }) +} + +func clearFlowCookie(w http.ResponseWriter, name string) { + http.SetCookie(w, &http.Cookie{ + Name: name, + Value: "", + Path: callbackCookiePath, + MaxAge: -1, + HttpOnly: true, + Secure: true, + SameSite: http.SameSiteLaxMode, + }) +} + +func readFlowCookie(r *http.Request, name string) string { + c, err := r.Cookie(name) + if err != nil { + return "" + } + return c.Value +} diff --git a/backend/internal/auth/login_test.go b/backend/internal/auth/login_test.go new file mode 100644 index 0000000..2528fa6 --- /dev/null +++ b/backend/internal/auth/login_test.go @@ -0,0 +1,211 @@ +package auth + +import ( + "crypto/sha256" + "encoding/base64" + "encoding/json" + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "absence-management/internal/storage" + + jwt "github.com/golang-jwt/jwt/v5" +) + +// fakeProvider is a minimal OIDC token endpoint that checks the PKCE verifier +// and returns an ID token carrying the given claims. +func fakeProvider(t *testing.T, sign func(jwt.MapClaims) string, claims jwt.MapClaims) *httptest.Server { + t.Helper() + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + t.Errorf("parse form: %v", err) + } + if r.Form.Get("code") != "the-code" || r.Form.Get("client_secret") != "s3cret" { + w.WriteHeader(http.StatusBadRequest) + return + } + if r.Form.Get("code_verifier") == "" || r.Form.Get("redirect_uri") != "https://offly.example.com/api/v1/auth/callback" { + w.WriteHeader(http.StatusBadRequest) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]interface{}{"id_token": sign(claims), "expires_in": 3600}) + })) +} + +func setLoginEnv(t *testing.T) { + t.Setenv("AUTH_CLIENT_SECRET", "s3cret") + t.Setenv("AUTH_REDIRECT_URL", "https://offly.example.com/api/v1/auth/callback") + t.Setenv("AUTH_POST_LOGIN_REDIRECT_URL", "https://offly.example.com/") + t.Setenv("AUTH_SCOPES", "openid profile email") +} + +// login runs LoginHandler and returns its redirect URL and flow cookies. +func login(t *testing.T, v *Verifier) (*url.URL, []*http.Cookie) { + t.Helper() + rec := httptest.NewRecorder() + LoginHandler(v)(rec, httptest.NewRequest(http.MethodGet, "/api/v1/auth/login", nil)) + if rec.Code != http.StatusFound { + t.Fatalf("login status = %d", rec.Code) + } + loc, err := url.Parse(rec.Header().Get("Location")) + if err != nil { + t.Fatal(err) + } + return loc, rec.Result().Cookies() +} + +func TestLoginHandler_BuildsAuthorizationRequest(t *testing.T) { + setLoginEnv(t) + v := testVerifier(newTestKey(t)) + v.authorizationURL = "https://login.microsoftonline.com/tenant-id/oauth2/v2.0/authorize" + + loc, cookies := login(t, v) + q := loc.Query() + if loc.Host != "login.microsoftonline.com" || loc.Path != "/tenant-id/oauth2/v2.0/authorize" { + t.Errorf("unexpected authorization endpoint %s", loc) + } + for k, want := range map[string]string{ + "client_id": testClientID, + "redirect_uri": "https://offly.example.com/api/v1/auth/callback", + "response_type": "code", + "scope": "openid profile email", + "code_challenge_method": "S256", + } { + if q.Get(k) != want { + t.Errorf("%s = %q, want %q", k, q.Get(k), want) + } + } + + byName := map[string]string{} + for _, c := range cookies { + byName[c.Name] = c.Value + if !c.HttpOnly || !c.Secure { + t.Errorf("cookie %s must be HttpOnly and Secure", c.Name) + } + } + if q.Get("state") == "" || q.Get("state") != byName[stateCookieName] { + t.Error("state must be sent and stored in a cookie") + } + if q.Get("nonce") == "" || q.Get("nonce") != byName[nonceCookieName] { + t.Error("nonce must be sent and stored in a cookie") + } + sum := sha256.Sum256([]byte(byName[pkceVerifierCookieName])) + if q.Get("code_challenge") != base64.RawURLEncoding.EncodeToString(sum[:]) { + t.Error("code_challenge must be the S256 of the stored verifier") + } +} + +// callback replays the provider redirect with the flow cookies of a login. +func callback(t *testing.T, v *Verifier, store storage.Storage, state string, cookies []*http.Cookie) *httptest.ResponseRecorder { + t.Helper() + req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/callback?code=the-code&state="+url.QueryEscape(state), nil) + for _, c := range cookies { + req.AddCookie(c) + } + rec := httptest.NewRecorder() + CallbackHandler(store, v)(rec, req) + return rec +} + +func TestCallback_FullFlow(t *testing.T) { + setLoginEnv(t) + t.Setenv("AUTH_ALLOWED_GROUPS", "users-oid") + key := newTestKey(t) + v := testVerifier(key) + v.authorizationURL = "https://idp.example.com/authorize" + + loc, cookies := login(t, v) + nonce := loc.Query().Get("nonce") + + claims := baseClaims() + delete(claims, "email") // Entra without the optional email claim + claims["preferred_username"] = "jane@contoso.com" + claims["name"] = "Jane Doe" + claims["nonce"] = nonce + claims["groups"] = []interface{}{"users-oid"} + provider := fakeProvider(t, func(c jwt.MapClaims) string { return signRS256(t, key, c) }, claims) + defer provider.Close() + v.tokenURL = provider.URL + + store := storage.NewMemoryStorage() + rec := callback(t, v, store, loc.Query().Get("state"), cookies) + if rec.Code != http.StatusFound { + t.Fatalf("callback status = %d: %s", rec.Code, rec.Body.String()) + } + if got := rec.Header().Get("Location"); got != "https://offly.example.com/?logged_in=true" { + t.Errorf("post-login redirect = %q", got) + } + var session bool + for _, c := range rec.Result().Cookies() { + if c.Name == authTokenCookieName && c.Value != "" && c.HttpOnly && c.Secure { + session = true + } + } + if !session { + t.Error("session cookie not set") + } + users, _ := store.GetUsers() + if len(users) != 1 || users[0].Email != "jane@contoso.com" || users[0].Name != "Jane Doe" { + t.Errorf("user not provisioned: %+v", users) + } +} + +func TestCallback_RejectsStateMismatch(t *testing.T) { + setLoginEnv(t) + v := testVerifier(newTestKey(t)) + v.authorizationURL = "https://idp.example.com/authorize" + v.tokenURL = "https://idp.example.com/token" + + _, cookies := login(t, v) + if rec := callback(t, v, storage.NewMemoryStorage(), "forged-state", cookies); rec.Code != http.StatusBadRequest { + t.Fatalf("forged state: status = %d, want 400", rec.Code) + } + if rec := callback(t, v, storage.NewMemoryStorage(), "whatever", nil); rec.Code != http.StatusBadRequest { + t.Fatalf("no flow cookies: status = %d, want 400", rec.Code) + } +} + +func TestCallback_DeniesUserOutsideAllowedGroups(t *testing.T) { + setLoginEnv(t) + t.Setenv("AUTH_ALLOWED_GROUPS", "users-oid") + key := newTestKey(t) + v := testVerifier(key) + v.authorizationURL = "https://idp.example.com/authorize" + + loc, cookies := login(t, v) + claims := baseClaims() + claims["nonce"] = loc.Query().Get("nonce") + claims["groups"] = []interface{}{"guests"} + provider := fakeProvider(t, func(c jwt.MapClaims) string { return signRS256(t, key, c) }, claims) + defer provider.Close() + v.tokenURL = provider.URL + + store := storage.NewMemoryStorage() + if rec := callback(t, v, store, loc.Query().Get("state"), cookies); rec.Code != http.StatusForbidden { + t.Fatalf("status = %d, want 403", rec.Code) + } + if users, _ := store.GetUsers(); len(users) != 0 { + t.Error("denied user must not be provisioned") + } +} + +func TestCallback_RejectsReplayedNonce(t *testing.T) { + setLoginEnv(t) + key := newTestKey(t) + v := testVerifier(key) + v.authorizationURL = "https://idp.example.com/authorize" + + loc, cookies := login(t, v) + claims := baseClaims() + claims["nonce"] = "nonce-from-another-login" + provider := fakeProvider(t, func(c jwt.MapClaims) string { return signRS256(t, key, c) }, claims) + defer provider.Close() + v.tokenURL = provider.URL + + if rec := callback(t, v, storage.NewMemoryStorage(), loc.Query().Get("state"), cookies); rec.Code != http.StatusUnauthorized { + t.Fatalf("status = %d, want 401", rec.Code) + } +} diff --git a/backend/internal/auth/middleware.go b/backend/internal/auth/middleware.go index ccadcfa..d58806f 100644 --- a/backend/internal/auth/middleware.go +++ b/backend/internal/auth/middleware.go @@ -69,7 +69,7 @@ func AuthMiddleware(v *Verifier, store storage.Storage, required bool) func(http return } - email, _ := claims["email"].(string) + email := EmailFromClaims(claims) if email == "" { if required { w.Header().Set("Content-Type", "application/json") @@ -81,21 +81,26 @@ func AuthMiddleware(v *Verifier, store storage.Storage, required bool) func(http return } - // Extract groups from claims - var groups []string - if groupsRaw, ok := claims["groups"].([]interface{}); ok { - for _, g := range groupsRaw { - if s, ok := g.(string); ok { - groups = append(groups, s) - } + // Groups from the configured claim (AUTH_GROUPS_CLAIM). + groups := GroupsFromClaims(claims) + + // Members of no allowed group are treated as unauthenticated. + if !IsAllowedIdentity(email, groups) { + if required { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusForbidden) + _ = json.NewEncoder(w).Encode(map[string]string{"error": "not a member of an allowed group"}) + return } + next.ServeHTTP(w, r) + return } // Find user ID by email users, _ := store.GetUsers() var userID string for _, u := range users { - if u.Email == email { + if strings.EqualFold(u.Email, email) { userID = u.ID break } @@ -134,31 +139,14 @@ func GetUserID(r *http.Request) string { return "" } -// IsAdmin checks if the user email is in the admin list from environment variable. +// IsAdmin checks whether the authenticated user is an admin: email listed in +// AUTH_ADMIN_EMAILS, or member of one of AUTH_ADMIN_GROUPS. func IsAdmin(r *http.Request) bool { - adminEmails := os.Getenv("AUTH_ADMIN_EMAILS") - if adminEmails == "" { - adminEmails = os.Getenv("ADMIN_EMAILS") // Fallback - } - - if adminEmails == "" { - return false - } - userEmail := GetUserEmail(r) if userEmail == "" { return false } - - // Split comma-separated list of admin emails - adminList := strings.Split(adminEmails, ",") - for _, admin := range adminList { - if strings.TrimSpace(admin) == userEmail { - return true - } - } - - return false + return IsAdminIdentity(userEmail, GetUserGroups(r)) } // RequireAdmin middleware returns 403 if user is not admin. diff --git a/backend/internal/auth/oidc.go b/backend/internal/auth/oidc.go index ff70d10..b1e555c 100644 --- a/backend/internal/auth/oidc.go +++ b/backend/internal/auth/oidc.go @@ -2,8 +2,10 @@ package auth import ( "context" + "encoding/json" "errors" "fmt" + "log" "net/http" "os" "strings" @@ -13,40 +15,55 @@ import ( jwt "github.com/golang-jwt/jwt/v5" ) -// Verifier validates OIDC JWTs against a JWKS and basic claims. +// Verifier validates OIDC JWTs against the provider's JWKS and holds the +// provider endpoints used by the login flow. type Verifier struct { - jwks keyfunc.Keyfunc + keyFunc jwt.Keyfunc issuer string clientID string + + authorizationURL string + tokenURL string +} + +// Signing algorithms accepted for ID tokens ("none" and HMAC are rejected). +var allowedSigningMethods = []string{ + "RS256", "RS384", "RS512", + "PS256", "PS384", "PS512", + "ES256", "ES384", "ES512", } // NewVerifierFromEnv initializes a Verifier using environment variables. -// AUTH_ISSUER_URL: e.g. http://localhost:5556/dex -// AUTH_CLIENT_ID: OIDC client id (e.g. wirety) -// AUTH_JWKS_URL: optional, defaults to issuer + "/keys" +// +// AUTH_ISSUER_URL issuer, e.g. http://localhost:5556/dex or +// https://login.microsoftonline.com//v2.0 +// AUTH_CLIENT_ID OIDC client id (Entra ID: the application/client id) +// AUTH_AUTHORIZATION_URL optional override of the discovered authorization endpoint +// AUTH_TOKEN_URL optional override of the discovered token endpoint +// AUTH_JWKS_URL optional override of the discovered JWKS URI +// AUTH_JWKS_CACHE_TTL JWKS refresh interval in seconds (default 3600) +// +// Endpoints come from the issuer's OpenID discovery document +// (/.well-known/openid-configuration). If discovery is unavailable they +// fall back to the Dex layout (/auth, /token, /keys). func NewVerifierFromEnv() (*Verifier, error) { - issuer := os.Getenv("AUTH_ISSUER_URL") - if issuer == "" { - issuer = "http://localhost:5556/dex" - } - clientID := os.Getenv("AUTH_CLIENT_ID") - if clientID == "" { - clientID = "wirety" - } + issuer := strings.TrimRight(envOr("AUTH_ISSUER_URL", "http://localhost:5556/dex"), "/") + clientID := envOr("AUTH_CLIENT_ID", "offly") - jwksURL := os.Getenv("AUTH_JWKS_URL") - if jwksURL == "" { - // Dex publishes JWKS at /keys - jwksURL = strings.TrimRight(issuer, "/") + "/keys" + disc, err := discover(issuer) + if err != nil { + log.Printf("auth: OIDC discovery failed (%v) — falling back to Dex-style endpoints", err) + disc = &discoveryDocument{} } - // Create a keyfunc that auto-refreshes JWKS using defaults or TTL override. - ttlStr := os.Getenv("AUTH_JWKS_CACHE_TTL") - if ttlStr == "" { - ttlStr = "3600" // default 60 minutes - } - ttl, _ := time.ParseDuration(ttlStr + "s") + authorizationURL := firstNonEmpty(os.Getenv("AUTH_AUTHORIZATION_URL"), disc.AuthorizationEndpoint, issuer+"/auth") + tokenURL := firstNonEmpty(os.Getenv("AUTH_TOKEN_URL"), disc.TokenEndpoint, issuer+"/token") + jwksURL := firstNonEmpty(os.Getenv("AUTH_JWKS_URL"), disc.JWKSURI, issuer+"/keys") + ttl, err := time.ParseDuration(envOr("AUTH_JWKS_CACHE_TTL", "3600") + "s") + if err != nil || ttl <= 0 { + ttl = time.Hour + } kf, err := keyfunc.NewDefaultOverrideCtx(context.Background(), []string{jwksURL}, keyfunc.Override{ RefreshInterval: ttl, }) @@ -54,7 +71,48 @@ func NewVerifierFromEnv() (*Verifier, error) { return nil, fmt.Errorf("failed to create JWKS keyfunc: %w", err) } - return &Verifier{jwks: kf, issuer: issuer, clientID: clientID}, nil + v := newVerifier(kf.Keyfunc, issuer, clientID) + v.authorizationURL = authorizationURL + v.tokenURL = tokenURL + log.Printf("auth: OIDC issuer=%s authorization=%s token=%s jwks=%s", issuer, authorizationURL, tokenURL, jwksURL) + return v, nil +} + +func newVerifier(keyFunc jwt.Keyfunc, issuer, clientID string) *Verifier { + return &Verifier{keyFunc: keyFunc, issuer: issuer, clientID: clientID} +} + +type discoveryDocument struct { + Issuer string `json:"issuer"` + AuthorizationEndpoint string `json:"authorization_endpoint"` + TokenEndpoint string `json:"token_endpoint"` + JWKSURI string `json:"jwks_uri"` +} + +func discover(issuer string) (*discoveryDocument, error) { + client := &http.Client{Timeout: 10 * time.Second} + resp, err := client.Get(issuer + "/.well-known/openid-configuration") //nolint:gosec // issuer comes from operator config + if err != nil { + return nil, err + } + defer func() { _ = resp.Body.Close() }() + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("discovery returned HTTP %d", resp.StatusCode) + } + var doc discoveryDocument + if err := json.NewDecoder(resp.Body).Decode(&doc); err != nil { + return nil, fmt.Errorf("invalid discovery document: %w", err) + } + return &doc, nil +} + +func firstNonEmpty(values ...string) string { + for _, v := range values { + if strings.TrimSpace(v) != "" { + return v + } + } + return "" } // VerifyBearer extracts and verifies the JWT from the Authorization header. @@ -70,45 +128,36 @@ func (v *Verifier) VerifyBearer(r *http.Request) (jwt.MapClaims, error) { return v.VerifyToken(parts[1]) } -// VerifyToken validates signature and standard claims (iss, aud/azp, exp). +// VerifyToken validates the signature (allowed algorithms only) and the +// standard claims: iss must equal the issuer, aud must contain the client id +// (string or array form — Entra ID uses a string), exp is required. func (v *Verifier) VerifyToken(tokenString string) (jwt.MapClaims, error) { - token, err := jwt.Parse(tokenString, v.jwks.Keyfunc) + claims := jwt.MapClaims{} + token, err := jwt.ParseWithClaims(tokenString, claims, v.keyFunc, + jwt.WithValidMethods(allowedSigningMethods), + jwt.WithIssuer(v.issuer), + jwt.WithAudience(v.clientID), + jwt.WithExpirationRequired(), + jwt.WithLeeway(30*time.Second), + ) if err != nil { return nil, fmt.Errorf("token parse/verify failed: %w", err) } if !token.Valid { return nil, errors.New("invalid token") } + return claims, nil +} - claims, ok := token.Claims.(jwt.MapClaims) - if !ok { - return nil, errors.New("invalid claims type") - } - - // Basic claim checks - if iss, _ := claims["iss"].(string); iss == "" || !strings.EqualFold(iss, v.issuer) { - return nil, errors.New("issuer mismatch") +// VerifyIDToken verifies an ID token issued by the login flow, including its +// nonce (replay protection). +func (v *Verifier) VerifyIDToken(tokenString, expectedNonce string) (jwt.MapClaims, error) { + claims, err := v.VerifyToken(tokenString) + if err != nil { + return nil, err } - // Either aud contains clientID or azp equals clientID - if aud, ok := claims["aud"].([]interface{}); ok { - found := false - for _, a := range aud { - if s, _ := a.(string); s == v.clientID { - found = true - break - } - } - if !found { - // fallback to string aud - if s, _ := claims["aud"].(string); s != v.clientID { - return nil, errors.New("audience mismatch") - } - } - } else if azp, _ := claims["azp"].(string); azp != "" { - if azp != v.clientID { - return nil, errors.New("authorized party mismatch") - } + if nonce, _ := claims["nonce"].(string); expectedNonce == "" || nonce != expectedNonce { + return nil, errors.New("nonce mismatch") } - return claims, nil } diff --git a/backend/internal/auth/oidc_test.go b/backend/internal/auth/oidc_test.go new file mode 100644 index 0000000..79e131c --- /dev/null +++ b/backend/internal/auth/oidc_test.go @@ -0,0 +1,126 @@ +package auth + +import ( + "crypto/rand" + "crypto/rsa" + "strings" + "testing" + "time" + + jwt "github.com/golang-jwt/jwt/v5" +) + +const ( + testIssuer = "https://login.microsoftonline.com/tenant-id/v2.0" + testClientID = "offly-client-id" +) + +func newTestKey(t *testing.T) *rsa.PrivateKey { + t.Helper() + k, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatal(err) + } + return k +} + +func testVerifier(key *rsa.PrivateKey) *Verifier { + return newVerifier(func(*jwt.Token) (interface{}, error) { return &key.PublicKey, nil }, testIssuer, testClientID) +} + +func signRS256(t *testing.T, key *rsa.PrivateKey, claims jwt.MapClaims) string { + t.Helper() + s, err := jwt.NewWithClaims(jwt.SigningMethodRS256, claims).SignedString(key) + if err != nil { + t.Fatal(err) + } + return s +} + +func baseClaims() jwt.MapClaims { + return jwt.MapClaims{ + "iss": testIssuer, + "aud": testClientID, // Entra ID: audience is a plain string + "exp": time.Now().Add(time.Hour).Unix(), + "email": "jane@contoso.com", + } +} + +func TestVerifyToken_EntraStringAudience(t *testing.T) { + key := newTestKey(t) + if _, err := testVerifier(key).VerifyToken(signRS256(t, key, baseClaims())); err != nil { + t.Fatalf("valid Entra-style token rejected: %v", err) + } +} + +func TestVerifyToken_ArrayAudience(t *testing.T) { + key := newTestKey(t) + c := baseClaims() + c["aud"] = []string{"other", testClientID} + if _, err := testVerifier(key).VerifyToken(signRS256(t, key, c)); err != nil { + t.Fatalf("token with array aud rejected: %v", err) + } +} + +// Regression: a string "aud" used to skip the audience check entirely, so a +// token issued by the same tenant for ANOTHER application was accepted. +func TestVerifyToken_RejectsForeignAudience(t *testing.T) { + key := newTestKey(t) + c := baseClaims() + c["aud"] = "another-app-client-id" + if _, err := testVerifier(key).VerifyToken(signRS256(t, key, c)); err == nil { + t.Fatal("token issued for another application must be rejected") + } +} + +func TestVerifyToken_RejectsWrongIssuer(t *testing.T) { + key := newTestKey(t) + c := baseClaims() + c["iss"] = "https://login.microsoftonline.com/other-tenant/v2.0" + if _, err := testVerifier(key).VerifyToken(signRS256(t, key, c)); err == nil { + t.Fatal("token from another issuer must be rejected") + } +} + +func TestVerifyToken_RejectsExpiredAndMissingExp(t *testing.T) { + key := newTestKey(t) + c := baseClaims() + c["exp"] = time.Now().Add(-time.Hour).Unix() + if _, err := testVerifier(key).VerifyToken(signRS256(t, key, c)); err == nil { + t.Fatal("expired token must be rejected") + } + delete(c, "exp") + if _, err := testVerifier(key).VerifyToken(signRS256(t, key, c)); err == nil { + t.Fatal("token without exp must be rejected") + } +} + +func TestVerifyToken_RejectsHMAC(t *testing.T) { + key := newTestKey(t) + // Algorithm confusion: an HS256 token must never be accepted. + s, err := jwt.NewWithClaims(jwt.SigningMethodHS256, baseClaims()).SignedString([]byte("secret")) + if err != nil { + t.Fatal(err) + } + if _, err := testVerifier(key).VerifyToken(s); err == nil { + t.Fatal("HS256 token must be rejected") + } +} + +func TestVerifyIDToken_Nonce(t *testing.T) { + key := newTestKey(t) + v := testVerifier(key) + c := baseClaims() + c["nonce"] = "n-123" + tok := signRS256(t, key, c) + + if _, err := v.VerifyIDToken(tok, "n-123"); err != nil { + t.Fatalf("matching nonce rejected: %v", err) + } + if _, err := v.VerifyIDToken(tok, "other"); err == nil || !strings.Contains(err.Error(), "nonce") { + t.Fatalf("mismatching nonce must be rejected, got %v", err) + } + if _, err := v.VerifyIDToken(tok, ""); err == nil { + t.Fatal("missing expected nonce must be rejected") + } +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index f0145ef..b7c545f 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -20,6 +20,7 @@ function App() { enabled: !!cfg.enabled, issuerUrl: cfg.issuerUrl || '', clientId: cfg.clientId || '', + loginUrl: cfg.loginUrl || '/api/v1/auth/login', }) if (cfg.enabled) await getCurrentUser() } diff --git a/frontend/src/auth.ts b/frontend/src/auth.ts index b619e28..87c0c49 100644 --- a/frontend/src/auth.ts +++ b/frontend/src/auth.ts @@ -1,8 +1,9 @@ -type AuthConfig = { enabled: boolean; issuerUrl: string; clientId: string } +type AuthConfig = { enabled: boolean; issuerUrl: string; clientId: string; loginUrl: string } let RUNTIME_AUTH_CONFIG: AuthConfig = { enabled: false, issuerUrl: '', clientId: '', + loginUrl: '/api/v1/auth/login', } export function setAuthConfig(c: Partial) { @@ -13,19 +14,12 @@ export function getAuthConfig(): AuthConfig { return RUNTIME_AUTH_CONFIG } -// Start login by redirecting to Dex via backend +// Start login: the backend builds the provider authorization request (discovered +// endpoint, configured redirect URI and scopes, state/nonce/PKCE) and redirects. export async function startLogin(): Promise { - const { issuerUrl, clientId } = getAuthConfig() - if (!issuerUrl || !clientId) throw new Error('SSO not configured') - - const params = new URLSearchParams({ - client_id: clientId, - redirect_uri: 'http://localhost:8080/api/v1/auth/callback', - response_type: 'code', - scope: 'openid profile email groups', - }) - - window.location.href = `${issuerUrl}/auth?${params.toString()}` + const { enabled, loginUrl } = getAuthConfig() + if (!enabled) throw new Error('SSO not configured') + window.location.href = loginUrl || '/api/v1/auth/login' } // Check if user just logged in (via query param from backend redirect) diff --git a/helm/offly/templates/_helpers.tpl b/helm/offly/templates/_helpers.tpl index 4b38d30..6da7d0b 100644 --- a/helm/offly/templates/_helpers.tpl +++ b/helm/offly/templates/_helpers.tpl @@ -60,3 +60,15 @@ Create the name of the service account to use {{- default "default" .Values.serviceAccount.name }} {{- end }} {{- end }} + +{{/* +Name of the Secret holding the OIDC client secret (auth.existingSecret, or the +Secret created by the chart from auth.clientSecret). +*/}} +{{- define "offly.authSecretName" -}} +{{- if .Values.auth.existingSecret }} +{{- .Values.auth.existingSecret }} +{{- else }} +{{- printf "%s-auth" (include "offly.fullname" .) }} +{{- end }} +{{- end }} diff --git a/helm/offly/templates/auth-secret.yaml b/helm/offly/templates/auth-secret.yaml new file mode 100644 index 0000000..283106a --- /dev/null +++ b/helm/offly/templates/auth-secret.yaml @@ -0,0 +1,11 @@ +{{- if and .Values.auth.enabled (not .Values.auth.existingSecret) }} +apiVersion: v1 +kind: Secret +metadata: + name: {{ include "offly.authSecretName" . }} + labels: + {{- include "offly.labels" . | nindent 4 }} +type: Opaque +stringData: + {{ .Values.auth.existingSecretKey | default "client-secret" }}: {{ required "auth.clientSecret (or auth.existingSecret) is required when auth.enabled" .Values.auth.clientSecret | quote }} +{{- end }} diff --git a/helm/offly/templates/deployment.yaml b/helm/offly/templates/deployment.yaml index c3847b6..7e343b0 100644 --- a/helm/offly/templates/deployment.yaml +++ b/helm/offly/templates/deployment.yaml @@ -64,6 +64,39 @@ spec: value: {{ .Values.service.http.port | quote }} - name: MCP_ENABLED value: {{ .Values.env.mcpEnabled | default false | quote }} + {{- with .Values.auth }} + {{- if .enabled }} + # --- OIDC SSO (see SSO-README.md) --- + - name: AUTH_ENABLED + value: "true" + - name: AUTH_ISSUER_URL + value: {{ required "auth.issuerUrl is required when auth.enabled" .issuerUrl | quote }} + - name: AUTH_CLIENT_ID + value: {{ required "auth.clientId is required when auth.enabled" .clientId | quote }} + - name: AUTH_CLIENT_SECRET + valueFrom: + secretKeyRef: + name: {{ include "offly.authSecretName" $ }} + key: {{ .existingSecretKey | default "client-secret" }} + - name: AUTH_REDIRECT_URL + value: {{ printf "%s/api/v1/auth/callback" (trimSuffix "/" (required "auth.publicUrl is required when auth.enabled" .publicUrl)) | quote }} + - name: AUTH_POST_LOGIN_REDIRECT_URL + value: {{ printf "%s/" (trimSuffix "/" .publicUrl) | quote }} + - name: AUTH_SCOPES + value: {{ .scopes | quote }} + - name: AUTH_GROUPS_CLAIM + value: {{ .groupsClaim | quote }} + - name: AUTH_ADMIN_GROUPS + value: {{ join "," .adminGroups | quote }} + - name: AUTH_ALLOWED_GROUPS + value: {{ join "," .allowedGroups | quote }} + - name: AUTH_ADMIN_EMAILS + value: {{ join "," .adminEmails | quote }} + {{- end }} + {{- end }} + {{- with .Values.extraEnv }} + {{- toYaml . | nindent 12 }} + {{- end }} resources: {{- toYaml .Values.resources | nindent 12 }} {{- with .Values.nodeSelector }} diff --git a/helm/offly/values.yaml b/helm/offly/values.yaml index bc7501e..9c09994 100644 --- a/helm/offly/values.yaml +++ b/helm/offly/values.yaml @@ -20,6 +20,36 @@ mongodb: replicaCount: 1 +# OIDC SSO with group-based RBAC — Dex, Microsoft Entra ID, Keycloak… (see SSO-README.md) +auth: + enabled: false + # Entra ID: https://login.microsoftonline.com//v2.0 + issuerUrl: "" + # Entra ID: the application (client) ID of the app registration + clientId: "" + # Client secret: reference an existing Secret (recommended), or set it inline + # and the chart creates the Secret (dev only). + existingSecret: "" + existingSecretKey: client-secret + clientSecret: "" + # Public URL of Offly. Redirect URI to register at the provider: + # /api/v1/auth/callback + publicUrl: "" + # Entra ID does not accept a "groups" scope (groups come from the app + # registration's "groups claim"); Dex needs it: "openid profile email groups". + scopes: "openid profile email" + # Claim carrying the groups (use "roles" to map Entra app roles instead). + groupsClaim: groups + # Groups granted the admin role (Entra ID: group object IDs). + adminGroups: [] + # Groups allowed to log in; empty = any authenticated user of the provider. + allowedGroups: [] + # Additional admins by email. + adminEmails: [] + +# Extra environment variables for the offly container. +extraEnv: [] + env: storageType: mongodb db: From 0aa4dcc64d00e27705f84536841f6ab29f5c14a1 Mon Sep 17 00:00:00 2001 From: jplanckeel Date: Thu, 1 Oct 2026 11:47:59 +0200 Subject: [PATCH 2/3] test(auth): pin group RBAC against the new write rules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- CLAUDE.md | 22 +++- README.md | 22 +++- SSO-README.md | 9 +- backend/cmd/server/rbac_test.go | 180 ++++++++++++++++++++++++++++++++ 4 files changed, 225 insertions(+), 8 deletions(-) create mode 100644 backend/cmd/server/rbac_test.go diff --git a/CLAUDE.md b/CLAUDE.md index 1a415c8..8204ed9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -61,16 +61,32 @@ Storage structs are plain Go (no proto tags); the service layer maps between `st ### Auth / RBAC -Off by default. `AUTH_ENABLED=true` turns on OIDC (Dex in dev, see `dex/` and `SSO-README.md`): tokens arrive as a `Bearer` header or an `auth_token` cookie, are verified against JWKS (`internal/auth/oidc.go`), and `AuthMiddleware` injects email/groups/id into the request context. +Off by default. `AUTH_ENABLED=true` turns on OIDC against **any provider** — Dex in dev (`dex/`), +Entra ID, Keycloak; see `SSO-README.md`. The backend owns the whole confidential authorization-code +flow: `GET /api/v1/auth/login` (`internal/auth/login.go`) mints state, nonce and a PKCE verifier, +redirects to the endpoint found by OIDC **discovery** (`internal/auth/oidc.go`), and the callback +exchanges the code and sets the `auth_token` cookie. The frontend knows only `loginUrl` from +`/api/v1/auth/config` — it never builds a provider URL itself. Tokens then arrive as a `Bearer` +header or that cookie, are verified against JWKS, and `AuthMiddleware` injects email/groups/id into +the request context. + +Identity rules live in `internal/auth/config.go`, all read from the environment at call time: +`AUTH_GROUPS_CLAIM` says which claim carries the groups, `AUTH_ADMIN_GROUPS` (alias +`AUTH_ADMIN_GROUP`) and `AUTH_ADMIN_EMAILS` grant admin, `AUTH_ALLOWED_GROUPS` gates access at all. +An identity outside the allowed groups is deliberately treated as **unauthenticated** rather than +rejected outright — it keeps read access and loses every write. `rbacMiddleware` in `backend/cmd/server/main.go` wraps the whole `/api/` mux and enforces, by URL path and HTTP method: - GET is always allowed, even unauthenticated -- writes require auth; admins (`AUTH_ADMIN_EMAILS`, or group `AUTH_ADMIN_GROUP`) bypass everything +- writes require auth; admins bypass everything - non-admins may only PUT/POST their own `/users/{id}` and only create/modify absences whose `userId` is theirs (POST bodies are read and re-wrapped to check this) +- `/events` writes are open to **any authenticated caller** — the one exception, an explicit allow + placed before the default deny - `/teams`, `/departments`, `/holidays` writes are admin-only +- anything else falls through to the default deny -This authorization logic lives in the HTTP layer, not in the services — the gRPC services themselves are unauthenticated. +This authorization logic lives in the HTTP layer, not in the services — the gRPC services themselves are unauthenticated. `cmd/server/rbac_test.go` stands up a fake OIDC provider (discovery + JWKS) and signs real tokens to pin the combination of group-based roles and those path rules; it is the only test that exercises `rbacMiddleware` end to end. `UpdateUserRequest` carries both `title` (field 5, the historical name) and `job_profile` (field 6); the service prefers `job_profile` and falls back to `title`. Before that field existed diff --git a/README.md b/README.md index 9f7ed32..0213bb8 100644 --- a/README.md +++ b/README.md @@ -186,6 +186,12 @@ helm upgrade offly offly/offly | `AUTH_CLIENT_SECRET` | OIDC client secret, used on the callback exchange | — | | `AUTH_JWKS_URL` | JWKS endpoint | `/keys` | | `AUTH_JWKS_CACHE_TTL` | JWKS cache lifetime, in seconds | `3600` | +| `AUTH_REDIRECT_URL` | Redirect URI registered at the provider | `http://localhost:8080/api/v1/auth/callback` | +| `AUTH_POST_LOGIN_REDIRECT_URL` | Where the browser lands after login | `http://localhost:3000/` | +| `AUTH_SCOPES` | Space-separated scopes (Entra ID: drop `groups`) | `openid profile email groups` | +| `AUTH_GROUPS_CLAIM` | Claim holding the user's groups | `groups` | +| `AUTH_ADMIN_GROUPS` | Comma-separated groups granted the `admin` role (`AUTH_ADMIN_GROUP` also read) | — | +| `AUTH_ALLOWED_GROUPS` | Comma-separated groups allowed to log in; empty = any authenticated user | — | | `AUTH_ADMIN_EMAILS` | Comma-separated emails granted the `admin` role (`ADMIN_EMAILS` also read) | — | | `MCP_ENABLED` | Expose the read-only MCP server at `/mcp` | `false` | @@ -194,16 +200,24 @@ back to the default. gRPC always binds to loopback — it is reached only by the ## 🔐 SSO Authentication -Offly supports optional SSO via [Dex](https://dexidp.io) (OIDC/PKCE flow). +Offly authenticates against any **OpenID Connect** provider — [Dex](https://dexidp.io) (bundled +for development), **Microsoft Entra ID**, Keycloak… The backend drives the whole confidential +authorization-code flow (state, nonce, PKCE); the frontend only ever calls `/api/v1/auth/login`. ``` -Browser ──PKCE──▶ Dex ──ID Token──▶ Backend ──JWT verify──▶ SQLite +Browser ──login──▶ Backend ──authorize (state, nonce, PKCE)──▶ OIDC provider + ◀── callback: code exchange, JWT verify, HttpOnly cookie ``` +Roles are granted **by group** (`AUTH_ADMIN_GROUPS`, read from the `AUTH_GROUPS_CLAIM` claim) +and/or by email (`AUTH_ADMIN_EMAILS`). + | Role | Permissions | |------|------------| -| `admin` | Full access — users, teams, holidays, absences | -| `user` | Read all · Edit own profile & absences only · Add and edit events | +| `admin` | Full access — users, teams, holidays, absences, events | +| `user` | Read all · Edit own profile & absences · Add and edit events | +| Outside `AUTH_ALLOWED_GROUPS` | Login refused (403) | +| Signed out | Read-only (GET) | See [SSO-README.md](SSO-README.md) for the full configuration guide. diff --git a/SSO-README.md b/SSO-README.md index e8b9ea8..e443547 100644 --- a/SSO-README.md +++ b/SSO-README.md @@ -39,13 +39,20 @@ Le frontend ne connaît ni l'issuer ni le client : il appelle seulement | Identité | Rôle Offly | Droits | |----------|-----------|--------| | Membre d'un groupe de `AUTH_ADMIN_GROUPS`, ou email dans `AUTH_ADMIN_EMAILS` | `admin` | Tout (organisation, jours fériés, utilisateurs, absences) | -| Autre utilisateur autorisé | `user` | Lecture de tout ; écriture de son profil et de ses absences uniquement | +| Autre utilisateur autorisé | `user` | Lecture de tout ; écriture de son profil, de ses absences, et des **événements** | | Hors `AUTH_ALLOWED_GROUPS` (si défini) | — | Connexion refusée (403) | | Non connecté | — | Lecture seule (GET) | Les groupes sont lus dans le claim `AUTH_GROUPS_CLAIM` (`groups` par défaut ; `roles` pour s'appuyer sur les *app roles* Entra ID). +`/api/v1/events` est la **seule écriture ouverte à tout compte autorisé** : un +repas d'équipe ou un midi jeux se propose, il ne s'administre pas. La règle est +une autorisation explicite placée avant le refus par défaut du `rbacMiddleware` — +la retirer ne libère pas l'endpoint, elle le ferme. Une identité hors +`AUTH_ALLOWED_GROUPS` est traitée comme anonyme : elle lit, elle n'écrit pas, +pas même un événement (`backend/cmd/server/rbac_test.go` épingle ces deux règles). + ## Configuration | Variable | Défaut | Description | diff --git a/backend/cmd/server/rbac_test.go b/backend/cmd/server/rbac_test.go new file mode 100644 index 0000000..b4307cf --- /dev/null +++ b/backend/cmd/server/rbac_test.go @@ -0,0 +1,180 @@ +package main + +import ( + "crypto/rand" + "crypto/rsa" + "encoding/base64" + "encoding/json" + "math/big" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "absence-management/internal/auth" + "absence-management/internal/storage" + + jwt "github.com/golang-jwt/jwt/v5" +) + +// Ces tests font se rencontrer les deux moitiés du produit : le RBAC par +// groupes (AUTH_ADMIN_GROUPS, AUTH_ALLOWED_GROUPS) et les écritures ouvertes +// par les écrans récents. Chacune se vérifie isolément ailleurs ; ici on épingle +// leur combinaison, qui est ce qu'une fusion peut casser sans qu'un compilateur +// ni un test unitaire ne s'en aperçoive. + +const testClientID = "offly-client-id" + +// fakeProvider sert un document de découverte OIDC et un JWKS portant la clé +// publique de test : `NewVerifierFromEnv` le consomme comme un vrai fournisseur. +func fakeProvider(t *testing.T, key *rsa.PrivateKey) string { + t.Helper() + + mux := http.NewServeMux() + server := httptest.NewServer(mux) + t.Cleanup(server.Close) + + mux.HandleFunc("/.well-known/openid-configuration", func(w http.ResponseWriter, _ *http.Request) { + _ = json.NewEncoder(w).Encode(map[string]string{ + "issuer": server.URL, + "authorization_endpoint": server.URL + "/auth", + "token_endpoint": server.URL + "/token", + "jwks_uri": server.URL + "/keys", + }) + }) + mux.HandleFunc("/keys", func(w http.ResponseWriter, _ *http.Request) { + _ = json.NewEncoder(w).Encode(map[string]any{"keys": []map[string]string{{ + "kty": "RSA", + "kid": "test-key", + "use": "sig", + "alg": "RS256", + "n": base64.RawURLEncoding.EncodeToString(key.PublicKey.N.Bytes()), + "e": base64.RawURLEncoding.EncodeToString(big.NewInt(int64(key.PublicKey.E)).Bytes()), + }}}) + }) + + return server.URL +} + +func tokenFor(t *testing.T, key *rsa.PrivateKey, issuer, email string, groups []string) string { + t.Helper() + + token := jwt.NewWithClaims(jwt.SigningMethodRS256, jwt.MapClaims{ + "iss": issuer, + "aud": testClientID, + "exp": time.Now().Add(time.Hour).Unix(), + "email": email, + "groups": groups, + }) + token.Header["kid"] = "test-key" + + signed, err := token.SignedString(key) + if err != nil { + t.Fatalf("sign: %v", err) + } + return signed +} + +// rbacUnderTest monte le middleware réel sur un stockage mémoire peuplé d'une +// personne, et renvoie la fonction qui joue une requête. +func rbacUnderTest(t *testing.T, key *rsa.PrivateKey, issuer string) func(method, path, token string) int { + t.Helper() + + store := storage.NewMemoryStorage() + if err := store.CreateUser(&storage.User{ID: "u-jane", Name: "Jane", Email: "jane@contoso.com"}); err != nil { + t.Fatalf("CreateUser: %v", err) + } + + // Sans ce drapeau, AuthMiddleware laisse passer sans identité : c'est aussi la + // raison pour laquelle main.go ne monte le RBAC que lorsqu'il est armé. + t.Setenv("AUTH_ENABLED", "true") + t.Setenv("AUTH_ISSUER_URL", issuer) + t.Setenv("AUTH_CLIENT_ID", testClientID) + verifier, err := auth.NewVerifierFromEnv() + if err != nil { + t.Fatalf("NewVerifierFromEnv: %v", err) + } + + handler := rbacMiddleware(store, verifier, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + })) + + return func(method, path, token string) int { + req := httptest.NewRequest(method, path, strings.NewReader(`{"name":"x","startDate":"2026-01-05"}`)) + if token != "" { + req.Header.Set("Authorization", "Bearer "+token) + } + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + return rec.Code + } +} + +func TestRBAC_EventsAreOpenToAnyAuthenticatedUser(t *testing.T) { + key, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatal(err) + } + issuer := fakeProvider(t, key) + t.Setenv("AUTH_ADMIN_GROUPS", "offly-admins") + + call := rbacUnderTest(t, key, issuer) + member := tokenFor(t, key, issuer, "jane@contoso.com", []string{"dev-team"}) + admin := tokenFor(t, key, issuer, "root@contoso.com", []string{"offly-admins"}) + + cases := []struct { + label string + method string + path string + token string + want int + }{ + // La lecture reste ouverte, y compris sans identité. + {"lecture anonyme des événements", "GET", "/api/v1/events", "", http.StatusOK}, + // La règle du jour : proposer un événement ne demande pas d'être admin. + {"création par un simple membre", "POST", "/api/v1/events", member, http.StatusOK}, + {"modification par un simple membre", "PUT", "/api/v1/events/e-1", member, http.StatusOK}, + {"suppression par un simple membre", "DELETE", "/api/v1/events/e-1", member, http.StatusOK}, + // … mais une identité reste exigée : un anonyme n'écrit pas. + {"création anonyme refusée", "POST", "/api/v1/events", "", http.StatusUnauthorized}, + // Les équipes, elles, restent administrées. + {"équipe créée par un membre : refus", "POST", "/api/v1/teams", member, http.StatusForbidden}, + {"équipe créée par un admin de groupe", "POST", "/api/v1/teams", admin, http.StatusOK}, + {"férié créé par un membre : refus", "POST", "/api/v1/holidays", member, http.StatusForbidden}, + } + + for _, c := range cases { + t.Run(c.label, func(t *testing.T) { + if got := call(c.method, c.path, c.token); got != c.want { + t.Errorf("%s %s = %d, attendu %d", c.method, c.path, got, c.want) + } + }) + } +} + +// Avec AUTH_ALLOWED_GROUPS, une identité hors des groupes autorisés est traitée +// comme anonyme : elle lit, mais n'écrit pas — pas même un événement, dont la +// règle est pourtant la plus permissive de l'application. +func TestRBAC_DisallowedGroupCannotWriteEvents(t *testing.T) { + key, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatal(err) + } + issuer := fakeProvider(t, key) + t.Setenv("AUTH_ALLOWED_GROUPS", "offly-users") + + call := rbacUnderTest(t, key, issuer) + outsider := tokenFor(t, key, issuer, "jane@contoso.com", []string{"another-tenant-group"}) + insider := tokenFor(t, key, issuer, "jane@contoso.com", []string{"offly-users"}) + + if got := call("GET", "/api/v1/events", outsider); got != http.StatusOK { + t.Errorf("lecture par une identité hors groupe = %d, attendu %d", got, http.StatusOK) + } + if got := call("POST", "/api/v1/events", outsider); got != http.StatusUnauthorized { + t.Errorf("écriture par une identité hors groupe = %d, attendu %d", got, http.StatusUnauthorized) + } + if got := call("POST", "/api/v1/events", insider); got != http.StatusOK { + t.Errorf("écriture par un membre autorisé = %d, attendu %d", got, http.StatusOK) + } +} From 3f5426b1f13348c3eeac0856dfed9f99b9ae2da5 Mon Sep 17 00:00:00 2001 From: jplanckeel Date: Thu, 1 Oct 2026 15:21:41 +0200 Subject: [PATCH 3/3] fix(test): drop the embedded PublicKey selector in the JWKS helper 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) --- backend/cmd/server/rbac_test.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/cmd/server/rbac_test.go b/backend/cmd/server/rbac_test.go index b4307cf..380cdc2 100644 --- a/backend/cmd/server/rbac_test.go +++ b/backend/cmd/server/rbac_test.go @@ -49,8 +49,8 @@ func fakeProvider(t *testing.T, key *rsa.PrivateKey) string { "kid": "test-key", "use": "sig", "alg": "RS256", - "n": base64.RawURLEncoding.EncodeToString(key.PublicKey.N.Bytes()), - "e": base64.RawURLEncoding.EncodeToString(big.NewInt(int64(key.PublicKey.E)).Bytes()), + "n": base64.RawURLEncoding.EncodeToString(key.N.Bytes()), + "e": base64.RawURLEncoding.EncodeToString(big.NewInt(int64(key.E)).Bytes()), }}}) })