mirror of
https://github.com/nikdoof/pocket-id.git
synced 2025-12-14 07:12:19 +00:00
feat: map allowed groups to OIDC clients (#202)
This commit is contained in:
@@ -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 }
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -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"`
|
||||||
|
}
|
||||||
|
|||||||
@@ -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"`
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
DROP TABLE oidc_clients_allowed_user_groups;
|
||||||
@@ -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)
|
||||||
|
);
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
DROP TABLE oidc_clients_allowed_user_groups;
|
||||||
@@ -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
|
||||||
|
);
|
||||||
75
frontend/src/lib/components/collapsible-card.svelte
Normal file
75
frontend/src/lib/components/collapsible-card.svelte
Normal 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>
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 & {
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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'}"
|
||||||
|
|||||||
@@ -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>
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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>
|
|
||||||
|
|||||||
@@ -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');
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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>
|
|
||||||
|
|||||||
@@ -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")');
|
||||||
|
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user