From c111b7914731a3cafeaa55102b515f84a1ad74dc Mon Sep 17 00:00:00 2001 From: Kyle Mendell Date: Mon, 9 Jun 2025 10:46:03 -0500 Subject: [PATCH] feat: oidc client data preview (#624) Co-authored-by: Elias Schneider --- .../internal/controller/oidc_controller.go | 42 +++ backend/internal/dto/oidc_dto.go | 6 + backend/internal/service/jwt_service.go | 42 ++- backend/internal/service/oidc_service.go | 256 +++++++++++------- backend/internal/utils/jwt_util.go | 19 ++ frontend/messages/en.json | 20 +- .../lib/components/form/multi-select.svelte | 56 ++++ .../components/form/searchable-select.svelte | 35 ++- frontend/src/lib/services/oidc-service.ts | 7 + frontend/src/lib/utils/debounce-util.ts | 16 +- .../admin/oidc-clients/[id]/+page.svelte | 29 +- .../oidc-client-preview-modal.svelte | 211 +++++++++++++++ 12 files changed, 626 insertions(+), 113 deletions(-) create mode 100644 backend/internal/utils/jwt_util.go create mode 100644 frontend/src/lib/components/form/multi-select.svelte create mode 100644 frontend/src/routes/settings/admin/oidc-clients/oidc-client-preview-modal.svelte diff --git a/backend/internal/controller/oidc_controller.go b/backend/internal/controller/oidc_controller.go index 443e301e..754a7955 100644 --- a/backend/internal/controller/oidc_controller.go +++ b/backend/internal/controller/oidc_controller.go @@ -48,6 +48,8 @@ func NewOidcController(group *gin.RouterGroup, authMiddleware *middleware.AuthMi group.DELETE("/oidc/clients/:id/logo", oc.deleteClientLogoHandler) group.POST("/oidc/clients/:id/logo", authMiddleware.Add(), fileSizeLimitMiddleware.Add(2<<20), oc.updateClientLogoHandler) + group.GET("/oidc/clients/:id/preview/:userId", authMiddleware.Add(), oc.getClientPreviewHandler) + group.POST("/oidc/device/authorize", oc.deviceAuthorizationHandler) group.POST("/oidc/device/verify", authMiddleware.WithAdminNotRequired().Add(), oc.verifyDeviceCodeHandler) group.GET("/oidc/device/info", authMiddleware.WithAdminNotRequired().Add(), oc.getDeviceCodeInfoHandler) @@ -721,3 +723,43 @@ func (oc *OidcController) getDeviceCodeInfoHandler(c *gin.Context) { c.JSON(http.StatusOK, deviceCodeInfo) } + +// getClientPreviewHandler godoc +// @Summary Preview OIDC client data for user +// @Description Get a preview of the OIDC data (ID token, access token, userinfo) that would be sent to the client for a specific user +// @Tags OIDC +// @Produce json +// @Param id path string true "Client ID" +// @Param userId path string true "User ID to preview data for" +// @Param scopes query string false "Scopes to include in the preview (comma-separated)" +// @Success 200 {object} dto.OidcClientPreviewDto "Preview data including ID token, access token, and userinfo payloads" +// @Security BearerAuth +// @Router /api/oidc/clients/{id}/preview/{userId} [get] +func (oc *OidcController) getClientPreviewHandler(c *gin.Context) { + clientID := c.Param("id") + userID := c.Param("userId") + scopes := c.Query("scopes") + + if clientID == "" { + _ = c.Error(&common.ValidationError{Message: "client ID is required"}) + return + } + + if userID == "" { + _ = c.Error(&common.ValidationError{Message: "user ID is required"}) + return + } + + if scopes == "" { + _ = c.Error(&common.ValidationError{Message: "scopes are required"}) + return + } + + preview, err := oc.oidcService.GetClientPreview(c.Request.Context(), clientID, userID, scopes) + if err != nil { + _ = c.Error(err) + return + } + + c.JSON(http.StatusOK, preview) +} diff --git a/backend/internal/dto/oidc_dto.go b/backend/internal/dto/oidc_dto.go index df317787..9e9aaf7c 100644 --- a/backend/internal/dto/oidc_dto.go +++ b/backend/internal/dto/oidc_dto.go @@ -145,3 +145,9 @@ type AuthorizedOidcClientDto struct { Scope string `json:"scope"` Client OidcClientMetaDataDto `json:"client"` } + +type OidcClientPreviewDto struct { + IdToken map[string]interface{} `json:"idToken"` + AccessToken map[string]interface{} `json:"accessToken"` + UserInfo map[string]interface{} `json:"userInfo"` +} diff --git a/backend/internal/service/jwt_service.go b/backend/internal/service/jwt_service.go index 389cd608..bf3061de 100644 --- a/backend/internal/service/jwt_service.go +++ b/backend/internal/service/jwt_service.go @@ -234,7 +234,8 @@ func (s *JwtService) VerifyAccessToken(tokenString string) (jwt.Token, error) { return token, nil } -func (s *JwtService) GenerateIDToken(userClaims map[string]any, clientID string, nonce string) (string, error) { +// BuildIDToken creates an ID token with all claims +func (s *JwtService) BuildIDToken(userClaims map[string]any, clientID string, nonce string) (jwt.Token, error) { now := time.Now() token, err := jwt.NewBuilder(). Expiration(now.Add(1 * time.Hour)). @@ -242,33 +243,43 @@ func (s *JwtService) GenerateIDToken(userClaims map[string]any, clientID string, Issuer(common.EnvConfig.AppURL). Build() if err != nil { - return "", fmt.Errorf("failed to build token: %w", err) + return nil, fmt.Errorf("failed to build token: %w", err) } err = SetAudienceString(token, clientID) if err != nil { - return "", fmt.Errorf("failed to set 'aud' claim in token: %w", err) + return nil, fmt.Errorf("failed to set 'aud' claim in token: %w", err) } err = SetTokenType(token, IDTokenJWTType) if err != nil { - return "", fmt.Errorf("failed to set 'type' claim in token: %w", err) + return nil, fmt.Errorf("failed to set 'type' claim in token: %w", err) } for k, v := range userClaims { err = token.Set(k, v) if err != nil { - return "", fmt.Errorf("failed to set claim '%s': %w", k, err) + return nil, fmt.Errorf("failed to set claim '%s': %w", k, err) } } if nonce != "" { err = token.Set("nonce", nonce) if err != nil { - return "", fmt.Errorf("failed to set claim 'nonce': %w", err) + return nil, fmt.Errorf("failed to set claim 'nonce': %w", err) } } + return token, nil +} + +// GenerateIDToken creates and signs an ID token +func (s *JwtService) GenerateIDToken(userClaims map[string]any, clientID string, nonce string) (string, error) { + token, err := s.BuildIDToken(userClaims, clientID, nonce) + if err != nil { + return "", err + } + alg, _ := s.privateKey.Algorithm() signed, err := jwt.Sign(token, jwt.WithKey(alg, s.privateKey)) if err != nil { @@ -311,7 +322,8 @@ func (s *JwtService) VerifyIdToken(tokenString string, acceptExpiredTokens bool) return token, nil } -func (s *JwtService) GenerateOauthAccessToken(user model.User, clientID string) (string, error) { +// BuildOauthAccessToken creates an OAuth access token with all claims +func (s *JwtService) BuildOauthAccessToken(user model.User, clientID string) (jwt.Token, error) { now := time.Now() token, err := jwt.NewBuilder(). Subject(user.ID). @@ -320,17 +332,27 @@ func (s *JwtService) GenerateOauthAccessToken(user model.User, clientID string) Issuer(common.EnvConfig.AppURL). Build() if err != nil { - return "", fmt.Errorf("failed to build token: %w", err) + return nil, fmt.Errorf("failed to build token: %w", err) } err = SetAudienceString(token, clientID) if err != nil { - return "", fmt.Errorf("failed to set 'aud' claim in token: %w", err) + return nil, fmt.Errorf("failed to set 'aud' claim in token: %w", err) } err = SetTokenType(token, OAuthAccessTokenJWTType) if err != nil { - return "", fmt.Errorf("failed to set 'type' claim in token: %w", err) + return nil, fmt.Errorf("failed to set 'type' claim in token: %w", err) + } + + return token, nil +} + +// GenerateOauthAccessToken creates and signs an OAuth access token +func (s *JwtService) GenerateOauthAccessToken(user model.User, clientID string) (string, error) { + token, err := s.BuildOauthAccessToken(user, clientID) + if err != nil { + return "", err } alg, _ := s.privateKey.Algorithm() diff --git a/backend/internal/service/oidc_service.go b/backend/internal/service/oidc_service.go index 0a355917..b8d1e8ce 100644 --- a/backend/internal/service/oidc_service.go +++ b/backend/internal/service/oidc_service.go @@ -841,97 +841,6 @@ func (s *OidcService) DeleteClientLogo(ctx context.Context, clientID string) err return nil } -func (s *OidcService) GetUserClaimsForClient(ctx context.Context, userID string, clientID string) (map[string]interface{}, error) { - tx := s.db.Begin() - defer func() { - tx.Rollback() - }() - - claims, err := s.getUserClaimsForClientInternal(ctx, userID, clientID, s.db) - if err != nil { - return nil, err - } - - err = tx.Commit().Error - if err != nil { - return nil, err - } - - return claims, nil -} - -func (s *OidcService) getUserClaimsForClientInternal(ctx context.Context, userID string, clientID string, tx *gorm.DB) (map[string]interface{}, error) { - var authorizedOidcClient model.UserAuthorizedOidcClient - err := tx. - WithContext(ctx). - Preload("User.UserGroups"). - First(&authorizedOidcClient, "user_id = ? AND client_id = ?", userID, clientID). - Error - if err != nil { - return nil, err - } - - user := authorizedOidcClient.User - scopes := strings.Split(authorizedOidcClient.Scope, " ") - - claims := map[string]interface{}{ - "sub": user.ID, - } - - if slices.Contains(scopes, "email") { - claims["email"] = user.Email - claims["email_verified"] = s.appConfigService.GetDbConfig().EmailsVerified.IsTrue() - } - - if slices.Contains(scopes, "groups") { - userGroups := make([]string, len(user.UserGroups)) - for i, group := range user.UserGroups { - userGroups[i] = group.Name - } - claims["groups"] = userGroups - } - - profileClaims := map[string]interface{}{ - "given_name": user.FirstName, - "family_name": user.LastName, - "name": user.FullName(), - "preferred_username": user.Username, - "picture": common.EnvConfig.AppURL + "/api/users/" + user.ID + "/profile-picture.png", - } - - if slices.Contains(scopes, "profile") { - // Add profile claims - for k, v := range profileClaims { - claims[k] = v - } - - // Add custom claims - customClaims, err := s.customClaimService.GetCustomClaimsForUserWithUserGroups(ctx, userID, tx) - if err != nil { - return nil, err - } - - for _, customClaim := range customClaims { - // The value of the custom claim can be a JSON object or a string - var jsonValue interface{} - err := json.Unmarshal([]byte(customClaim.Value), &jsonValue) - if err == nil { - // It's JSON so we store it as an object - claims[customClaim.Key] = jsonValue - } else { - // Marshalling failed, so we store it as a string - claims[customClaim.Key] = customClaim.Value - } - } - } - - if slices.Contains(scopes, "email") { - claims["email"] = user.Email - } - - return claims, nil -} - func (s *OidcService) UpdateAllowedUserGroups(ctx context.Context, id string, input dto.OidcUpdateAllowedUserGroupsDto) (client model.OidcClient, err error) { tx := s.db.Begin() defer func() { @@ -1519,3 +1428,168 @@ func (s *OidcService) verifyClientAssertionFromFederatedIdentities(ctx context.C // If we're here, the assertion is valid return nil } + +func (s *OidcService) GetClientPreview(ctx context.Context, clientID string, userID string, scopes string) (*dto.OidcClientPreviewDto, error) { + tx := s.db.Begin() + defer func() { + tx.Rollback() + }() + + client, err := s.getClientInternal(ctx, clientID, tx) + if err != nil { + return nil, err + } + + var user model.User + err = tx. + WithContext(ctx). + Preload("UserGroups"). + First(&user, "id = ?", userID). + Error + if err != nil { + return nil, err + } + + if !s.IsUserGroupAllowedToAuthorize(user, client) { + return nil, &common.OidcAccessDeniedError{} + } + + dummyAuthorizedClient := model.UserAuthorizedOidcClient{ + UserID: userID, + ClientID: clientID, + Scope: scopes, + User: user, + } + + userClaims, err := s.getUserClaimsFromAuthorizedClient(ctx, &dummyAuthorizedClient, tx) + if err != nil { + return nil, err + } + + idToken, err := s.jwtService.BuildIDToken(userClaims, clientID, "") + if err != nil { + return nil, err + } + + accessToken, err := s.jwtService.BuildOauthAccessToken(user, clientID) + if err != nil { + return nil, err + } + + idTokenPayload, err := utils.GetClaimsFromToken(idToken) + if err != nil { + return nil, err + } + + accessTokenPayload, err := utils.GetClaimsFromToken(accessToken) + if err != nil { + return nil, err + } + + err = tx.Commit().Error + if err != nil { + return nil, err + } + + return &dto.OidcClientPreviewDto{ + IdToken: idTokenPayload, + AccessToken: accessTokenPayload, + UserInfo: userClaims, + }, nil +} + +func (s *OidcService) GetUserClaimsForClient(ctx context.Context, userID string, clientID string) (map[string]interface{}, error) { + tx := s.db.Begin() + defer func() { + tx.Rollback() + }() + + claims, err := s.getUserClaimsForClientInternal(ctx, userID, clientID, s.db) + if err != nil { + return nil, err + } + + err = tx.Commit().Error + if err != nil { + return nil, err + } + + return claims, nil +} + +func (s *OidcService) getUserClaimsForClientInternal(ctx context.Context, userID string, clientID string, tx *gorm.DB) (map[string]interface{}, error) { + var authorizedOidcClient model.UserAuthorizedOidcClient + err := tx. + WithContext(ctx). + Preload("User.UserGroups"). + First(&authorizedOidcClient, "user_id = ? AND client_id = ?", userID, clientID). + Error + if err != nil { + return nil, err + } + + return s.getUserClaimsFromAuthorizedClient(ctx, &authorizedOidcClient, tx) + +} + +func (s *OidcService) getUserClaimsFromAuthorizedClient(ctx context.Context, authorizedClient *model.UserAuthorizedOidcClient, tx *gorm.DB) (map[string]interface{}, error) { + user := authorizedClient.User + scopes := strings.Split(authorizedClient.Scope, " ") + + claims := map[string]interface{}{ + "sub": user.ID, + } + + if slices.Contains(scopes, "email") { + claims["email"] = user.Email + claims["email_verified"] = s.appConfigService.GetDbConfig().EmailsVerified.IsTrue() + } + + if slices.Contains(scopes, "groups") { + userGroups := make([]string, len(user.UserGroups)) + for i, group := range user.UserGroups { + userGroups[i] = group.Name + } + claims["groups"] = userGroups + } + + profileClaims := map[string]interface{}{ + "given_name": user.FirstName, + "family_name": user.LastName, + "name": user.FullName(), + "preferred_username": user.Username, + "picture": common.EnvConfig.AppURL + "/api/users/" + user.ID + "/profile-picture.png", + } + + if slices.Contains(scopes, "profile") { + // Add profile claims + for k, v := range profileClaims { + claims[k] = v + } + + // Add custom claims + customClaims, err := s.customClaimService.GetCustomClaimsForUserWithUserGroups(ctx, user.ID, tx) + if err != nil { + return nil, err + } + + for _, customClaim := range customClaims { + // The value of the custom claim can be a JSON object or a string + var jsonValue interface{} + err := json.Unmarshal([]byte(customClaim.Value), &jsonValue) + if err == nil { + // It's JSON, so we store it as an object + claims[customClaim.Key] = jsonValue + } else { + // Marshaling failed, so we store it as a string + claims[customClaim.Key] = customClaim.Value + } + } + } + + if slices.Contains(scopes, "email") { + claims["email"] = user.Email + } + + return claims, nil +} diff --git a/backend/internal/utils/jwt_util.go b/backend/internal/utils/jwt_util.go new file mode 100644 index 00000000..b02b9f5a --- /dev/null +++ b/backend/internal/utils/jwt_util.go @@ -0,0 +1,19 @@ +package utils + +import ( + "fmt" + + "github.com/lestrrat-go/jwx/v3/jwt" +) + +func GetClaimsFromToken(token jwt.Token) (map[string]any, error) { + claims := make(map[string]any) + for _, key := range token.Keys() { + var value any + if err := token.Get(key, &value); err != nil { + return nil, fmt.Errorf("failed to get claim %s: %w", key, err) + } + claims[key] = value + } + return claims, nil +} diff --git a/frontend/messages/en.json b/frontend/messages/en.json index 7ee88229..fcfdb567 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -353,5 +353,23 @@ "oidc_allowed_group_count": "Allowed Group Count", "unrestricted": "Unrestricted", "show_advanced_options": "Show Advanced Options", - "hide_advanced_options": "Hide Advanced Options" + "hide_advanced_options": "Hide Advanced Options", + "oidc_data_preview": "OIDC Data Preview", + "preview_the_oidc_data_that_would_be_sent_for_different_users": "Preview the OIDC data that would be sent for different users", + "id_token": "ID Token", + "access_token": "Access Token", + "userinfo": "Userinfo", + "id_token_payload": "ID Token Payload", + "access_token_payload": "Access Token Payload", + "userinfo_endpoint_response": "Userinfo Endpoint Response", + "copy": "Copy", + "no_preview_data_available": "No preview data available", + "copy_all": "Copy All", + "preview": "Preview", + "preview_for_user": "Preview for {name} ({email})", + "preview_the_oidc_data_that_would_be_sent_for_this_user": "Preview the OIDC data that would be sent for this user", + "show": "Show", + "select_an_option": "Select an option", + "select_user": "Select User", + "error": "Error" } diff --git a/frontend/src/lib/components/form/multi-select.svelte b/frontend/src/lib/components/form/multi-select.svelte new file mode 100644 index 00000000..cf521f72 --- /dev/null +++ b/frontend/src/lib/components/form/multi-select.svelte @@ -0,0 +1,56 @@ + + + + + {#snippet child({ props })} + + {/snippet} + + + {#each items as item} + handleItemSelect(item.value)} + closeOnSelect={autoClose} + > + {item.label} + + {/each} + + diff --git a/frontend/src/lib/components/form/searchable-select.svelte b/frontend/src/lib/components/form/searchable-select.svelte index 724741b6..c0a119ef 100644 --- a/frontend/src/lib/components/form/searchable-select.svelte +++ b/frontend/src/lib/components/form/searchable-select.svelte @@ -2,15 +2,19 @@ import { Button } from '$lib/components/ui/button'; import * as Command from '$lib/components/ui/command'; import * as Popover from '$lib/components/ui/popover'; + import { m } from '$lib/paraglide/messages'; import { cn } from '$lib/utils/style'; - import { LucideCheck, LucideChevronDown } from '@lucide/svelte'; + import { LoaderCircle, LucideCheck, LucideChevronDown } from '@lucide/svelte'; import { tick } from 'svelte'; - import type { HTMLAttributes } from 'svelte/elements'; + import type { FormEventHandler, HTMLAttributes } from 'svelte/elements'; let { items, value = $bindable(), onSelect, + oninput, + isLoading, + selectText = m.select_an_option(), ...restProps }: HTMLAttributes & { items: { @@ -18,7 +22,10 @@ label: string; }[]; value: string; + oninput?: FormEventHandler; onSelect?: (value: string) => void; + isLoading?: boolean; + selectText?: string; } = $props(); let open = $state(false); @@ -53,21 +60,35 @@ - + - + - filterItems(e.target.value)} /> - No results found. + { + filterItems(e.currentTarget.value); + oninput?.(e); + }} + /> + + {#if isLoading} +
+ +
+ {:else} + {m.no_items_found()} + {/if} +
{#each filteredItems as item} void>(func: T, delay: number) { +export function debounced any>( + func: T, + delay: number, + onLoadingChange?: (loading: boolean) => void +) { let debounceTimeout: ReturnType; return (...args: Parameters) => { @@ -6,8 +10,14 @@ export function debounced void>(func: T, delay: nu clearTimeout(debounceTimeout); } - debounceTimeout = setTimeout(() => { - func(...args); + onLoadingChange?.(true); + + debounceTimeout = setTimeout(async () => { + try { + await func(...args); + } finally { + onLoadingChange?.(false); + } }, delay); }; } diff --git a/frontend/src/routes/settings/admin/oidc-clients/[id]/+page.svelte b/frontend/src/routes/settings/admin/oidc-clients/[id]/+page.svelte index e55be368..92167233 100644 --- a/frontend/src/routes/settings/admin/oidc-clients/[id]/+page.svelte +++ b/frontend/src/routes/settings/admin/oidc-clients/[id]/+page.svelte @@ -13,10 +13,11 @@ import clientSecretStore from '$lib/stores/client-secret-store'; import type { OidcClientCreateWithLogo } from '$lib/types/oidc.type'; import { axiosErrorToast } from '$lib/utils/error-util'; - import { LucideChevronLeft, LucideRefreshCcw } from '@lucide/svelte'; + import { LucideChevronLeft, LucideRefreshCcw, RectangleEllipsis } from '@lucide/svelte'; import { toast } from 'svelte-sonner'; import { slide } from 'svelte/transition'; import OidcForm from '../oidc-client-form.svelte'; + import OidcClientPreviewModal from '../oidc-client-preview-modal.svelte'; let { data } = $props(); let client = $state({ @@ -24,6 +25,7 @@ allowedUserGroupIds: data.allowedUserGroups.map((g) => g.id) }); let showAllDetails = $state(false); + let showPreview = $state(false); const oidcService = new OidcService(); @@ -91,6 +93,12 @@ }); } + let previewUserId = $state(null); + + function handlePreview(userId: string) { + previewUserId = userId; + } + beforeNavigate(() => { clientSecretStore.clear(); }); @@ -180,3 +188,22 @@ + + +
+
+ + {m.oidc_data_preview()} + + + {m.preview_the_oidc_data_that_would_be_sent_for_different_users()} + +
+ + +
+
+
+ diff --git a/frontend/src/routes/settings/admin/oidc-clients/oidc-client-preview-modal.svelte b/frontend/src/routes/settings/admin/oidc-clients/oidc-client-preview-modal.svelte new file mode 100644 index 00000000..ec483c03 --- /dev/null +++ b/frontend/src/routes/settings/admin/oidc-clients/oidc-client-preview-modal.svelte @@ -0,0 +1,211 @@ + + + + + + {m.oidc_data_preview()} + + {#if user} + {m.preview_for_user({ name: user.firstName + ' ' + user.lastName, email: user.email })} + {:else} + {m.preview_the_oidc_data_that_would_be_sent_for_this_user()} + {/if} + + + +
+ {#if loadingPreview} +
+
+
+ {/if} + +
+
+ +
+ ({ + value: user.id, + label: user.username + }))} + value={user?.id || ''} + oninput={(e) => onUserSearch(e.currentTarget.value)} + onSelect={(value) => { + user = users.find((u) => u.id === value) || null; + loadPreviewData(); + }} + /> +
+
+
+ + +
+
+ + {#if errorMessage && !loadingPreview} + + + {m.error()} + + {errorMessage} + + + {/if} + + {#if previewData && !loadingPreview} + + + {m.id_token()} + {m.access_token()} + {m.userinfo()} + + + {@render tabContent(previewData.idToken, m.id_token_payload())} + + + + {@render tabContent(previewData.accessToken, m.access_token_payload())} + + + + {@render tabContent(previewData.userInfo, m.userinfo_endpoint_response())} + + + {/if} +
+
+
+ +{#snippet tabContent(data: any, title: string)} +
+
+ + + + +
+
+ {#each Object.entries(data || {}) as [key, value]} +
+ +
+ +
+ {typeof value === 'object' ? JSON.stringify(value, null, 2) : value} +
+
+
+
+ {/each} +
+
+{/snippet}