feat: map allowed groups to OIDC clients (#202)

This commit is contained in:
Elias Schneider
2025-02-03 18:41:15 +01:00
committed by GitHub
parent 430421e98b
commit 13b02a072f
30 changed files with 518 additions and 218 deletions

View File

@@ -176,3 +176,11 @@ func (e *LdapUserGroupUpdateError) Error() string {
return "LDAP user groups can't be updated" return "LDAP user groups can't be updated"
} }
func (e *LdapUserGroupUpdateError) HttpStatusCode() int { return http.StatusForbidden } func (e *LdapUserGroupUpdateError) HttpStatusCode() int { return http.StatusForbidden }
type OidcAccessDeniedError struct{}
func (e *OidcAccessDeniedError) Error() string {
return "You're not allowed to access this service"
}
func (e *OidcAccessDeniedError) HttpStatusCode() int { return http.StatusForbidden }

View File

@@ -14,7 +14,8 @@ func NewOidcController(group *gin.RouterGroup, jwtAuthMiddleware *middleware.Jwt
oc := &OidcController{oidcService: oidcService, jwtService: jwtService} oc := &OidcController{oidcService: oidcService, jwtService: jwtService}
group.POST("/oidc/authorize", jwtAuthMiddleware.Add(false), oc.authorizeHandler) group.POST("/oidc/authorize", jwtAuthMiddleware.Add(false), oc.authorizeHandler)
group.POST("/oidc/authorize/new-client", jwtAuthMiddleware.Add(false), oc.authorizeNewClientHandler) group.POST("/oidc/authorization-required", jwtAuthMiddleware.Add(false), oc.authorizationConfirmationRequiredHandler)
group.POST("/oidc/token", oc.createTokensHandler) group.POST("/oidc/token", oc.createTokensHandler)
group.GET("/oidc/userinfo", oc.userInfoHandler) group.GET("/oidc/userinfo", oc.userInfoHandler)
@@ -24,6 +25,7 @@ func NewOidcController(group *gin.RouterGroup, jwtAuthMiddleware *middleware.Jwt
group.PUT("/oidc/clients/:id", jwtAuthMiddleware.Add(true), oc.updateClientHandler) group.PUT("/oidc/clients/:id", jwtAuthMiddleware.Add(true), oc.updateClientHandler)
group.DELETE("/oidc/clients/:id", jwtAuthMiddleware.Add(true), oc.deleteClientHandler) group.DELETE("/oidc/clients/:id", jwtAuthMiddleware.Add(true), oc.deleteClientHandler)
group.PUT("/oidc/clients/:id/allowed-user-groups", jwtAuthMiddleware.Add(true), oc.updateAllowedUserGroupsHandler)
group.POST("/oidc/clients/:id/secret", jwtAuthMiddleware.Add(true), oc.createClientSecretHandler) group.POST("/oidc/clients/:id/secret", jwtAuthMiddleware.Add(true), oc.createClientSecretHandler)
group.GET("/oidc/clients/:id/logo", oc.getClientLogoHandler) group.GET("/oidc/clients/:id/logo", oc.getClientLogoHandler)
@@ -57,25 +59,20 @@ func (oc *OidcController) authorizeHandler(c *gin.Context) {
c.JSON(http.StatusOK, response) c.JSON(http.StatusOK, response)
} }
func (oc *OidcController) authorizeNewClientHandler(c *gin.Context) { func (oc *OidcController) authorizationConfirmationRequiredHandler(c *gin.Context) {
var input dto.AuthorizeOidcClientRequestDto var input dto.AuthorizationRequiredDto
if err := c.ShouldBindJSON(&input); err != nil { if err := c.ShouldBindJSON(&input); err != nil {
c.Error(err) c.Error(err)
return return
} }
code, callbackURL, err := oc.oidcService.AuthorizeNewClient(input, c.GetString("userID"), c.ClientIP(), c.Request.UserAgent()) hasAuthorizedClient, err := oc.oidcService.HasAuthorizedClient(input.ClientID, c.GetString("userID"), input.Scope)
if err != nil { if err != nil {
c.Error(err) c.Error(err)
return return
} }
response := dto.AuthorizeOidcClientResponseDto{ c.JSON(http.StatusOK, gin.H{"authorizationRequired": !hasAuthorizedClient})
Code: code,
CallbackURL: callbackURL,
}
c.JSON(http.StatusOK, response)
} }
func (oc *OidcController) createTokensHandler(c *gin.Context) { func (oc *OidcController) createTokensHandler(c *gin.Context) {
@@ -134,7 +131,7 @@ func (oc *OidcController) getClientHandler(c *gin.Context) {
// Return a different DTO based on the user's role // Return a different DTO based on the user's role
if c.GetBool("userIsAdmin") { if c.GetBool("userIsAdmin") {
clientDto := dto.OidcClientDto{} clientDto := dto.OidcClientWithAllowedUserGroupsDto{}
err = dto.MapStruct(client, &clientDto) err = dto.MapStruct(client, &clientDto)
if err == nil { if err == nil {
c.JSON(http.StatusOK, clientDto) c.JSON(http.StatusOK, clientDto)
@@ -191,7 +188,7 @@ func (oc *OidcController) createClientHandler(c *gin.Context) {
return return
} }
var clientDto dto.OidcClientDto var clientDto dto.OidcClientWithAllowedUserGroupsDto
if err := dto.MapStruct(client, &clientDto); err != nil { if err := dto.MapStruct(client, &clientDto); err != nil {
c.Error(err) c.Error(err)
return return
@@ -223,7 +220,7 @@ func (oc *OidcController) updateClientHandler(c *gin.Context) {
return return
} }
var clientDto dto.OidcClientDto var clientDto dto.OidcClientWithAllowedUserGroupsDto
if err := dto.MapStruct(client, &clientDto); err != nil { if err := dto.MapStruct(client, &clientDto); err != nil {
c.Error(err) c.Error(err)
return return
@@ -278,3 +275,25 @@ func (oc *OidcController) deleteClientLogoHandler(c *gin.Context) {
c.Status(http.StatusNoContent) c.Status(http.StatusNoContent)
} }
func (oc *OidcController) updateAllowedUserGroupsHandler(c *gin.Context) {
var input dto.OidcUpdateAllowedUserGroupsDto
if err := c.ShouldBindJSON(&input); err != nil {
c.Error(err)
return
}
oidcClient, err := oc.oidcService.UpdateAllowedUserGroups(c.Param("id"), input)
if err != nil {
c.Error(err)
return
}
var oidcClientDto dto.OidcClientDto
if err := dto.MapStruct(oidcClient, &oidcClientDto); err != nil {
c.Error(err)
return
}
c.JSON(http.StatusOK, oidcClientDto)
}

View File

@@ -11,7 +11,14 @@ type OidcClientDto struct {
CallbackURLs []string `json:"callbackURLs"` CallbackURLs []string `json:"callbackURLs"`
IsPublic bool `json:"isPublic"` IsPublic bool `json:"isPublic"`
PkceEnabled bool `json:"pkceEnabled"` PkceEnabled bool `json:"pkceEnabled"`
CreatedBy UserDto `json:"createdBy"` }
type OidcClientWithAllowedUserGroupsDto struct {
PublicOidcClientDto
CallbackURLs []string `json:"callbackURLs"`
IsPublic bool `json:"isPublic"`
PkceEnabled bool `json:"pkceEnabled"`
AllowedUserGroups []UserGroupDtoWithUserCount `json:"allowedUserGroups"`
} }
type OidcClientCreateDto struct { type OidcClientCreateDto struct {
@@ -35,6 +42,11 @@ type AuthorizeOidcClientResponseDto struct {
CallbackURL string `json:"callbackURL"` CallbackURL string `json:"callbackURL"`
} }
type AuthorizationRequiredDto struct {
ClientID string `json:"clientID" binding:"required"`
Scope string `json:"scope" binding:"required"`
}
type OidcCreateTokensDto struct { type OidcCreateTokensDto struct {
GrantType string `form:"grant_type" binding:"required"` GrantType string `form:"grant_type" binding:"required"`
Code string `form:"code" binding:"required"` Code string `form:"code" binding:"required"`
@@ -42,3 +54,7 @@ type OidcCreateTokensDto struct {
ClientSecret string `form:"client_secret"` ClientSecret string `form:"client_secret"`
CodeVerifier string `form:"code_verifier"` CodeVerifier string `form:"code_verifier"`
} }
type OidcUpdateAllowedUserGroupsDto struct {
UserGroupIDs []string `json:"userGroupIds" binding:"required"`
}

View File

@@ -33,7 +33,3 @@ type UserGroupCreateDto struct {
type UserGroupUpdateUsersDto struct { type UserGroupUpdateUsersDto struct {
UserIDs []string `json:"userIds" binding:"required"` UserIDs []string `json:"userIds" binding:"required"`
} }
type AssignUserToGroupDto struct {
UserID string `json:"userId" binding:"required"`
}

View File

@@ -44,6 +44,7 @@ type OidcClient struct {
IsPublic bool IsPublic bool
PkceEnabled bool PkceEnabled bool
AllowedUserGroups []UserGroup `gorm:"many2many:oidc_clients_allowed_user_groups;"`
CreatedByID string CreatedByID string
CreatedBy User CreatedBy User
} }

View File

@@ -38,47 +38,40 @@ func NewOidcService(db *gorm.DB, jwtService *JwtService, appConfigService *AppCo
} }
func (s *OidcService) Authorize(input dto.AuthorizeOidcClientRequestDto, userID, ipAddress, userAgent string) (string, string, error) { func (s *OidcService) Authorize(input dto.AuthorizeOidcClientRequestDto, userID, ipAddress, userAgent string) (string, string, error) {
var userAuthorizedOIDCClient model.UserAuthorizedOidcClient
s.db.Preload("Client").First(&userAuthorizedOIDCClient, "client_id = ? AND user_id = ?", input.ClientID, userID)
if userAuthorizedOIDCClient.Client.IsPublic && input.CodeChallenge == "" {
return "", "", &common.OidcMissingCodeChallengeError{}
}
if userAuthorizedOIDCClient.Scope != input.Scope {
return "", "", &common.OidcMissingAuthorizationError{}
}
callbackURL, err := s.getCallbackURL(userAuthorizedOIDCClient.Client, input.CallbackURL)
if err != nil {
return "", "", err
}
code, err := s.createAuthorizationCode(input.ClientID, userID, input.Scope, input.Nonce, input.CodeChallenge, input.CodeChallengeMethod)
if err != nil {
return "", "", err
}
s.auditLogService.Create(model.AuditLogEventClientAuthorization, ipAddress, userAgent, userID, model.AuditLogData{"clientName": userAuthorizedOIDCClient.Client.Name})
return code, callbackURL, nil
}
func (s *OidcService) AuthorizeNewClient(input dto.AuthorizeOidcClientRequestDto, userID, ipAddress, userAgent string) (string, string, error) {
var client model.OidcClient var client model.OidcClient
if err := s.db.First(&client, "id = ?", input.ClientID).Error; err != nil { if err := s.db.Preload("AllowedUserGroups").First(&client, "id = ?", input.ClientID).Error; err != nil {
return "", "", err return "", "", err
} }
// If the client is not public, the code challenge must be provided
if client.IsPublic && input.CodeChallenge == "" { if client.IsPublic && input.CodeChallenge == "" {
return "", "", &common.OidcMissingCodeChallengeError{} return "", "", &common.OidcMissingCodeChallengeError{}
} }
// Get the callback URL of the client. Return an error if the provided callback URL is not allowed
callbackURL, err := s.getCallbackURL(client, input.CallbackURL) callbackURL, err := s.getCallbackURL(client, input.CallbackURL)
if err != nil { if err != nil {
return "", "", err return "", "", err
} }
// Check if the user group is allowed to authorize the client
var user model.User
if err := s.db.Preload("UserGroups").First(&user, "id = ?", userID).Error; err != nil {
return "", "", err
}
if !s.IsUserGroupAllowedToAuthorize(user, client) {
return "", "", &common.OidcAccessDeniedError{}
}
// Check if the user has already authorized the client with the given scope
hasAuthorizedClient, err := s.HasAuthorizedClient(input.ClientID, userID, input.Scope)
if err != nil {
return "", "", err
}
// If the user has not authorized the client, create a new authorization in the database
if !hasAuthorizedClient {
userAuthorizedClient := model.UserAuthorizedOidcClient{ userAuthorizedClient := model.UserAuthorizedOidcClient{
UserID: userID, UserID: userID,
ClientID: input.ClientID, ClientID: input.ClientID,
@@ -87,22 +80,69 @@ func (s *OidcService) AuthorizeNewClient(input dto.AuthorizeOidcClientRequestDto
if err := s.db.Create(&userAuthorizedClient).Error; err != nil { if err := s.db.Create(&userAuthorizedClient).Error; err != nil {
if errors.Is(err, gorm.ErrDuplicatedKey) { if errors.Is(err, gorm.ErrDuplicatedKey) {
err = s.db.Model(&userAuthorizedClient).Update("scope", input.Scope).Error // The client has already been authorized but with a different scope so we need to update the scope
if err := s.db.Model(&userAuthorizedClient).Update("scope", input.Scope).Error; err != nil {
return "", "", err
}
} else { } else {
return "", "", err return "", "", err
} }
} }
}
// Create the authorization code
code, err := s.createAuthorizationCode(input.ClientID, userID, input.Scope, input.Nonce, input.CodeChallenge, input.CodeChallengeMethod) code, err := s.createAuthorizationCode(input.ClientID, userID, input.Scope, input.Nonce, input.CodeChallenge, input.CodeChallengeMethod)
if err != nil { if err != nil {
return "", "", err return "", "", err
} }
// Log the authorization event
if hasAuthorizedClient {
s.auditLogService.Create(model.AuditLogEventClientAuthorization, ipAddress, userAgent, userID, model.AuditLogData{"clientName": client.Name})
} else {
s.auditLogService.Create(model.AuditLogEventNewClientAuthorization, ipAddress, userAgent, userID, model.AuditLogData{"clientName": client.Name}) s.auditLogService.Create(model.AuditLogEventNewClientAuthorization, ipAddress, userAgent, userID, model.AuditLogData{"clientName": client.Name})
}
return code, callbackURL, nil return code, callbackURL, nil
} }
// HasAuthorizedClient checks if the user has already authorized the client with the given scope
func (s *OidcService) HasAuthorizedClient(clientID, userID, scope string) (bool, error) {
var userAuthorizedOidcClient model.UserAuthorizedOidcClient
if err := s.db.First(&userAuthorizedOidcClient, "client_id = ? AND user_id = ?", clientID, userID).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return false, nil
}
return false, err
}
if userAuthorizedOidcClient.Scope != scope {
return false, nil
}
return true, nil
}
// IsUserGroupAllowedToAuthorize checks if the user group of the user is allowed to authorize the client
func (s *OidcService) IsUserGroupAllowedToAuthorize(user model.User, client model.OidcClient) bool {
if len(client.AllowedUserGroups) == 0 {
return true
}
isAllowedToAuthorize := false
for _, userGroup := range client.AllowedUserGroups {
for _, userGroupUser := range user.UserGroups {
if userGroup.ID == userGroupUser.ID {
isAllowedToAuthorize = true
break
}
}
}
return isAllowedToAuthorize
}
func (s *OidcService) CreateTokens(code, grantType, clientID, clientSecret, codeVerifier string) (string, string, error) { func (s *OidcService) CreateTokens(code, grantType, clientID, clientSecret, codeVerifier string) (string, string, error) {
if grantType != "authorization_code" { if grantType != "authorization_code" {
return "", "", &common.OidcGrantTypeNotSupportedError{} return "", "", &common.OidcGrantTypeNotSupportedError{}
@@ -161,7 +201,7 @@ func (s *OidcService) CreateTokens(code, grantType, clientID, clientSecret, code
func (s *OidcService) GetClient(clientID string) (model.OidcClient, error) { func (s *OidcService) GetClient(clientID string) (model.OidcClient, error) {
var client model.OidcClient var client model.OidcClient
if err := s.db.Preload("CreatedBy").First(&client, "id = ?", clientID).Error; err != nil { if err := s.db.Preload("CreatedBy").Preload("AllowedUserGroups").First(&client, "id = ?", clientID).Error; err != nil {
return model.OidcClient{}, err return model.OidcClient{}, err
} }
return client, nil return client, nil
@@ -382,6 +422,33 @@ func (s *OidcService) GetUserClaimsForClient(userID string, clientID string) (ma
return claims, nil return claims, nil
} }
func (s *OidcService) UpdateAllowedUserGroups(id string, input dto.OidcUpdateAllowedUserGroupsDto) (client model.OidcClient, err error) {
client, err = s.GetClient(id)
if err != nil {
return model.OidcClient{}, err
}
// Fetch the user groups based on UserGroupIDs in input
var groups []model.UserGroup
if len(input.UserGroupIDs) > 0 {
if err := s.db.Where("id IN (?)", input.UserGroupIDs).Find(&groups).Error; err != nil {
return model.OidcClient{}, err
}
}
// Replace the current user groups with the new set of user groups
if err := s.db.Model(&client).Association("AllowedUserGroups").Replace(groups); err != nil {
return model.OidcClient{}, err
}
// Save the updated client
if err := s.db.Save(&client).Error; err != nil {
return model.OidcClient{}, err
}
return client, nil
}
func (s *OidcService) createAuthorizationCode(clientID string, userID string, scope string, nonce string, codeChallenge string, codeChallengeMethod string) (string, error) { func (s *OidcService) createAuthorizationCode(clientID string, userID string, scope string, nonce string, codeChallenge string, codeChallengeMethod string) (string, error) {
randomString, err := utils.GenerateRandomAlphanumericString(32) randomString, err := utils.GenerateRandomAlphanumericString(32)
if err != nil { if err != nil {

View File

@@ -124,7 +124,10 @@ func (s *TestService) SeedDatabase() error {
Name: "Immich", Name: "Immich",
Secret: "$2a$10$Ak.FP8riD1ssy2AGGbG.gOpnp/rBpymd74j0nxNMtW0GG1Lb4gzxe", // PYjrE9u4v9GVqXKi52eur0eb2Ci4kc0x Secret: "$2a$10$Ak.FP8riD1ssy2AGGbG.gOpnp/rBpymd74j0nxNMtW0GG1Lb4gzxe", // PYjrE9u4v9GVqXKi52eur0eb2Ci4kc0x
CallbackURLs: model.CallbackURLs{"http://immich/auth/callback"}, CallbackURLs: model.CallbackURLs{"http://immich/auth/callback"},
CreatedByID: users[0].ID, CreatedByID: users[1].ID,
AllowedUserGroups: []model.UserGroup{
userGroups[1],
},
}, },
} }
for _, client := range oidcClients { for _, client := range oidcClients {
@@ -163,27 +166,31 @@ func (s *TestService) SeedDatabase() error {
return err return err
} }
publicKey1, err := s.getCborPublicKey("MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEwcOo5KV169KR67QEHrcYkeXE3CCxv2BgwnSq4VYTQxyLtdmKxegexa8JdwFKhKXa2BMI9xaN15BoL6wSCRFJhg==") // To generate a new key pair, run the following command:
publicKey2, err := s.getCborPublicKey("MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAESq/wR8QbBu3dKnpaw/v0mDxFFDwnJ/L5XHSg2tAmq5x1BpSMmIr3+DxCbybVvGRmWGh8kKhy7SMnK91M6rFHTA==") // openssl genpkey -algorithm EC -pkeyopt ec_paramgen_curve:P-256 | \
// openssl pkcs8 -topk8 -nocrypt | tee >(openssl pkey -pubout)
publicKeyPasskey1, err := s.getCborPublicKey("MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEwcOo5KV169KR67QEHrcYkeXE3CCxv2BgwnSq4VYTQxyLtdmKxegexa8JdwFKhKXa2BMI9xaN15BoL6wSCRFJhg==")
publicKeyPasskey2, err := s.getCborPublicKey("MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEj4qA0PrZzg8Co1C27nyUbzrp8Ewjr7eOlGI2LfrzmbL5nPhZRAdJ3hEaqrHMSnJBhfMqtQGKwDYpaLIQFAKLhw==")
if err != nil { if err != nil {
return err return err
} }
webauthnCredentials := []model.WebauthnCredential{ webauthnCredentials := []model.WebauthnCredential{
{ {
Name: "Passkey 1", Name: "Passkey 1",
CredentialID: []byte("test-credential-1"), CredentialID: []byte("test-credential-tim"),
PublicKey: publicKey1, PublicKey: publicKeyPasskey1,
AttestationType: "none", AttestationType: "none",
Transport: model.AuthenticatorTransportList{protocol.Internal}, Transport: model.AuthenticatorTransportList{protocol.Internal},
UserID: users[0].ID, UserID: users[0].ID,
}, },
{ {
Name: "Passkey 2", Name: "Passkey 2",
CredentialID: []byte("test-credential-2"), CredentialID: []byte("test-credential-craig"),
PublicKey: publicKey2, PublicKey: publicKeyPasskey2,
AttestationType: "none", AttestationType: "none",
Transport: model.AuthenticatorTransportList{protocol.Internal}, Transport: model.AuthenticatorTransportList{protocol.Internal},
UserID: users[0].ID, UserID: users[1].ID,
}, },
} }
for _, credential := range webauthnCredentials { for _, credential := range webauthnCredentials {

View File

@@ -0,0 +1 @@
DROP TABLE oidc_clients_allowed_user_groups;

View File

@@ -0,0 +1,8 @@
CREATE TABLE oidc_clients_allowed_user_groups
(
user_group_id UUID NOT NULL REFERENCES user_groups ON DELETE CASCADE,
oidc_client_id UUID NOT NULL REFERENCES oidc_clients ON DELETE CASCADE,
PRIMARY KEY (oidc_client_id, user_group_id)
);

View File

@@ -0,0 +1 @@
DROP TABLE oidc_clients_allowed_user_groups;

View File

@@ -0,0 +1,8 @@
CREATE TABLE oidc_clients_allowed_user_groups
(
user_group_id TEXT NOT NULL,
oidc_client_id TEXT NOT NULL,
PRIMARY KEY (oidc_client_id, user_group_id),
FOREIGN KEY (oidc_client_id) REFERENCES oidc_clients (id) ON DELETE CASCADE,
FOREIGN KEY (user_group_id) REFERENCES user_groups (id) ON DELETE CASCADE
);

View File

@@ -0,0 +1,75 @@
<script lang="ts">
import { cn } from '$lib/utils/style';
import { LucideChevronDown } from 'lucide-svelte';
import { onMount, type Snippet } from 'svelte';
import { slide } from 'svelte/transition';
import { Button } from './ui/button';
import * as Card from './ui/card';
let {
id,
title,
description,
defaultExpanded = false,
children
}: {
id: string;
title: string;
description?: string;
defaultExpanded?: boolean;
children: Snippet;
} = $props();
let expanded = $state(defaultExpanded);
function loadExpandedState() {
const state = JSON.parse(localStorage.getItem('collapsible-cards-expanded') || '{}');
expanded = state[id] || false;
}
function saveExpandedState() {
const state = JSON.parse(localStorage.getItem('collapsible-cards-expanded') || '{}');
state[id] = expanded;
localStorage.setItem('collapsible-cards-expanded', JSON.stringify(state));
}
function toggleExpanded() {
expanded = !expanded;
saveExpandedState();
}
onMount(() => {
if (defaultExpanded) {
saveExpandedState();
}
loadExpandedState();
});
</script>
<Card.Root>
<Card.Header class="cursor-pointer" onclick={toggleExpanded}>
<div class="flex items-center justify-between">
<div>
<Card.Title>{title}</Card.Title>
{#if description}
<Card.Description>{description}</Card.Description>
{/if}
</div>
<Button class="ml-10 h-8 p-3" variant="ghost" aria-label="Expand card">
<LucideChevronDown
class={cn(
'h-5 w-5 transition-transform duration-200',
expanded && 'rotate-180 transform'
)}
/>
</Button>
</div>
</Card.Header>
{#if expanded}
<div transition:slide={{ duration: 200 }}>
<Card.Content>
{@render children()}
</Card.Content>
</div>
{/if}
</Card.Root>

View File

@@ -8,6 +8,6 @@
export { className as class }; export { className as class };
</script> </script>
<p class={cn('text-sm text-muted-foreground', className)} {...$$restProps}> <p class={cn('text-sm text-muted-foreground mt-1', className)} {...$$restProps}>
<slot /> <slot />
</p> </p>

View File

@@ -1,4 +1,9 @@
import type { AuthorizeResponse, OidcClient, OidcClientCreate } from '$lib/types/oidc.type'; import type {
AuthorizeResponse,
OidcClient,
OidcClientCreate,
OidcClientWithAllowedUserGroups
} from '$lib/types/oidc.type';
import type { Paginated, SearchPaginationSortRequest } from '$lib/types/pagination.type'; import type { Paginated, SearchPaginationSortRequest } from '$lib/types/pagination.type';
import APIService from './api-service'; import APIService from './api-service';
@@ -23,24 +28,13 @@ class OidcService extends APIService {
return res.data as AuthorizeResponse; return res.data as AuthorizeResponse;
} }
async authorizeNewClient( async isAuthorizationRequired(clientId: string, scope: string) {
clientId: string, const res = await this.api.post('/oidc/authorization-required', {
scope: string,
callbackURL: string,
nonce?: string,
codeChallenge?: string,
codeChallengeMethod?: string
) {
const res = await this.api.post('/oidc/authorize/new-client', {
scope, scope,
nonce, clientId
callbackURL,
clientId,
codeChallenge,
codeChallengeMethod
}); });
return res.data as AuthorizeResponse; return res.data.authorizationRequired as boolean;
} }
async listClients(options?: SearchPaginationSortRequest) { async listClients(options?: SearchPaginationSortRequest) {
@@ -59,7 +53,7 @@ class OidcService extends APIService {
} }
async getClient(id: string) { async getClient(id: string) {
return (await this.api.get(`/oidc/clients/${id}`)).data as OidcClient; return (await this.api.get(`/oidc/clients/${id}`)).data as OidcClientWithAllowedUserGroups;
} }
async updateClient(id: string, client: OidcClientCreate) { async updateClient(id: string, client: OidcClientCreate) {
@@ -88,6 +82,11 @@ class OidcService extends APIService {
async createClientSecret(id: string) { async createClientSecret(id: string) {
return (await this.api.post(`/oidc/clients/${id}/secret`)).data.secret as string; return (await this.api.post(`/oidc/clients/${id}/secret`)).data.secret as string;
} }
async updateAllowedUserGroups(id: string, userGroupIds: string[]) {
const res = await this.api.put(`/oidc/clients/${id}/allowed-user-groups`, { userGroupIds });
return res.data as OidcClientWithAllowedUserGroups;
}
} }
export default OidcService; export default OidcService;

View File

@@ -1,3 +1,5 @@
import type { UserGroup } from './user-group.type';
export type OidcClient = { export type OidcClient = {
id: string; id: string;
name: string; name: string;
@@ -8,6 +10,10 @@ export type OidcClient = {
pkceEnabled: boolean; pkceEnabled: boolean;
}; };
export type OidcClientWithAllowedUserGroups = OidcClient & {
allowedUserGroups: UserGroup[];
};
export type OidcClientCreate = Omit<OidcClient, 'id' | 'logoURL' | 'hasLogo'>; export type OidcClientCreate = Omit<OidcClient, 'id' | 'logoURL' | 'hasLogo'>;
export type OidcClientCreateWithLogo = OidcClientCreate & { export type OidcClientCreateWithLogo = OidcClientCreate & {

View File

@@ -23,6 +23,7 @@
let success = false; let success = false;
let errorMessage: string | null = null; let errorMessage: string | null = null;
let authorizationRequired = false; let authorizationRequired = false;
let authorizationConfirmed = false;
export let data: PageData; export let data: PageData;
let { scope, nonce, client, state, callbackURL, codeChallenge, codeChallengeMethod } = data; let { scope, nonce, client, state, callbackURL, codeChallenge, codeChallengeMethod } = data;
@@ -40,7 +41,17 @@
if (!$userStore?.id) { if (!$userStore?.id) {
const loginOptions = await webauthnService.getLoginOptions(); const loginOptions = await webauthnService.getLoginOptions();
const authResponse = await startAuthentication(loginOptions); const authResponse = await startAuthentication(loginOptions);
await webauthnService.finishLogin(authResponse); const user = await webauthnService.finishLogin(authResponse);
userStore.setUser(user);
}
if (!authorizationConfirmed) {
authorizationRequired = await oidService.isAuthorizationRequired(client!.id, scope);
if (authorizationRequired) {
isLoading = false;
authorizationConfirmed = true;
return;
}
} }
await oidService await oidService
@@ -49,7 +60,7 @@
onSuccess(code, callbackURL); onSuccess(code, callbackURL);
}); });
} catch (e) { } catch (e) {
if (e instanceof AxiosError && e.response?.status === 403) { if (e instanceof AxiosError && e.response?.data.error === 'Missing authorization') {
authorizationRequired = true; authorizationRequired = true;
} else { } else {
errorMessage = getWebauthnErrorMessage(e); errorMessage = getWebauthnErrorMessage(e);
@@ -58,27 +69,6 @@
} }
} }
async function authorizeNewClient() {
isLoading = true;
try {
await oidService
.authorizeNewClient(
client!.id,
scope,
callbackURL,
nonce,
codeChallenge,
codeChallengeMethod
)
.then(async ({ code, callbackURL }) => {
onSuccess(code, callbackURL);
});
} catch (e) {
errorMessage = getWebauthnErrorMessage(e);
isLoading = false;
}
}
function onSuccess(code: string, callbackURL: string) { function onSuccess(code: string, callbackURL: string) {
success = true; success = true;
setTimeout(() => { setTimeout(() => {
@@ -100,14 +90,14 @@
{:else} {:else}
<SignInWrapper showEmailOneTimeAccessButton={$appConfigStore.emailOneTimeAccessEnabled}> <SignInWrapper showEmailOneTimeAccessButton={$appConfigStore.emailOneTimeAccessEnabled}>
<ClientProviderImages {client} {success} error={!!errorMessage} /> <ClientProviderImages {client} {success} error={!!errorMessage} />
<h1 class="mt-5 font-playfair text-3xl font-bold sm:text-4xl">Sign in to {client.name}</h1> <h1 class="font-playfair mt-5 text-3xl font-bold sm:text-4xl">Sign in to {client.name}</h1>
{#if errorMessage} {#if errorMessage}
<p class="mb-10 mt-2 text-muted-foreground"> <p class="text-muted-foreground mb-10 mt-2">
{errorMessage}. Please try again. {errorMessage}.
</p> </p>
{/if} {/if}
{#if !authorizationRequired && !errorMessage} {#if !authorizationRequired && !errorMessage}
<p class="mb-10 mt-2 text-muted-foreground"> <p class="text-muted-foreground mb-10 mt-2">
Do you want to sign in to <b>{client.name}</b> with your Do you want to sign in to <b>{client.name}</b> with your
<b>{$appConfigStore.appName}</b> account? <b>{$appConfigStore.appName}</b> account?
</p> </p>
@@ -115,7 +105,7 @@
<div transition:slide={{ duration: 300 }}> <div transition:slide={{ duration: 300 }}>
<Card.Root class="mb-10 mt-6"> <Card.Root class="mb-10 mt-6">
<Card.Header class="pb-5"> <Card.Header class="pb-5">
<p class="text-start text-muted-foreground"> <p class="text-muted-foreground text-start">
<b>{client.name}</b> wants to access the following information: <b>{client.name}</b> wants to access the following information:
</p> </p>
</Card.Header> </Card.Header>
@@ -146,13 +136,7 @@
<div class="flex w-full justify-stretch gap-2"> <div class="flex w-full justify-stretch gap-2">
<Button onclick={() => history.back()} class="w-full" variant="secondary">Cancel</Button> <Button onclick={() => history.back()} class="w-full" variant="secondary">Cancel</Button>
{#if !errorMessage} {#if !errorMessage}
<Button <Button class="w-full" {isLoading} on:click={authorize}>Sign in</Button>
class="w-full"
{isLoading}
on:click={authorizationRequired ? authorizeNewClient : authorize}
>
Sign in
</Button>
{:else} {:else}
<Button class="w-full" on:click={() => (errorMessage = null)}>Try again</Button> <Button class="w-full" on:click={() => (errorMessage = null)}>Try again</Button>
{/if} {/if}

View File

@@ -30,7 +30,7 @@
<div class="flex justify-center gap-3"> <div class="flex justify-center gap-3">
<div <div
class=" rounded-2xl bg-muted p-3 transition-transform duration-500 ease-in {success || error class=" bg-muted transition-translate rounded-2xl p-3 duration-500 ease-in {success || error
? 'translate-x-[108px]' ? 'translate-x-[108px]'
: ''}" : ''}"
> >
@@ -38,10 +38,12 @@
</div> </div>
<ConnectArrow <ConnectArrow
class="arrow-fade-out h-w-32 w-32 {success || error ? 'opacity-0' : 'opacity-100'}" class="h-w-32 w-32 transition-opacity duration-500 {success || error
? 'opacity-0'
: 'opacity-100 delay-300'}"
/> />
<div <div
class="rounded-2xl p-3 [transition:transform_500ms_ease-in,background-color_200ms] {success || class="rounded-2xl p-3 [transition:translate_500ms_ease-in,background-color_200ms] {success ||
error error
? '-translate-x-[108px]' ? '-translate-x-[108px]'
: ''} {animationDone ? (success ? 'bg-green-200' : 'bg-red-200') : 'bg-muted'}" : ''} {animationDone ? (success ? 'bg-green-200' : 'bg-red-200') : 'bg-muted'}"

View File

@@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import * as Card from '$lib/components/ui/card'; import CollapsibleCard from '$lib/components/collapsible-card.svelte';
import AppConfigService from '$lib/services/app-config-service'; import AppConfigService from '$lib/services/app-config-service';
import appConfigStore from '$lib/stores/application-configuration-store'; import appConfigStore from '$lib/stores/application-configuration-store';
import type { AllAppConfig } from '$lib/types/application-configuration'; import type { AllAppConfig } from '$lib/types/application-configuration';
@@ -55,45 +55,27 @@
<title>Application Configuration</title> <title>Application Configuration</title>
</svelte:head> </svelte:head>
<Card.Root> <CollapsibleCard id="application-configuration-general" title="General" defaultExpanded>
<Card.Header>
<Card.Title>General</Card.Title>
</Card.Header>
<Card.Content>
<AppConfigGeneralForm {appConfig} callback={updateAppConfig} /> <AppConfigGeneralForm {appConfig} callback={updateAppConfig} />
</Card.Content> </CollapsibleCard>
</Card.Root>
<Card.Root> <CollapsibleCard
<Card.Header> id="application-configuration-email"
<Card.Title>Email</Card.Title> title="Email"
<Card.Description> description="Enable email notifications to alert users when a login is detected from a new device or
Enable email notifications to alert users when a login is detected from a new device or location."
location. >
</Card.Description>
</Card.Header>
<Card.Content>
<AppConfigEmailForm {appConfig} callback={updateAppConfig} /> <AppConfigEmailForm {appConfig} callback={updateAppConfig} />
</Card.Content> </CollapsibleCard>
</Card.Root>
<Card.Root> <CollapsibleCard
<Card.Header> id="application-configuration-ldap"
<Card.Title>LDAP</Card.Title> title="LDAP"
<Card.Description> description="Configure LDAP settings to sync users and groups from an LDAP server."
Configure LDAP settings to sync users and groups from an LDAP server. >
</Card.Description>
</Card.Header>
<Card.Content>
<AppConfigLdapForm {appConfig} callback={updateAppConfig} /> <AppConfigLdapForm {appConfig} callback={updateAppConfig} />
</Card.Content> </CollapsibleCard>
</Card.Root>
<Card.Root> <CollapsibleCard id="application-configuration-images" title="Images">
<Card.Header>
<Card.Title>Images</Card.Title>
</Card.Header>
<Card.Content>
<UpdateApplicationImages callback={updateImages} /> <UpdateApplicationImages callback={updateImages} />
</Card.Content> </CollapsibleCard>
</Card.Root>

View File

@@ -1,12 +1,14 @@
<script lang="ts"> <script lang="ts">
import { beforeNavigate } from '$app/navigation'; import { beforeNavigate } from '$app/navigation';
import { page } from '$app/stores'; import { page } from '$app/stores';
import CollapsibleCard from '$lib/components/collapsible-card.svelte';
import { openConfirmDialog } from '$lib/components/confirm-dialog'; import { openConfirmDialog } from '$lib/components/confirm-dialog';
import CopyToClipboard from '$lib/components/copy-to-clipboard.svelte'; import CopyToClipboard from '$lib/components/copy-to-clipboard.svelte';
import { Button } from '$lib/components/ui/button'; import { Button } from '$lib/components/ui/button';
import * as Card from '$lib/components/ui/card'; import * as Card from '$lib/components/ui/card';
import Label from '$lib/components/ui/label/label.svelte'; import Label from '$lib/components/ui/label/label.svelte';
import OidcService from '$lib/services/oidc-service'; import OidcService from '$lib/services/oidc-service';
import UserGroupService from '$lib/services/user-group-service';
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';
@@ -14,12 +16,17 @@
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 UserGroupSelection from '../user-group-selection.svelte';
let { data } = $props(); let { data } = $props();
let client = $state(data); let client = $state({
...data,
allowedUserGroupIds: data.allowedUserGroups.map((g) => g.id)
});
let showAllDetails = $state(false); let showAllDetails = $state(false);
const oidcService = new OidcService(); const oidcService = new OidcService();
const userGroupService = new UserGroupService();
const setupDetails = $state({ const setupDetails = $state({
'Authorization URL': `https://${$page.url.hostname}/authorize`, 'Authorization URL': `https://${$page.url.hostname}/authorize`,
@@ -74,6 +81,17 @@
}); });
} }
async function updateUserGroupClients(allowedGroups: string[]) {
await oidcService
.updateAllowedUserGroups(client.id, allowedGroups)
.then(() => {
toast.success('Allowed user groups updated successfully');
})
.catch((e) => {
axiosErrorToast(e);
});
}
beforeNavigate(() => { beforeNavigate(() => {
clientSecretStore.clear(); clientSecretStore.clear();
}); });
@@ -84,7 +102,7 @@
</svelte:head> </svelte:head>
<div> <div>
<a class="flex text-sm text-muted-foreground" href="/settings/admin/oidc-clients" <a class="text-muted-foreground flex text-sm" href="/settings/admin/oidc-clients"
><LucideChevronLeft class="h-5 w-5" /> Back</a ><LucideChevronLeft class="h-5 w-5" /> Back</a
> >
</div> </div>
@@ -97,7 +115,7 @@
<div class="mb-2 flex"> <div class="mb-2 flex">
<Label class="mb-0 w-44">Client ID</Label> <Label class="mb-0 w-44">Client ID</Label>
<CopyToClipboard value={client.id}> <CopyToClipboard value={client.id}>
<span class="text-sm text-muted-foreground" data-testid="client-id"> {client.id}</span> <span class="text-muted-foreground text-sm" data-testid="client-id"> {client.id}</span>
</CopyToClipboard> </CopyToClipboard>
</div> </div>
{#if !client.isPublic} {#if !client.isPublic}
@@ -105,12 +123,12 @@
<Label class="w-44">Client secret</Label> <Label class="w-44">Client secret</Label>
{#if $clientSecretStore} {#if $clientSecretStore}
<CopyToClipboard value={$clientSecretStore}> <CopyToClipboard value={$clientSecretStore}>
<span class="text-sm text-muted-foreground" data-testid="client-secret"> <span class="text-muted-foreground text-sm" data-testid="client-secret">
{$clientSecretStore} {$clientSecretStore}
</span> </span>
</CopyToClipboard> </CopyToClipboard>
{:else} {:else}
<span class="text-sm text-muted-foreground" data-testid="client-secret" <span class="text-muted-foreground text-sm" data-testid="client-secret"
>••••••••••••••••••••••••••••••••</span >••••••••••••••••••••••••••••••••</span
> >
<Button <Button
@@ -129,7 +147,7 @@
<div class="mb-5 flex"> <div class="mb-5 flex">
<Label class="mb-0 w-44">{key}</Label> <Label class="mb-0 w-44">{key}</Label>
<CopyToClipboard {value}> <CopyToClipboard {value}>
<span class="text-sm text-muted-foreground">{value}</span> <span class="text-muted-foreground text-sm">{value}</span>
</CopyToClipboard> </CopyToClipboard>
</div> </div>
{/each} {/each}
@@ -151,3 +169,15 @@
<OidcForm existingClient={client} callback={updateClient} /> <OidcForm existingClient={client} callback={updateClient} />
</Card.Content> </Card.Content>
</Card.Root> </Card.Root>
<CollapsibleCard
id="allowed-user-groups"
title="Allowed User Groups"
description="Add user groups to this client to restrict access to users in these groups. If no user groups are selected, all users will have access to this client."
>
{#await userGroupService.list() then groups}
<UserGroupSelection {groups} bind:selectedGroupIds={client.allowedUserGroupIds} />
{/await}
<div class="mt-5 flex justify-end">
<Button on:click={() => updateUserGroupClients(client.allowedUserGroupIds)}>Save</Button>
</div>
</CollapsibleCard>

View File

@@ -76,7 +76,7 @@
</script> </script>
<form onsubmit={onSubmit}> <form onsubmit={onSubmit}>
<div class="grid grid-cols-2 gap-3 sm:flex-row"> <div class="grid grid-cols-2 gap-x-3 gap-y-7 sm:flex-row">
<FormInput label="Name" class="w-full" bind:input={$inputs.name} /> <FormInput label="Name" class="w-full" bind:input={$inputs.name} />
<OidcCallbackUrlInput <OidcCallbackUrlInput
class="w-full" class="w-full"

View File

@@ -0,0 +1,34 @@
<script lang="ts">
import AdvancedTable from '$lib/components/advanced-table.svelte';
import * as Table from '$lib/components/ui/table';
import UserGroupService from '$lib/services/user-group-service';
import type { OidcClient } from '$lib/types/oidc.type';
import type { Paginated } from '$lib/types/pagination.type';
import type { UserGroup } from '$lib/types/user-group.type';
let {
groups: initialGroups,
selectionDisabled = false,
selectedGroupIds = $bindable()
}: {
groups: Paginated<UserGroup>;
selectionDisabled?: boolean;
selectedGroupIds: string[];
} = $props();
const userGroupService = new UserGroupService();
let groups = $state(initialGroups);
</script>
<AdvancedTable
items={groups}
onRefresh={async (o) => (groups = await userGroupService.list(o))}
columns={[{ label: 'Name', sortColumn: 'name' }]}
bind:selectedIds={selectedGroupIds}
{selectionDisabled}
>
{#snippet rows({ item })}
<Table.Cell>{item.name}</Table.Cell>
{/snippet}
</AdvancedTable>

View File

@@ -1,4 +1,5 @@
<script lang="ts"> <script lang="ts">
import CollapsibleCard from '$lib/components/collapsible-card.svelte';
import CustomClaimsInput from '$lib/components/custom-claims-input.svelte'; import CustomClaimsInput from '$lib/components/custom-claims-input.svelte';
import { Badge } from '$lib/components/ui/badge'; import { Badge } from '$lib/components/ui/badge';
import { Button } from '$lib/components/ui/button'; import { Button } from '$lib/components/ui/button';
@@ -61,7 +62,7 @@
</svelte:head> </svelte:head>
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<a class="flex text-sm text-muted-foreground" href="/settings/admin/user-groups" <a class="text-muted-foreground flex text-sm" href="/settings/admin/user-groups"
><LucideChevronLeft class="h-5 w-5" /> Back</a ><LucideChevronLeft class="h-5 w-5" /> Back</a
> >
{#if !!userGroup.ldapId} {#if !!userGroup.ldapId}
@@ -100,19 +101,13 @@
</Card.Content> </Card.Content>
</Card.Root> </Card.Root>
<Card.Root> <CollapsibleCard
<Card.Header> id="user-group-custom-claims"
<Card.Title>Custom Claims</Card.Title> title="Custom Claims"
<Card.Description> description="Custom claims are key-value pairs that can be used to store additional information about a user. These claims will be included in the ID token if the scope 'profile' is requested. Custom claims defined on the user will be prioritized if there are conflicts."
Custom claims are key-value pairs that can be used to store additional information about a >
user. These claims will be included in the ID token if the scope "profile" is requested.
Custom claims defined on the user will be prioritized if there are conflicts.
</Card.Description>
</Card.Header>
<Card.Content>
<CustomClaimsInput bind:customClaims={userGroup.customClaims} /> <CustomClaimsInput bind:customClaims={userGroup.customClaims} />
<div class="mt-5 flex justify-end"> <div class="mt-5 flex justify-end">
<Button onclick={updateCustomClaims} type="submit">Save</Button> <Button onclick={updateCustomClaims} type="submit">Save</Button>
</div> </div>
</Card.Content> </CollapsibleCard>
</Card.Root>

View File

@@ -32,10 +32,10 @@
try { try {
await userGroupService.remove(userGroup.id); await userGroupService.remove(userGroup.id);
userGroups = await userGroupService.list(requestOptions!); userGroups = await userGroupService.list(requestOptions!);
toast.success('User group deleted successfully');
} catch (e) { } catch (e) {
axiosErrorToast(e); axiosErrorToast(e);
} }
toast.success('User group deleted successfully');
} }
} }
}); });

View File

@@ -1,4 +1,5 @@
<script lang="ts"> <script lang="ts">
import CollapsibleCard from '$lib/components/collapsible-card.svelte';
import Badge from '$lib/components/ui/badge/badge.svelte'; import Badge from '$lib/components/ui/badge/badge.svelte';
import { Button } from '$lib/components/ui/button'; import { Button } from '$lib/components/ui/button';
import * as Card from '$lib/components/ui/card'; import * as Card from '$lib/components/ui/card';
@@ -45,7 +46,7 @@
</svelte:head> </svelte:head>
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<a class="flex text-sm text-muted-foreground" href="/settings/admin/users" <a class="text-muted-foreground flex text-sm" href="/settings/admin/users"
><LucideChevronLeft class="h-5 w-5" /> Back</a ><LucideChevronLeft class="h-5 w-5" /> Back</a
> >
{#if !!user.ldapId} {#if !!user.ldapId}
@@ -61,18 +62,13 @@
</Card.Content> </Card.Content>
</Card.Root> </Card.Root>
<Card.Root> <CollapsibleCard
<Card.Header> id="user-custom-claims"
<Card.Title>Custom Claims</Card.Title> title="Custom Claims"
<Card.Description> description="Custom claims are key-value pairs that can be used to store additional information about a user. These claims will be included in the ID token if the scope 'profile' is requested."
Custom claims are key-value pairs that can be used to store additional information about a >
user. These claims will be included in the ID token if the scope "profile" is requested.
</Card.Description>
</Card.Header>
<Card.Content>
<CustomClaimsInput bind:customClaims={user.customClaims} /> <CustomClaimsInput bind:customClaims={user.customClaims} />
<div class="mt-5 flex justify-end"> <div class="mt-5 flex justify-end">
<Button onclick={updateCustomClaims} type="submit">Save</Button> <Button onclick={updateCustomClaims} type="submit">Save</Button>
</div> </div>
</Card.Content> </CollapsibleCard>
</Card.Root>

View File

@@ -40,7 +40,7 @@ test('Update account details fails with already taken username', async ({ page }
test('Add passkey to an account', async ({ page }) => { test('Add passkey to an account', async ({ page }) => {
await page.goto('/settings/account'); await page.goto('/settings/account');
await (await passkeyUtil.init(page)).addPasskey('new'); await (await passkeyUtil.init(page)).addPasskey('timNew');
await page.click('button:text("Add Passkey")'); await page.click('button:text("Add Passkey")');

View File

@@ -24,6 +24,8 @@ test('Update general configuration', async ({ page }) => {
test('Update email configuration', async ({ page }) => { test('Update email configuration', async ({ page }) => {
await page.goto('/settings/admin/application-configuration'); await page.goto('/settings/admin/application-configuration');
await page.getByRole('button', { name: 'Expand card' }).nth(1).click();
await page.getByLabel('SMTP Host').fill('smtp.gmail.com'); await page.getByLabel('SMTP Host').fill('smtp.gmail.com');
await page.getByLabel('SMTP Port').fill('587'); await page.getByLabel('SMTP Port').fill('587');
await page.getByLabel('SMTP User').fill('test@gmail.com'); await page.getByLabel('SMTP User').fill('test@gmail.com');
@@ -47,14 +49,53 @@ test('Update email configuration', async ({ page }) => {
await expect(page.getByLabel('Email One Time Access')).toBeChecked(); await expect(page.getByLabel('Email One Time Access')).toBeChecked();
}); });
test('Update LDAP configuration', async ({ page }) => {
await page.goto('/settings/admin/application-configuration');
await page.getByRole('button', { name: 'Expand card' }).nth(2).click();
await page.getByLabel('LDAP URL').fill('ldap://localhost:389');
await page.getByLabel('LDAP Bind DN').fill('cn=admin,dc=example,dc=com');
await page.getByLabel('LDAP Bind Password').fill('password');
await page.getByLabel('LDAP Base DN').fill('dc=example,dc=com');
await page.getByLabel('User Unique Identifier Attribute').fill('uuid');
await page.getByLabel('Username Attribute').fill('uid');
await page.getByLabel('User Mail Attribute').fill('mail');
await page.getByLabel('User First Name Attribute').fill('givenName');
await page.getByLabel('User Last Name Attribute').fill('sn');
await page.getByLabel('Group Unique Identifier Attribute').fill('uuid');
await page.getByLabel('Group Name Attribute').fill('cn');
await page.getByLabel('Admin Group Name').fill('admin');
await page.getByRole('button', { name: 'Enable' }).click();
await expect(page.getByRole('status')).toHaveText('LDAP configuration updated successfully');
await page.reload();
await expect(page.getByRole('button', { name: 'Disable' })).toBeVisible();
await expect(page.getByLabel('LDAP URL')).toHaveValue('ldap://localhost:389');
await expect(page.getByLabel('LDAP Bind DN')).toHaveValue('cn=admin,dc=example,dc=com');
await expect(page.getByLabel('LDAP Bind Password')).toHaveValue('password');
await expect(page.getByLabel('LDAP Base DN')).toHaveValue('dc=example,dc=com');
await expect(page.getByLabel('User Unique Identifier Attribute')).toHaveValue('uuid');
await expect(page.getByLabel('Username Attribute')).toHaveValue('uid');
await expect(page.getByLabel('User Mail Attribute')).toHaveValue('mail');
await expect(page.getByLabel('User First Name Attribute')).toHaveValue('givenName');
await expect(page.getByLabel('User Last Name Attribute')).toHaveValue('sn');
await expect(page.getByLabel('Admin Group Name')).toHaveValue('admin');
});
test('Update application images', async ({ page }) => { test('Update application images', async ({ page }) => {
await page.goto('/settings/admin/application-configuration'); await page.goto('/settings/admin/application-configuration');
await page.getByRole('button', { name: 'Expand card' }).nth(3).click();
await page.getByLabel('Favicon').setInputFiles('tests/assets/w3-schools-favicon.ico'); await page.getByLabel('Favicon').setInputFiles('tests/assets/w3-schools-favicon.ico');
await page.getByLabel('Light Mode Logo').setInputFiles('tests/assets/pingvin-share-logo.png'); await page.getByLabel('Light Mode Logo').setInputFiles('tests/assets/pingvin-share-logo.png');
await page.getByLabel('Dark Mode Logo').setInputFiles('tests/assets/nextcloud-logo.png'); await page.getByLabel('Dark Mode Logo').setInputFiles('tests/assets/nextcloud-logo.png');
await page.getByLabel('Background Image').setInputFiles('tests/assets/clouds.jpg'); await page.getByLabel('Background Image').setInputFiles('tests/assets/clouds.jpg');
await page.getByRole('button', { name: 'Save' }).nth(2).click(); await page.getByRole('button', { name: 'Save' }).nth(1).click();
await expect(page.getByRole('status')).toHaveText('Images updated successfully'); await expect(page.getByRole('status')).toHaveText('Images updated successfully');

View File

@@ -75,6 +75,24 @@ test('Authorize new client while not signed in', async ({ page }) => {
}); });
}); });
test('Authorize new client fails with user group not allowed', async ({ page }) => {
const oidcClient = oidcClients.immich;
const urlParams = createUrlParams(oidcClient);
await page.context().clearCookies();
await page.goto(`/authorize?${urlParams.toString()}`);
await (await passkeyUtil.init(page)).addPasskey('craig');
await page.getByRole('button', { name: 'Sign in' }).click();
await expect(page.getByTestId('scopes').getByRole('heading', { name: 'Email' })).toBeVisible();
await expect(page.getByTestId('scopes').getByRole('heading', { name: 'Profile' })).toBeVisible();
await page.getByRole('button', { name: 'Sign in' }).click();
await expect(page.getByRole('paragraph').first()).toHaveText("You're not allowed to access this service.");
});
function createUrlParams(oidcClient: { id: string; callbackUrl: string }) { function createUrlParams(oidcClient: { id: string; callbackUrl: string }) {
return new URLSearchParams({ return new URLSearchParams({
client_id: oidcClient.id, client_id: oidcClient.id,

View File

@@ -77,6 +77,8 @@ test('Delete user group', async ({ page }) => {
test('Update user group custom claims', async ({ page }) => { test('Update user group custom claims', async ({ page }) => {
await page.goto(`/settings/admin/user-groups/${userGroups.designers.id}`); await page.goto(`/settings/admin/user-groups/${userGroups.designers.id}`);
await page.getByRole('button', { name: 'Expand card' }).click();
// Add two custom claims // Add two custom claims
await page.getByRole('button', { name: 'Add custom claim' }).click(); await page.getByRole('button', { name: 'Add custom claim' }).click();

View File

@@ -142,6 +142,8 @@ test('Update user fails with already taken username', async ({ page }) => {
test('Update user custom claims', async ({ page }) => { test('Update user custom claims', async ({ page }) => {
await page.goto(`/settings/admin/users/${users.craig.id}`); await page.goto(`/settings/admin/users/${users.craig.id}`);
await page.getByRole('button', { name: 'Expand card' }).click();
// Add two custom claims // Add two custom claims
await page.getByRole('button', { name: 'Add custom claim' }).click(); await page.getByRole('button', { name: 'Add custom claim' }).click();

View File

@@ -2,18 +2,21 @@ import type { CDPSession, Page } from '@playwright/test';
// The existing passkeys are already stored in the database // The existing passkeys are already stored in the database
const passkeys = { const passkeys = {
existing1: { tim: {
credentialId: 'test-credential-1', credentialId: 'test-credential-tim',
userHandle: 'f4b89dc2-62fb-46bf-9f5f-c34f4eafe93e',
privateKey: privateKey:
'MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQg3rNKkGApsEA1TpGiphKh6axTq3Vh6wBghLLea/YkIp+hRANCAATBw6jkpXXr0pHrtAQetxiR5cTcILG/YGDCdKrhVhNDHIu12YrF6B7Frwl3AUqEpdrYEwj3Fo3XkGgvrBIJEUmG' 'MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQg3rNKkGApsEA1TpGiphKh6axTq3Vh6wBghLLea/YkIp+hRANCAATBw6jkpXXr0pHrtAQetxiR5cTcILG/YGDCdKrhVhNDHIu12YrF6B7Frwl3AUqEpdrYEwj3Fo3XkGgvrBIJEUmG'
}, },
existing2: { craig: {
credentialId: 'test-credential-2', credentialId: 'test-credential-craig',
userHandle: '1cd19686-f9a6-43f4-a41f-14a0bf5b4036',
privateKey: privateKey:
'MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQg3rNKkGApsEA1TpGiphKh6axTq3Vh6wBghLLea/YkIp+hRANCAATBw6jkpXXr0pHrtAQetxiR5cTcILG/YGDCdKrhVhNDHIu12YrF6B7Frwl3AUqEpdrYEwj3Fo3XkGgvrBIJEUmG' 'MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgL1UaeWG1KYpN+HcxQvXEJysiQjT9Fn7Zif3i5cY+s+yhRANCAASPioDQ+tnODwKjULbufJRvOunwTCOvt46UYjYt+vOZsvmc+FlEB0neERqqscxKckGF8yq1AYrANiloshAUAouH'
}, },
new: { timNew: {
credentialId: 'new-test-credential', credentialId: 'new-test-credential-tim',
userHandle: 'f4b89dc2-62fb-46bf-9f5f-c34f4eafe93e',
privateKey: privateKey:
'MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgFl2lIlRyc2G7O9D8WWrw2N8D7NTlhgWcKFY7jYxrfcmhRANCAASmvbCFrXshUvW7avTIysV9UymbhmUwGb7AonUMQPgqK2Jur7PWp9V0AIe5YMuXYH1oxsqY5CoAbdY2YsPmhYoX' 'MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgFl2lIlRyc2G7O9D8WWrw2N8D7NTlhgWcKFY7jYxrfcmhRANCAASmvbCFrXshUvW7avTIysV9UymbhmUwGb7AonUMQPgqK2Jur7PWp9V0AIe5YMuXYH1oxsqY5CoAbdY2YsPmhYoX'
} }
@@ -48,9 +51,9 @@ async function addVirtualAuthenticator(client: CDPSession): Promise<string> {
async function addPasskey( async function addPasskey(
authenticatorId: string, authenticatorId: string,
client: CDPSession, client: CDPSession,
passkeyName?: keyof typeof passkeys passkeyName: keyof typeof passkeys = 'tim'
): Promise<void> { ): Promise<void> {
const passkey = passkeys[passkeyName ?? 'existing1']; const passkey = passkeys[passkeyName];
await client.send('WebAuthn.addCredential', { await client.send('WebAuthn.addCredential', {
authenticatorId, authenticatorId,
credential: { credential: {
@@ -58,9 +61,8 @@ async function addPasskey(
isResidentCredential: true, isResidentCredential: true,
rpId: 'localhost', rpId: 'localhost',
privateKey: passkey.privateKey, privateKey: passkey.privateKey,
userHandle: btoa('f4b89dc2-62fb-46bf-9f5f-c34f4eafe93e'), userHandle: btoa(passkey.userHandle),
signCount: Math.round((new Date().getTime() - 1704444610871) / 1000 / 2) signCount: Math.round((new Date().getTime() - 1704444610871) / 1000 / 2)
// signCount: 2,
} }
}); });
} }