feat: oidc client data preview (#624)

Co-authored-by: Elias Schneider <login@eliasschneider.com>
This commit is contained in:
Kyle Mendell
2025-06-09 10:46:03 -05:00
committed by GitHub
parent 61bf14225b
commit c111b79147
12 changed files with 626 additions and 113 deletions

View File

@@ -48,6 +48,8 @@ func NewOidcController(group *gin.RouterGroup, authMiddleware *middleware.AuthMi
group.DELETE("/oidc/clients/:id/logo", oc.deleteClientLogoHandler) group.DELETE("/oidc/clients/:id/logo", oc.deleteClientLogoHandler)
group.POST("/oidc/clients/:id/logo", authMiddleware.Add(), fileSizeLimitMiddleware.Add(2<<20), oc.updateClientLogoHandler) 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/authorize", oc.deviceAuthorizationHandler)
group.POST("/oidc/device/verify", authMiddleware.WithAdminNotRequired().Add(), oc.verifyDeviceCodeHandler) group.POST("/oidc/device/verify", authMiddleware.WithAdminNotRequired().Add(), oc.verifyDeviceCodeHandler)
group.GET("/oidc/device/info", authMiddleware.WithAdminNotRequired().Add(), oc.getDeviceCodeInfoHandler) 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) 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)
}

View File

@@ -145,3 +145,9 @@ type AuthorizedOidcClientDto struct {
Scope string `json:"scope"` Scope string `json:"scope"`
Client OidcClientMetaDataDto `json:"client"` 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"`
}

View File

