diff --git a/backend/internal/auth/auth.go b/backend/internal/auth/auth.go index 20d227a..87ca1f8 100644 --- a/backend/internal/auth/auth.go +++ b/backend/internal/auth/auth.go @@ -4,6 +4,7 @@ import ( "context" "crypto/subtle" "encoding/base64" + "encoding/json" "fmt" "strings" @@ -227,6 +228,36 @@ func (a *Service) GetUserInfo(ctx context.Context, token *oauth2.Token) (*UserIn return userInfo, nil } +// ExtractRolesFromAccessToken parses the access token JWT payload and extracts +// roles using the configured role_attribute_path. Keycloak emits resource_access +// claims only in the access token by default, so this is required to support +// the common Keycloak client-role setup without extra mapper configuration. +// +// The access token was obtained via a verified code exchange with the provider, +// so parsing its claims without re-verifying the signature is safe here. +func (a *Service) ExtractRolesFromAccessToken(accessToken string) []string { + if accessToken == "" || a.authConfig.OIDC.RoleAttributePath == "" { + return nil + } + + parts := strings.Split(accessToken, ".") + if len(parts) < 2 { + return nil + } + + payload, err := base64.RawURLEncoding.DecodeString(parts[1]) + if err != nil { + return nil + } + + var claims map[string]interface{} + if err := json.Unmarshal(payload, &claims); err != nil { + return nil + } + + return extractRoles(claims, a.authConfig.OIDC.RoleAttributePath) +} + // IsAdmin checks if the user has admin role func (a *Service) IsAdmin(userInfo *UserInfo) bool { if a.authConfig.OIDC.AdminRole == "" { diff --git a/backend/internal/auth/auth_test.go b/backend/internal/auth/auth_test.go new file mode 100644 index 0000000..272da95 --- /dev/null +++ b/backend/internal/auth/auth_test.go @@ -0,0 +1,70 @@ +package auth + +import ( + "encoding/base64" + "encoding/json" + "testing" + + "Noooste/garage-ui/internal/config" +) + +func TestExtractRolesFromAccessToken_Keycloak(t *testing.T) { + payload := map[string]interface{}{ + "resource_access": map[string]interface{}{ + "GarageAdminUi": map[string]interface{}{ + "roles": []interface{}{"admin"}, + }, + "account": map[string]interface{}{ + "roles": []interface{}{"view-profile"}, + }, + }, + } + raw, err := json.Marshal(payload) + if err != nil { + t.Fatal(err) + } + token := "header." + base64.RawURLEncoding.EncodeToString(raw) + ".sig" + + svc := &Service{ + authConfig: &config.AuthConfig{ + OIDC: config.OIDCConfig{ + RoleAttributePath: "resource_access.GarageAdminUi.roles", + }, + }, + } + + roles := svc.ExtractRolesFromAccessToken(token) + if len(roles) != 1 || roles[0] != "admin" { + t.Fatalf("expected [admin], got %v", roles) + } +} + +func TestExtractRolesFromAccessToken_NoRoleClaim(t *testing.T) { + payload := map[string]interface{}{"sub": "user"} + raw, _ := json.Marshal(payload) + token := "header." + base64.RawURLEncoding.EncodeToString(raw) + ".sig" + + svc := &Service{ + authConfig: &config.AuthConfig{ + OIDC: config.OIDCConfig{RoleAttributePath: "resource_access.GarageAdminUi.roles"}, + }, + } + + if roles := svc.ExtractRolesFromAccessToken(token); roles != nil { + t.Fatalf("expected nil, got %v", roles) + } +} + +func TestExtractRolesFromAccessToken_Malformed(t *testing.T) { + svc := &Service{ + authConfig: &config.AuthConfig{ + OIDC: config.OIDCConfig{RoleAttributePath: "resource_access.GarageAdminUi.roles"}, + }, + } + if roles := svc.ExtractRolesFromAccessToken("not-a-jwt"); roles != nil { + t.Fatalf("expected nil for malformed token, got %v", roles) + } + if roles := svc.ExtractRolesFromAccessToken(""); roles != nil { + t.Fatalf("expected nil for empty token, got %v", roles) + } +} diff --git a/backend/internal/routes/routes.go b/backend/internal/routes/routes.go index 513ddd1..59358f2 100644 --- a/backend/internal/routes/routes.go +++ b/backend/internal/routes/routes.go @@ -243,26 +243,31 @@ func SetupRoutes( } // Enforce admin role if configured. Roles are often absent from the - // ID token (e.g. Keycloak only emits resource_access in access tokens - // unless the client scope is explicitly configured), so fall back to - // the userinfo endpoint before denying access. + // ID token and the userinfo endpoint (Keycloak emits resource_access + // only in the access token by default), so fall back to the access + // token and then the userinfo endpoint before denying access. if cfg.Auth.OIDC.AdminRole != "" { + if !authService.IsAdmin(userInfo) { + if roles := authService.ExtractRolesFromAccessToken(token.AccessToken); len(roles) > 0 { + userInfo.Roles = roles + } + } if !authService.IsAdmin(userInfo) { if uiFromUserinfo, uiErr := authService.GetUserInfo(ctx, token); uiErr == nil { if len(uiFromUserinfo.Roles) > 0 { userInfo.Roles = uiFromUserinfo.Roles } } - if !authService.IsAdmin(userInfo) { - logger.Warn(). - Str("username", userInfo.Username). - Str("required_role", cfg.Auth.OIDC.AdminRole). - Strs("roles", userInfo.Roles). - Msg("OIDC login denied: user does not have required admin role") - return c.Status(fiber.StatusForbidden).JSON(fiber.Map{ - "error": "User does not have the required admin role", - }) - } + } + if !authService.IsAdmin(userInfo) { + logger.Warn(). + Str("username", userInfo.Username). + Str("required_role", cfg.Auth.OIDC.AdminRole). + Strs("roles", userInfo.Roles). + Msg("OIDC login denied: user does not have required admin role") + return c.Status(fiber.StatusForbidden).JSON(fiber.Map{ + "error": "User does not have the required admin role", + }) } }