mirror of
https://github.com/pocket-id/pocket-id.git
synced 2025-12-16 09:13:20 +03:00
feat: oidc client data preview (#624)
Co-authored-by: Elias Schneider <login@eliasschneider.com>
This commit is contained in:
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
19
backend/internal/utils/jwt_util.go
Normal file
19
backend/internal/utils/jwt_util.go
Normal 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
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
56
frontend/src/lib/components/form/multi-select.svelte
Normal file
56
frontend/src/lib/components/form/multi-select.svelte
Normal 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>
|
||||
@@ -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<HTMLButtonElement> & {
|
||||
items: {
|
||||
@@ -18,7 +22,10 @@
|
||||
label: string;
|
||||
}[];
|
||||
value: string;
|
||||
oninput?: FormEventHandler<HTMLInputElement>;
|
||||
onSelect?: (value: string) => void;
|
||||
isLoading?: boolean;
|
||||
selectText?: string;
|
||||
} = $props();
|
||||
|
||||
let open = $state(false);
|
||||
@@ -53,21 +60,35 @@
|
||||
</script>
|
||||
|
||||
<Popover.Root bind:open {...restProps}>
|
||||
<Popover.Trigger class="w-full">
|
||||
<Popover.Trigger>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
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" />
|
||||
</Button>
|
||||
</Popover.Trigger>
|
||||
<Popover.Content class="p-0">
|
||||
<Popover.Content class="p-0" sameWidth>
|
||||
<Command.Root shouldFilter={false}>
|
||||
<Command.Input placeholder="Search..." oninput={(e: any) => filterItems(e.target.value)} />
|
||||
<Command.Empty>No results found.</Command.Empty>
|
||||
<Command.Input
|
||||
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>
|
||||
{#each filteredItems as item}
|
||||
<Command.Item
|
||||
|
||||
@@ -103,6 +103,13 @@ class OidcService extends APIService {
|
||||
const response = await this.api.get(`/oidc/device/info?code=${userCode}`);
|
||||
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;
|
||||
|
||||
@@ -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>;
|
||||
|
||||
return (...args: Parameters<T>) => {
|
||||
@@ -6,8 +10,14 @@ export function debounced<T extends (...args: any[]) => void>(func: T, delay: nu
|
||||
clearTimeout(debounceTimeout);
|
||||
}
|
||||
|
||||
debounceTimeout = setTimeout(() => {
|
||||
func(...args);
|
||||
onLoadingChange?.(true);
|
||||
|
||||
debounceTimeout = setTimeout(async () => {
|
||||
try {
|
||||
await func(...args);
|
||||
} finally {
|
||||
onLoadingChange?.(false);
|
||||
}
|
||||
}, delay);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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<string | null>(null);
|
||||
|
||||
function handlePreview(userId: string) {
|
||||
previewUserId = userId;
|
||||
}
|
||||
|
||||
beforeNavigate(() => {
|
||||
clientSecretStore.clear();
|
||||
});
|
||||
@@ -180,3 +188,22 @@
|
||||
<Button onclick={() => updateUserGroupClients(client.allowedUserGroupIds)}>{m.save()}</Button>
|
||||
</div>
|
||||
</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} />
|
||||
|
||||
@@ -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}
|
||||
Reference in New Issue
Block a user