From dd5d58a207004b68d4fefab4ab6a1c867b22afc6 Mon Sep 17 00:00:00 2001 From: Boris Rybalkin Date: Tue, 14 Jul 2026 13:26:01 +0100 Subject: [PATCH 1/2] authelia: include profile/email/groups claims in the id_token for registered OIDC clients Authelia 4.39 returns standard claims (email, name, groups, ...) only from the userinfo endpoint by default. OIDC clients that read identity from the id_token (e.g. Moodle's auth_oidc) therefore cannot provision users or read groups. Add a 'syncloud' claims policy that surfaces those claims in the id_token and apply it to every dynamically registered client. Backwards compatible: existing apps simply receive extra id_token claims (harmless); no golib or registration API change required. --- config/authelia/config.yml | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/config/authelia/config.yml b/config/authelia/config.yml index 5ad6e4f5..5cf32394 100644 --- a/config/authelia/config.yml +++ b/config/authelia/config.yml @@ -785,6 +785,16 @@ identity_providers: allowed_origins: - 'https://{{ .Domain }}' allowed_origins_from_client_redirect_uris: false + claims_policies: + syncloud: + id_token: + - 'email' + - 'email_verified' + - 'name' + - 'given_name' + - 'family_name' + - 'preferred_username' + - 'groups' clients: - client_id: 'syncloud' public: true @@ -814,6 +824,7 @@ identity_providers: - client_id: '{{ .ID }}' client_secret: '{{ .Secret }}' public: false + claims_policy: 'syncloud' authorization_policy: '{{ if $.TwoFactorEnabled }}two_factor{{ else }}one_factor{{ end }}' redirect_uris: {{- range .RedirectURIs }} From a9daeda084f1b1f16b07f5d302db61178f7d5fc3 Mon Sep 17 00:00:00 2001 From: Boris Rybalkin Date: Tue, 14 Jul 2026 19:46:45 +0100 Subject: [PATCH 2/2] testapp: verify id_token carries profile/email/groups claims The testapp OIDC fixture preferred the /userinfo endpoint, so the UI test passed regardless of what the id_token contained and could not prove the claims_policy change. Read preferred_username, email and groups strictly from the id_token (dropping the userinfo round trip), request the groups scope, and assert those claims in the Playwright OIDC test. --- test/testapp/cli/cmd/backend/main.go | 59 +++++++++++----------------- web/e2e/specs/08-oidc.spec.ts | 4 ++ 2 files changed, 27 insertions(+), 36 deletions(-) diff --git a/test/testapp/cli/cmd/backend/main.go b/test/testapp/cli/cmd/backend/main.go index ab23199b..3455f61f 100644 --- a/test/testapp/cli/cmd/backend/main.go +++ b/test/testapp/cli/cmd/backend/main.go @@ -80,7 +80,7 @@ func main() { "client_id": {"testapp"}, "response_type": {"code"}, "redirect_uri": {redirectURI}, - "scope": {"openid profile email"}, + "scope": {"openid profile email groups"}, "state": {state}, "code_challenge": {challenge}, "code_challenge_method": {"S256"}, @@ -131,60 +131,47 @@ func main() { } var tokenResp struct { - IDToken string `json:"id_token"` - AccessToken string `json:"access_token"` + IDToken string `json:"id_token"` } json.Unmarshal(body, &tokenResp) - var username string - if tokenResp.AccessToken != "" { - userinfoURL := authUrl + "/api/oidc/userinfo" - uReq, _ := http.NewRequest("GET", userinfoURL, nil) - uReq.Header.Set("Authorization", "Bearer "+tokenResp.AccessToken) - uResp, uErr := newHTTPClient().Do(uReq) - if uErr == nil { - defer uResp.Body.Close() - uBody, _ := io.ReadAll(uResp.Body) - var userInfo struct { - PreferredUsername string `json:"preferred_username"` - } - json.Unmarshal(uBody, &userInfo) - username = userInfo.PreferredUsername - } - } - if username == "" { - username, err = extractUsername(tokenResp.IDToken) - if err != nil { - http.Error(w, fmt.Sprintf("extract username: %v", err), http.StatusInternalServerError) - return - } + claims, err := extractClaims(tokenResp.IDToken) + if err != nil { + http.Error(w, fmt.Sprintf("extract claims: %v", err), http.StatusInternalServerError) + return } w.Header().Set("Content-Type", "text/plain") - fmt.Fprintf(w, "OK %s", username) + fmt.Fprintf(w, "OK %s email=%s groups=%s", claims.PreferredUsername, claims.Email, strings.Join(claims.Groups, ",")) }) http.Serve(listener, mux) } -func extractUsername(idToken string) (string, error) { +type idTokenClaims struct { + PreferredUsername string `json:"preferred_username"` + Email string `json:"email"` + Groups []string `json:"groups"` + Subject string `json:"sub"` +} + +func extractClaims(idToken string) (idTokenClaims, error) { + var claims idTokenClaims parts := strings.Split(idToken, ".") if len(parts) != 3 { - return "", fmt.Errorf("invalid id_token") + return claims, fmt.Errorf("invalid id_token") } payload, err := base64.RawURLEncoding.DecodeString(parts[1]) if err != nil { - return "", err + return claims, err } - var claims struct { - PreferredUsername string `json:"preferred_username"` - Subject string `json:"sub"` + if err := json.Unmarshal(payload, &claims); err != nil { + return claims, err } - json.Unmarshal(payload, &claims) - if claims.PreferredUsername != "" { - return claims.PreferredUsername, nil + if claims.PreferredUsername == "" { + claims.PreferredUsername = claims.Subject } - return claims.Subject, nil + return claims, nil } func randomString(length int) (string, error) { diff --git a/web/e2e/specs/08-oidc.spec.ts b/web/e2e/specs/08-oidc.spec.ts index 8a96b6f5..4f89b1df 100644 --- a/web/e2e/specs/08-oidc.spec.ts +++ b/web/e2e/specs/08-oidc.spec.ts @@ -27,6 +27,10 @@ test('testapp OIDC login', async ({}, testInfo) => { await shoot(page, testInfo, 'testapp-oidc-login') await page.locator('#sign-in-button').click() await expect(page.locator(`xpath=//*[contains(text(),'OK ${deviceUser}')]`)).toBeVisible() + const claims = await page.locator('body').innerText() + expect(claims).toContain(`OK ${deviceUser}`) + expect(claims).toMatch(/email=\S+@\S+/) + expect(claims).toContain('groups=syncloud') await shoot(page, testInfo, 'testapp-oidc-callback') })