mirror of
https://github.com/Noooste/garage-ui.git
synced 2026-07-26 07:48:13 +00:00
feat: add role extraction from access token for Keycloak integration
Signed-off-by: Noooste <83548733+Noooste@users.noreply.github.com>
This commit is contained in:
@@ -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 == "" {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user