@@ -234,7 +234,8 @@ func (s *JwtService) VerifyAccessToken(tokenString string) (jwt.Token, error) {
return token, nil 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() now := time.Now()
token, err := jwt.NewBuilder(). token, err := jwt.NewBuilder().
Expiration(now.Add(1 * time.Hour)). Expiration(now.Add(1 * time.Hour)).
@@ -242,33 +243,43 @@ func (s *JwtService) GenerateIDToken(userClaims map[string]any, clientID string,
Issuer(common.EnvConfig.AppURL). Issuer(common.EnvConfig.AppURL).
Build() Build()
if err != nil { 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) err = SetAudienceString(token, clientID)
if err != nil { 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) err = SetTokenType(token, IDTokenJWTType)
if err != nil { 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 { for k, v := range userClaims {
err = token.Set(k, v) err = token.Set(k, v)
if err != nil { 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 != "" { if nonce != "" {
err = token.Set("nonce", nonce) err = token.Set("nonce", nonce)
if err != nil { 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() alg, _ := s.privateKey.Algorithm()
signed, err := jwt.Sign(token, jwt.WithKey(alg, s.privateKey)) signed, err := jwt.Sign(token, jwt.WithKey(alg, s.privateKey))
if err != nil { if err != nil {
@@ -311,7 +322,8 @@ func (s *JwtService) VerifyIdToken(tokenString string, acceptExpiredTokens bool)
return token, nil 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() now := time.Now()
token, err := jwt.NewBuilder(). token, err := jwt.NewBuilder().
Subject(user.ID). Subject(user.ID).
@@ -320,17 +332,27 @@ func (s *JwtService) GenerateOauthAccessToken(user model.User, clientID string)
Issuer(common.EnvConfig.AppURL). Issuer(common.EnvConfig.AppURL).
Build() Build()
if err != nil { 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) err = SetAudienceString(token, clientID)
if err != nil { 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) err = SetTokenType(token, OAuthAccessTokenJWTType)
if err != nil { 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() alg, _ := s.privateKey.Algorithm()

View File

@@ -841,97 +841,6 @@ func (s *OidcService) DeleteClientLogo(ctx context.Context, clientID string) err
return nil 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) { func (s *OidcService) UpdateAllowedUserGroups(ctx context.Context, id string, input dto.OidcUpdateAllowedUserGroupsDto) (client model.OidcClient, err error) {
tx := s.db.Begin() tx := s.db.Begin()
defer func() { defer func() {
@@ -1519,3 +1428,168 @@ func (s *OidcService) verifyClientAssertionFromFederatedIdentities(ctx context.C
// If we're here, the assertion is valid // If we're here, the assertion is valid
return nil 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
}

View File

@@ -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
}

View File

@@ -353,5 +353,23 @@
"oidc_allowed_group_count": "Allowed Group Count", "oidc_allowed_group_count": "Allowed Group Count",
"unrestricted": "Unrestricted", "unrestricted": "Unrestricted",
"show_advanced_options": "Show Advanced Options", "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"
} }

View File

@@ -0,0 +1,56 @@
<script lang="ts">
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
import { LucideChevronDown } from '@lucide/svelte';
import { Badge } from '../ui/badge';
import { Button } from '../ui/button';
let {
items,
selectedItems = $bindable(),
onSelect,
autoClose = false
}: {
items: {
value: string;
label: string;
}[];
selectedItems: string[];
onSelect?: (value: string) => void;
autoClose?: boolean;
} = $props();
function handleItemSelect(value: string) {
if (selectedItems.includes(value)) {
selectedItems = selectedItems.filter((item) => item !== value);
} else {
selectedItems = [...selectedItems, value];
}
onSelect?.(value);
}
</script>
<DropdownMenu.Root>
<DropdownMenu.Trigger>
{#snippet child({ props })}
<Button {...props} variant="outline">
{#each items.filter((item) => selectedItems.includes(item.value)) as item}
<Badge variant="secondary">
{item.label}
</Badge>
{/each}
<LucideChevronDown class="text-muted-foreground ml-2 size-4" />
</Button>
{/snippet}
</DropdownMenu.Trigger>
<DropdownMenu.Content align="start" class="w-[var(--bits-dropdown-menu-anchor-width)]">
{#each items as item}
<DropdownMenu.CheckboxItem
checked={selectedItems.includes(item.value)}
onCheckedChange={() => handleItemSelect(item.value)}
closeOnSelect={autoClose}
>
{item.label}
</DropdownMenu.CheckboxItem>
{/each}
</DropdownMenu.Content>
</DropdownMenu.Root>

View File

@@ -2,15 +2,19 @@
import { Button } from '$lib/components/ui/button'; import { Button } from '$lib/components/ui/button';
import * as Command from '$lib/components/ui/command'; import * as Command from '$lib/components/ui/command';
import * as Popover from '$lib/components/ui/popover'; import * as Popover from '$lib/components/ui/popover';
import { m } from '$lib/paraglide/messages';
import { cn } from '$lib/utils/style'; import { cn } from '$lib/utils/style';
import { LucideCheck, LucideChevronDown } from '@lucide/svelte'; import { LoaderCircle, LucideCheck, LucideChevronDown } from '@lucide/svelte';
import { tick } from 'svelte'; import { tick } from 'svelte';
import type { HTMLAttributes } from 'svelte/elements'; import type { FormEventHandler, HTMLAttributes } from 'svelte/elements';
let { let {
items, items,
value = $bindable(), value = $bindable(),
onSelect, onSelect,
oninput,
isLoading,
selectText = m.select_an_option(),
...restProps ...restProps
}: HTMLAttributes<HTMLButtonElement> & { }: HTMLAttributes<HTMLButtonElement> & {
items: { items: {
@@ -18,7 +22,10 @@
label: string; label: string;
}[]; }[];
value: string; value: string;
oninput?: FormEventHandler<HTMLInputElement>;
onSelect?: (value: string) => void; onSelect?: (value: string) => void;
isLoading?: boolean;
selectText?: string;
} = $props(); } = $props();
let open = $state(false); let open = $state(false);
@@ -53,21 +60,35 @@
</script> </script>
<Popover.Root bind:open {...restProps}> <Popover.Root bind:open {...restProps}>
<Popover.Trigger class="w-full"> <Popover.Trigger>
<Button <Button
variant="outline" variant="outline"
role="combobox" role="combobox"
aria-expanded={open} aria-expanded={open}
class={cn('justify-between', restProps.class)} class={cn('justify-between', restProps.class)}
> >
{items.find((item) => item.value === value)?.label || 'Select an option'} {items.find((item) => item.value === value)?.label || selectText}
<LucideChevronDown class="ml-2 size-4 shrink-0 opacity-50" /> <LucideChevronDown class="ml-2 size-4 shrink-0 opacity-50" />
</Button> </Button>
</Popover.Trigger> </Popover.Trigger>
<Popover.Content class="p-0"> <Popover.Content class="p-0" sameWidth>
<Command.Root shouldFilter={false}> <Command.Root shouldFilter={false}>
<Command.Input placeholder="Search..." oninput={(e: any) => filterItems(e.target.value)} /> <Command.Input
<Command.Empty>No results found.</Command.Empty> placeholder={m.search()}
oninput={(e) => {
filterItems(e.currentTarget.value);
oninput?.(e);
}}
/>
<Command.Empty>
{#if isLoading}
<div class="flex w-full justify-center">
<LoaderCircle class="size-4 animate-spin" />
</div>
{:else}
{m.no_items_found()}
{/if}
</Command.Empty>
<Command.Group> <Command.Group>
{#each filteredItems as item} {#each filteredItems as item}
<Command.Item <Command.Item

View File

@@ -103,6 +103,13 @@ class OidcService extends APIService {
const response = await this.api.get(`/oidc/device/info?code=${userCode}`); const response = await this.api.get(`/oidc/device/info?code=${userCode}`);
return response.data; return response.data;
} }
async getClientPreview(id: string, userId: string, scopes: string) {
const response = await this.api.get(`/oidc/clients/${id}/preview/${userId}`, {
params: { scopes }
});
return response.data;
}
} }
export default OidcService; export default OidcService;

View File

@@ -1,4 +1,8 @@
export function debounced<T extends (...args: any[]) => void>(func: T, delay: number) { export function debounced<T extends (...args: any[]) => any>(
func: T,
delay: number,
onLoadingChange?: (loading: boolean) => void
) {
let debounceTimeout: ReturnType<typeof setTimeout>; let debounceTimeout: ReturnType<typeof setTimeout>;
return (...args: Parameters<T>) => { return (...args: Parameters<T>) => {
@@ -6,8 +10,14 @@ export function debounced<T extends (...args: any[]) => void>(func: T, delay: nu
clearTimeout(debounceTimeout); clearTimeout(debounceTimeout);
} }
debounceTimeout = setTimeout(() => { onLoadingChange?.(true);
func(...args);
debounceTimeout = setTimeout(async () => {
try {
await func(...args);
} finally {
onLoadingChange?.(false);
}
}, delay); }, delay);
}; };
} }

View File

@@ -13,10 +13,11 @@
import clientSecretStore from '$lib/stores/client-secret-store'; import clientSecretStore from '$lib/stores/client-secret-store';
import type { OidcClientCreateWithLogo } from '$lib/types/oidc.type'; import type { OidcClientCreateWithLogo } from '$lib/types/oidc.type';
import { axiosErrorToast } from '$lib/utils/error-util'; 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 { toast } from 'svelte-sonner';
import { slide } from 'svelte/transition'; import { slide } from 'svelte/transition';
import OidcForm from '../oidc-client-form.svelte'; import OidcForm from '../oidc-client-form.svelte';
import OidcClientPreviewModal from '../oidc-client-preview-modal.svelte';
let { data } = $props(); let { data } = $props();
let client = $state({ let client = $state({
@@ -24,6 +25,7 @@
allowedUserGroupIds: data.allowedUserGroups.map((g) => g.id) allowedUserGroupIds: data.allowedUserGroups.map((g) => g.id)
}); });
let showAllDetails = $state(false); let showAllDetails = $state(false);
let showPreview = $state(false);
const oidcService = new OidcService(); const oidcService = new OidcService();
@@ -91,6 +93,12 @@
}); });
} }
let previewUserId = $state<string | null>(null);
function handlePreview(userId: string) {
previewUserId = userId;
}
beforeNavigate(() => { beforeNavigate(() => {
clientSecretStore.clear(); clientSecretStore.clear();
}); });
@@ -180,3 +188,22 @@
<Button onclick={() => updateUserGroupClients(client.allowedUserGroupIds)}>{m.save()}</Button> <Button onclick={() => updateUserGroupClients(client.allowedUserGroupIds)}>{m.save()}</Button>
</div> </div>
</CollapsibleCard> </CollapsibleCard>
<Card.Root>
<Card.Header>
<div class="flex flex-col items-start justify-between gap-3 sm:flex-row sm:items-center">
<div>
<Card.Title>
{m.oidc_data_preview()}
</Card.Title>
<Card.Description>
{m.preview_the_oidc_data_that_would_be_sent_for_different_users()}
</Card.Description>
</div>
<Button variant="outline" onclick={() => (showPreview = true)}>
{m.show()}
</Button>
</div>
</Card.Header>
</Card.Root>
<OidcClientPreviewModal bind:open={showPreview} clientId={client.id} />

View File

@@ -0,0 +1,211 @@
<script lang="ts">
import CopyToClipboard from '$lib/components/copy-to-clipboard.svelte';
import MultiSelect from '$lib/components/form/multi-select.svelte';
import SearchableSelect from '$lib/components/form/searchable-select.svelte';
import * as Alert from '$lib/components/ui/alert';
import { Button } from '$lib/components/ui/button';
import * as Dialog from '$lib/components/ui/dialog';
import Label from '$lib/components/ui/label/label.svelte';
import * as Tabs from '$lib/components/ui/tabs';
import { m } from '$lib/paraglide/messages';
import OidcService from '$lib/services/oidc-service';
import UserService from '$lib/services/user-service';
import type { User } from '$lib/types/user.type';
import { debounced } from '$lib/utils/debounce-util';
import { getAxiosErrorMessage } from '$lib/utils/error-util';
import { LucideAlertTriangle } from '@lucide/svelte';
import { onMount } from 'svelte';
let {
open = $bindable(),
clientId
}: {
open: boolean;
clientId: string;
} = $props();
const oidcService = new OidcService();
const userService = new UserService();
let previewData = $state<{
idToken?: any;
accessToken?: any;
userInfo?: any;
} | null>(null);
let loadingPreview = $state(false);
let isUserSearchLoading = $state(false);
let user: User | null = $state(null);
let users: User[] = $state([]);
let scopes: string[] = $state(['openid', 'email', 'profile']);
let errorMessage: string | null = $state(null);
async function loadPreviewData() {
errorMessage = null;
try {
previewData = await oidcService.getClientPreview(clientId, user!.id, scopes.join(' '));
} catch (e) {
const error = getAxiosErrorMessage(e);
errorMessage = error;
previewData = null;
} finally {
loadingPreview = false;
}
}
async function loadUsers(search?: string) {
users = (
await userService.list({
search,
pagination: { limit: 10, page: 1 }
})
).data;
if (!user) {
user = users[0];
}
}
async function onOpenChange(open: boolean) {
if (!open) {
previewData = null;
errorMessage = null;
} else {
loadingPreview = true;
await loadPreviewData().finally(() => {
loadingPreview = false;
});
}
}
const onUserSearch = debounced(
async (search: string) => await loadUsers(search),
300,
(loading) => (isUserSearchLoading = loading)
);
$effect(() => {
if (open) {
loadPreviewData();
}
});
onMount(() => {
loadUsers();
});
</script>
<Dialog.Root bind:open {onOpenChange}>
<Dialog.Content class="sm-min-w[500px] max-h-[90vh] min-w-[90vw] overflow-auto lg:min-w-[1000px]">
<Dialog.Header>
<Dialog.Title>{m.oidc_data_preview()}</Dialog.Title>
<Dialog.Description>
{#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}
</Dialog.Description>
</Dialog.Header>
<div class="overflow-auto px-4">
{#if loadingPreview}
<div class="flex items-center justify-center py-12">
<div class="h-8 w-8 animate-spin rounded-full border-b-2 border-gray-900"></div>
</div>
{/if}
<div class="flex justify-start gap-3">
<div>
<Label class="text-sm font-medium">{m.users()}</Label>
<div>
<SearchableSelect
class="w-48"
selectText={m.select_user()}
isLoading={isUserSearchLoading}
items={Object.values(users).map((user) => ({
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();
}}
/>
</div>
</div>
<div>
<Label class="text-sm font-medium">Scopes</Label>
<MultiSelect
items={[
{ value: 'openid', label: 'openid' },
{ value: 'email', label: 'email' },
{ value: 'profile', label: 'profile' },
{ value: 'groups', label: 'groups' }
]}
bind:selectedItems={scopes}
/>
</div>
</div>
{#if errorMessage && !loadingPreview}
<Alert.Root variant="destructive" class="mt-5 mb-6">
<LucideAlertTriangle class="h-4 w-4" />
<Alert.Title>{m.error()}</Alert.Title>
<Alert.Description>
{errorMessage}
</Alert.Description>
</Alert.Root>
{/if}
{#if previewData && !loadingPreview}
<Tabs.Root value="id-token" class="mt-5 w-full">
<Tabs.List class="mb-6 grid w-full grid-cols-3">
<Tabs.Trigger value="id-token">{m.id_token()}</Tabs.Trigger>
<Tabs.Trigger value="access-token">{m.access_token()}</Tabs.Trigger>
<Tabs.Trigger value="userinfo">{m.userinfo()}</Tabs.Trigger>
</Tabs.List>
<Tabs.Content value="id-token">
{@render tabContent(previewData.idToken, m.id_token_payload())}
</Tabs.Content>
<Tabs.Content value="access-token" class="mt-4">
{@render tabContent(previewData.accessToken, m.access_token_payload())}
</Tabs.Content>
<Tabs.Content value="userinfo" class="mt-4">
{@render tabContent(previewData.userInfo, m.userinfo_endpoint_response())}
</Tabs.Content>
</Tabs.Root>
{/if}
</div>
</Dialog.Content>
</Dialog.Root>
{#snippet tabContent(data: any, title: string)}
<div class="space-y-4">
<div class="mb-6 flex items-center justify-between">
<Label class="text-lg font-semibold">{title}</Label>
<CopyToClipboard value={JSON.stringify(data, null, 2)}>
<Button size="sm" variant="outline">{m.copy_all()}</Button>
</CopyToClipboard>
</div>
<div class="space-y-3">
{#each Object.entries(data || {}) as [key, value]}
<div class="grid grid-cols-1 items-start gap-4 border-b pb-3 md:grid-cols-[200px_1fr]">
<Label class="pt-1 text-sm font-medium">{key}</Label>
<div class="min-w-0">
<CopyToClipboard value={typeof value === 'string' ? value : JSON.stringify(value)}>
<div
class="text-muted-foreground bg-muted/30 hover:bg-muted/50 cursor-pointer rounded px-3 py-2 font-mono text-sm"
>
{typeof value === 'object' ? JSON.stringify(value, null, 2) : value}
</div>
</CopyToClipboard>
</div>
</div>
{/each}
</div>
</div>
{/snippet}