mirror of
https://github.com/pocket-id/pocket-id.git
synced 2025-12-10 23:22:57 +03:00
Compare commits
18 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e2c38138be | ||
|
|
13b02a072f | ||
|
|
430421e98b | ||
|
|
61e71ad43b | ||
|
|
4db44e4818 | ||
|
|
9ab178712a | ||
|
|
ecd74b794f | ||
|
|
5afd651434 | ||
|
|
2d3cba6308 | ||
|
|
e607fe424a | ||
|
|
8ae446322a | ||
|
|
37a835b44e | ||
|
|
75f531fbc6 | ||
|
|
28346da731 | ||
|
|
a1b20f0e74 | ||
|
|
7497f4ad40 | ||
|
|
b530d646ac | ||
|
|
77985800ae |
51
.github/workflows/deploy-docs.yml
vendored
Normal file
51
.github/workflows/deploy-docs.yml
vendored
Normal file
@@ -0,0 +1,51 @@
|
||||
name: Deploy Docs
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- "docs/**"
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build Docusaurus
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: lts/*
|
||||
cache: "npm"
|
||||
cache-dependency-path: docs/package-lock.json
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm install
|
||||
working-directory: ./docs
|
||||
|
||||
- name: Build website
|
||||
run: npm run build
|
||||
working-directory: ./docs
|
||||
|
||||
- name: Upload Build Artifact
|
||||
uses: actions/upload-pages-artifact@v3
|
||||
with:
|
||||
path: docs/build
|
||||
|
||||
deploy:
|
||||
name: Deploy to GitHub Pages
|
||||
needs: build
|
||||
|
||||
permissions:
|
||||
pages: write
|
||||
id-token: write
|
||||
|
||||
environment:
|
||||
name: github-pages
|
||||
url: ${{ steps.deployment.outputs.page_url }}
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Deploy to GitHub Pages
|
||||
id: deployment
|
||||
uses: actions/deploy-pages@v4
|
||||
9
.github/workflows/e2e-tests.yml
vendored
9
.github/workflows/e2e-tests.yml
vendored
@@ -2,8 +2,17 @@ name: E2E Tests
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths-ignore:
|
||||
- "docs/**"
|
||||
- "**.md"
|
||||
- ".github/**"
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths-ignore:
|
||||
- "docs/**"
|
||||
- "**.md"
|
||||
- ".github/**"
|
||||
|
||||
jobs:
|
||||
build:
|
||||
timeout-minutes: 20
|
||||
|
||||
16
CHANGELOG.md
16
CHANGELOG.md
@@ -1,3 +1,19 @@
|
||||
## [](https://github.com/stonith404/pocket-id/compare/v0.27.2...v) (2025-02-03)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* allow LDAP users and groups to be deleted if LDAP gets disabled ([9ab1787](https://github.com/stonith404/pocket-id/commit/9ab178712aa3cc71546a89226e67b7ba91245251))
|
||||
* map allowed groups to OIDC clients ([#202](https://github.com/stonith404/pocket-id/issues/202)) ([13b02a0](https://github.com/stonith404/pocket-id/commit/13b02a072f20ce10e12fd8b897cbf42a908f3291))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **caddy:** trusted_proxies for IPv6 enabled hosts ([#189](https://github.com/stonith404/pocket-id/issues/189)) ([37a835b](https://github.com/stonith404/pocket-id/commit/37a835b44e308622f6862de494738dd2bfb58ef0))
|
||||
* missing user service dependency ([61e71ad](https://github.com/stonith404/pocket-id/commit/61e71ad43b8f0f498133d3eb2381382e7bc642b9))
|
||||
* non LDAP user group can't be updated after update ([ecd74b7](https://github.com/stonith404/pocket-id/commit/ecd74b794f1ffb7da05bce0046fb8d096b039409))
|
||||
* use cursor pointer on clickable elements ([7798580](https://github.com/stonith404/pocket-id/commit/77985800ae9628104e03e7f2e803b7ed9eaaf4e0))
|
||||
|
||||
## [](https://github.com/stonith404/pocket-id/compare/v0.27.1...v) (2025-01-27)
|
||||
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
Pocket ID is a simple OIDC provider that allows users to authenticate with their passkeys to your services.
|
||||
|
||||
→ Try out the [Demo](https://pocket-id.eliasschneider.com)
|
||||
→ Try out the [Demo](https://demo.pocket-id.org)
|
||||
|
||||
<img src="https://github.com/user-attachments/assets/96ac549d-b897-404a-8811-f42b16ea58e2" width="1200"/>
|
||||
|
||||
@@ -14,7 +14,7 @@ Additionally, what makes Pocket ID special is that it only supports [passkey](ht
|
||||
|
||||
Pocket ID can be set up in multiple ways. The easiest and recommended way is to use Docker.
|
||||
|
||||
Visit the [documentation](https://stonith404.github.io/pocket-id) for the setup guide and more information.
|
||||
Visit the [documentation](https://docs.pocket-id.org) for the setup guide and more information.
|
||||
|
||||
## Contribute
|
||||
|
||||
|
||||
@@ -38,11 +38,11 @@ func initRouter(db *gorm.DB, appConfigService *service.AppConfigService) {
|
||||
auditLogService := service.NewAuditLogService(db, appConfigService, emailService, geoLiteService)
|
||||
jwtService := service.NewJwtService(appConfigService)
|
||||
webauthnService := service.NewWebAuthnService(db, jwtService, auditLogService, appConfigService)
|
||||
userService := service.NewUserService(db, jwtService, auditLogService, emailService)
|
||||
userService := service.NewUserService(db, jwtService, auditLogService, emailService, appConfigService)
|
||||
customClaimService := service.NewCustomClaimService(db)
|
||||
oidcService := service.NewOidcService(db, jwtService, appConfigService, auditLogService, customClaimService)
|
||||
testService := service.NewTestService(db, appConfigService)
|
||||
userGroupService := service.NewUserGroupService(db)
|
||||
userGroupService := service.NewUserGroupService(db, appConfigService)
|
||||
ldapService := service.NewLdapService(db, appConfigService, userService, userGroupService)
|
||||
|
||||
rateLimitMiddleware := middleware.NewRateLimitMiddleware()
|
||||
|
||||
@@ -176,3 +176,11 @@ func (e *LdapUserGroupUpdateError) Error() string {
|
||||
return "LDAP user groups can't be updated"
|
||||
}
|
||||
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}
|
||||
|
||||
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.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.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.GET("/oidc/clients/:id/logo", oc.getClientLogoHandler)
|
||||
@@ -57,25 +59,20 @@ func (oc *OidcController) authorizeHandler(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, response)
|
||||
}
|
||||
|
||||
func (oc *OidcController) authorizeNewClientHandler(c *gin.Context) {
|
||||
var input dto.AuthorizeOidcClientRequestDto
|
||||
func (oc *OidcController) authorizationConfirmationRequiredHandler(c *gin.Context) {
|
||||
var input dto.AuthorizationRequiredDto
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
c.Error(err)
|
||||
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 {
|
||||
c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
response := dto.AuthorizeOidcClientResponseDto{
|
||||
Code: code,
|
||||
CallbackURL: callbackURL,
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, response)
|
||||
c.JSON(http.StatusOK, gin.H{"authorizationRequired": !hasAuthorizedClient})
|
||||
}
|
||||
|
||||
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
|
||||
if c.GetBool("userIsAdmin") {
|
||||
clientDto := dto.OidcClientDto{}
|
||||
clientDto := dto.OidcClientWithAllowedUserGroupsDto{}
|
||||
err = dto.MapStruct(client, &clientDto)
|
||||
if err == nil {
|
||||
c.JSON(http.StatusOK, clientDto)
|
||||
@@ -191,7 +188,7 @@ func (oc *OidcController) createClientHandler(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
var clientDto dto.OidcClientDto
|
||||
var clientDto dto.OidcClientWithAllowedUserGroupsDto
|
||||
if err := dto.MapStruct(client, &clientDto); err != nil {
|
||||
c.Error(err)
|
||||
return
|
||||
@@ -223,7 +220,7 @@ func (oc *OidcController) updateClientHandler(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
var clientDto dto.OidcClientDto
|
||||
var clientDto dto.OidcClientWithAllowedUserGroupsDto
|
||||
if err := dto.MapStruct(client, &clientDto); err != nil {
|
||||
c.Error(err)
|
||||
return
|
||||
@@ -278,3 +275,25 @@ func (oc *OidcController) deleteClientLogoHandler(c *gin.Context) {
|
||||
|
||||
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"`
|
||||
IsPublic bool `json:"isPublic"`
|
||||
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 {
|
||||
@@ -35,6 +42,11 @@ type AuthorizeOidcClientResponseDto struct {
|
||||
CallbackURL string `json:"callbackURL"`
|
||||
}
|
||||
|
||||
type AuthorizationRequiredDto struct {
|
||||
ClientID string `json:"clientID" binding:"required"`
|
||||
Scope string `json:"scope" binding:"required"`
|
||||
}
|
||||
|
||||
type OidcCreateTokensDto struct {
|
||||
GrantType string `form:"grant_type" binding:"required"`
|
||||
Code string `form:"code" binding:"required"`
|
||||
@@ -42,3 +54,7 @@ type OidcCreateTokensDto struct {
|
||||
ClientSecret string `form:"client_secret"`
|
||||
CodeVerifier string `form:"code_verifier"`
|
||||
}
|
||||
|
||||
type OidcUpdateAllowedUserGroupsDto struct {
|
||||
UserGroupIDs []string `json:"userGroupIds" binding:"required"`
|
||||
}
|
||||
|
||||
@@ -33,7 +33,3 @@ type UserGroupCreateDto struct {
|
||||
type UserGroupUpdateUsersDto struct {
|
||||
UserIDs []string `json:"userIds" binding:"required"`
|
||||
}
|
||||
|
||||
type AssignUserToGroupDto struct {
|
||||
UserID string `json:"userId" binding:"required"`
|
||||
}
|
||||
|
||||
@@ -44,8 +44,9 @@ type OidcClient struct {
|
||||
IsPublic bool
|
||||
PkceEnabled bool
|
||||
|
||||
CreatedByID string
|
||||
CreatedBy User
|
||||
AllowedUserGroups []UserGroup `gorm:"many2many:oidc_clients_allowed_user_groups;"`
|
||||
CreatedByID string
|
||||
CreatedBy User
|
||||
}
|
||||
|
||||
func (c *OidcClient) AfterFind(_ *gorm.DB) (err error) {
|
||||
|
||||
@@ -119,6 +119,7 @@ var defaultDbConfig = model.AppConfig{
|
||||
LdapEnabled: model.AppConfigVariable{
|
||||
Key: "ldapEnabled",
|
||||
Type: "bool",
|
||||
IsPublic: true,
|
||||
DefaultValue: "false",
|
||||
},
|
||||
LdapUrl: model.AppConfigVariable{
|
||||
|
||||
@@ -38,71 +38,111 @@ func NewOidcService(db *gorm.DB, jwtService *JwtService, appConfigService *AppCo
|
||||
}
|
||||
|
||||
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
|
||||
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
|
||||
}
|
||||
|
||||
// If the client is not public, the code challenge must be provided
|
||||
if client.IsPublic && input.CodeChallenge == "" {
|
||||
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)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
userAuthorizedClient := model.UserAuthorizedOidcClient{
|
||||
UserID: userID,
|
||||
ClientID: input.ClientID,
|
||||
Scope: input.Scope,
|
||||
// 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 err := s.db.Create(&userAuthorizedClient).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrDuplicatedKey) {
|
||||
err = s.db.Model(&userAuthorizedClient).Update("scope", input.Scope).Error
|
||||
} else {
|
||||
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{
|
||||
UserID: userID,
|
||||
ClientID: input.ClientID,
|
||||
Scope: input.Scope,
|
||||
}
|
||||
|
||||
if err := s.db.Create(&userAuthorizedClient).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrDuplicatedKey) {
|
||||
// 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 {
|
||||
return "", "", err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create the authorization code
|
||||
code, err := s.createAuthorizationCode(input.ClientID, userID, input.Scope, input.Nonce, input.CodeChallenge, input.CodeChallengeMethod)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
s.auditLogService.Create(model.AuditLogEventNewClientAuthorization, ipAddress, userAgent, userID, model.AuditLogData{"clientName": client.Name})
|
||||
// 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})
|
||||
|
||||
}
|
||||
|
||||
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) {
|
||||
if grantType != "authorization_code" {
|
||||
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) {
|
||||
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 client, nil
|
||||
@@ -382,6 +422,33 @@ func (s *OidcService) GetUserClaimsForClient(userID string, clientID string) (ma
|
||||
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) {
|
||||
randomString, err := utils.GenerateRandomAlphanumericString(32)
|
||||
if err != nil {
|
||||
|
||||
@@ -124,7 +124,10 @@ func (s *TestService) SeedDatabase() error {
|
||||
Name: "Immich",
|
||||
Secret: "$2a$10$Ak.FP8riD1ssy2AGGbG.gOpnp/rBpymd74j0nxNMtW0GG1Lb4gzxe", // PYjrE9u4v9GVqXKi52eur0eb2Ci4kc0x
|
||||
CallbackURLs: model.CallbackURLs{"http://immich/auth/callback"},
|
||||
CreatedByID: users[0].ID,
|
||||
CreatedByID: users[1].ID,
|
||||
AllowedUserGroups: []model.UserGroup{
|
||||
userGroups[1],
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, client := range oidcClients {
|
||||
@@ -163,27 +166,31 @@ func (s *TestService) SeedDatabase() error {
|
||||
return err
|
||||
}
|
||||
|
||||
publicKey1, err := s.getCborPublicKey("MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEwcOo5KV169KR67QEHrcYkeXE3CCxv2BgwnSq4VYTQxyLtdmKxegexa8JdwFKhKXa2BMI9xaN15BoL6wSCRFJhg==")
|
||||
publicKey2, err := s.getCborPublicKey("MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAESq/wR8QbBu3dKnpaw/v0mDxFFDwnJ/L5XHSg2tAmq5x1BpSMmIr3+DxCbybVvGRmWGh8kKhy7SMnK91M6rFHTA==")
|
||||
// To generate a new key pair, run the following command:
|
||||
// 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 {
|
||||
return err
|
||||
}
|
||||
webauthnCredentials := []model.WebauthnCredential{
|
||||
{
|
||||
Name: "Passkey 1",
|
||||
CredentialID: []byte("test-credential-1"),
|
||||
PublicKey: publicKey1,
|
||||
CredentialID: []byte("test-credential-tim"),
|
||||
PublicKey: publicKeyPasskey1,
|
||||
AttestationType: "none",
|
||||
Transport: model.AuthenticatorTransportList{protocol.Internal},
|
||||
UserID: users[0].ID,
|
||||
},
|
||||
{
|
||||
Name: "Passkey 2",
|
||||
CredentialID: []byte("test-credential-2"),
|
||||
PublicKey: publicKey2,
|
||||
CredentialID: []byte("test-credential-craig"),
|
||||
PublicKey: publicKeyPasskey2,
|
||||
AttestationType: "none",
|
||||
Transport: model.AuthenticatorTransportList{protocol.Internal},
|
||||
UserID: users[0].ID,
|
||||
UserID: users[1].ID,
|
||||
},
|
||||
}
|
||||
for _, credential := range webauthnCredentials {
|
||||
|
||||
@@ -10,11 +10,12 @@ import (
|
||||
)
|
||||
|
||||
type UserGroupService struct {
|
||||
db *gorm.DB
|
||||
db *gorm.DB
|
||||
appConfigService *AppConfigService
|
||||
}
|
||||
|
||||
func NewUserGroupService(db *gorm.DB) *UserGroupService {
|
||||
return &UserGroupService{db: db}
|
||||
func NewUserGroupService(db *gorm.DB, appConfigService *AppConfigService) *UserGroupService {
|
||||
return &UserGroupService{db: db, appConfigService: appConfigService}
|
||||
}
|
||||
|
||||
func (s *UserGroupService) List(name string, sortedPaginationRequest utils.SortedPaginationRequest) (groups []model.UserGroup, response utils.PaginationResponse, err error) {
|
||||
@@ -51,7 +52,8 @@ func (s *UserGroupService) Delete(id string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
if group.LdapID != nil {
|
||||
// Disallow deleting the group if it is an LDAP group and LDAP is enabled
|
||||
if group.LdapID != nil && s.appConfigService.DbConfig.LdapEnabled.Value == "true" {
|
||||
return &common.LdapUserGroupUpdateError{}
|
||||
}
|
||||
|
||||
@@ -83,13 +85,13 @@ func (s *UserGroupService) Update(id string, input dto.UserGroupCreateDto, allow
|
||||
return model.UserGroup{}, err
|
||||
}
|
||||
|
||||
if group.LdapID != nil && !allowLdapUpdate {
|
||||
// Disallow updating the group if it is an LDAP group and LDAP is enabled
|
||||
if !allowLdapUpdate && group.LdapID != nil && s.appConfigService.DbConfig.LdapEnabled.Value == "true" {
|
||||
return model.UserGroup{}, &common.LdapUserGroupUpdateError{}
|
||||
}
|
||||
|
||||
group.Name = input.Name
|
||||
group.FriendlyName = input.FriendlyName
|
||||
group.LdapID = &input.LdapID
|
||||
|
||||
if err := s.db.Preload("Users").Save(&group).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrDuplicatedKey) {
|
||||
|
||||
@@ -17,14 +17,15 @@ import (
|
||||
)
|
||||
|
||||
type UserService struct {
|
||||
db *gorm.DB
|
||||
jwtService *JwtService
|
||||
auditLogService *AuditLogService
|
||||
emailService *EmailService
|
||||
db *gorm.DB
|
||||
jwtService *JwtService
|
||||
auditLogService *AuditLogService
|
||||
emailService *EmailService
|
||||
appConfigService *AppConfigService
|
||||
}
|
||||
|
||||
func NewUserService(db *gorm.DB, jwtService *JwtService, auditLogService *AuditLogService, emailService *EmailService) *UserService {
|
||||
return &UserService{db: db, jwtService: jwtService, auditLogService: auditLogService, emailService: emailService}
|
||||
func NewUserService(db *gorm.DB, jwtService *JwtService, auditLogService *AuditLogService, emailService *EmailService, appConfigService *AppConfigService) *UserService {
|
||||
return &UserService{db: db, jwtService: jwtService, auditLogService: auditLogService, emailService: emailService, appConfigService: appConfigService}
|
||||
}
|
||||
|
||||
func (s *UserService) ListUsers(searchTerm string, sortedPaginationRequest utils.SortedPaginationRequest) ([]model.User, utils.PaginationResponse, error) {
|
||||
@@ -52,7 +53,8 @@ func (s *UserService) DeleteUser(userID string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
if user.LdapID != nil {
|
||||
// Disallow deleting the user if it is an LDAP user and LDAP is enabled
|
||||
if user.LdapID != nil && s.appConfigService.DbConfig.LdapEnabled.Value == "true" {
|
||||
return &common.LdapUserUpdateError{}
|
||||
}
|
||||
|
||||
@@ -86,7 +88,8 @@ func (s *UserService) UpdateUser(userID string, updatedUser dto.UserCreateDto, u
|
||||
return model.User{}, err
|
||||
}
|
||||
|
||||
if user.LdapID != nil && !allowLdapUpdate {
|
||||
// Disallow updating the user if it is an LDAP group and LDAP is enabled
|
||||
if !allowLdapUpdate && user.LdapID != nil && s.appConfigService.DbConfig.LdapEnabled.Value == "true" {
|
||||
return model.User{}, &common.LdapUserUpdateError{}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 @@
|
||||
UPDATE user_groups SET ldap_id = '' WHERE ldap_id IS NULL;
|
||||
@@ -0,0 +1 @@
|
||||
UPDATE user_groups SET ldap_id = null WHERE ldap_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
|
||||
);
|
||||
@@ -0,0 +1 @@
|
||||
UPDATE user_groups SET ldap_id = '' WHERE ldap_id IS NULL;
|
||||
@@ -0,0 +1 @@
|
||||
UPDATE user_groups SET ldap_id = null WHERE ldap_id = '';
|
||||
63
docs/docs/client-examples/freshrss.md
Normal file
63
docs/docs/client-examples/freshrss.md
Normal file
@@ -0,0 +1,63 @@
|
||||
---
|
||||
id: freshrss
|
||||
---
|
||||
|
||||
# FreshRSS
|
||||
|
||||
The following example variables are used, and should be replaced with your actual URLs.
|
||||
|
||||
- `freshrss.example.com` (The URL of your Proxmox instance.)
|
||||
- `id.example.com` (The URL of your Pocket ID instance.)
|
||||
|
||||
## Pocket ID Setup
|
||||
|
||||
1. In Pocket ID create a new OIDC Client, name it, for example, `FreshRSS`.
|
||||
2. Set a logo for this OIDC Client if you would like to.
|
||||
3. Set the callback URL to: `https://freshrss.example.com`.
|
||||
4. Copy the `Client ID`, `Client Secret`, and `OIDC Discovery URL` for use in the next steps.
|
||||
|
||||
## FreshRSS Setup
|
||||
|
||||
See [FreshRSS’ OpenID Connect documentation](16_OpenID-Connect.md) for general OIDC settings.
|
||||
|
||||
This is an example docker-compose file for FreshRSS with OIDC enabled.
|
||||
|
||||
```yaml
|
||||
services:
|
||||
freshrss:
|
||||
image: freshrss/freshrss:1.25.0
|
||||
container_name: freshrss
|
||||
ports:
|
||||
- 8080:80
|
||||
volumes:
|
||||
- /freshrss_data:/var/www/FreshRSS/data
|
||||
- /freshrss_extensions:/var/www/FreshRSS/extensions
|
||||
environment:
|
||||
CRON_MIN: 1,31
|
||||
TZ: Etc/UTC
|
||||
OIDC_ENABLED: 1
|
||||
OIDC_CLIENT_ID: <POCKET_ID_CLIENT_ID>
|
||||
OIDC_CLIENT_SECRET: <POCKET_ID_SECRET>
|
||||
OIDC_PROVIDER_METADATA_URL: https://id.example.com/.well-known/openid-configuration
|
||||
OIDC_SCOPES: openid email profile
|
||||
OIDC_X_FORWARDED_HEADERS: X-Forwarded-Proto X-Forwarded-Host
|
||||
OIDC_REMOTE_USER_CLAIM: preferred_username
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- freshrss
|
||||
networks:
|
||||
freshrss:
|
||||
name: freshrss
|
||||
```
|
||||
|
||||
:::important
|
||||
The Username used in Pocket ID must match the Username used in FreshRSS **exactly**. This also applies to case sensitivity. As of version `0.24` of Pocket ID all Usernames are required to be entirely lowercase. FreshRSS allows for uppercase. If a Pocket ID Username is `amanda` and your FreshRSS Username is `Amanda`, you will get a 403 error in FreshRSS and be unable to login. As of version `1.25` of FreshRSS, you are unable to change your username in the GUI. To change your FreshRSS username to lowercase or to match your Pocket ID username, you must nagivate to your FreshRSS volume location. Go to `data/users/` and change the folder for your user to the matching username in Pocket ID, then restart the FreshRSS container to apply the changes.
|
||||
:::
|
||||
|
||||
## Complete OIDC Setup
|
||||
|
||||
If you are setting up a new instance of FreshRSS, simply start the container with the OIDC variables and navigate to your FreshRSS URL.
|
||||
|
||||
If you are adding OIDC to an existing FreshRSS instance, recreate the container with the docker-compose file with the OIDC variables in it and navigate to your FreshRSS URL. Go to `Settings > Authentication` and change the Authentication method to **HTTP** and hit Submit. Logout to test your OIDC connection.
|
||||
|
||||
If you have an error with Pocket ID or are unable to login to your FreshRSS account, you can revert to password login by editing your `config.php` file for FreshRSS. Find the value for `auth_type` and change from `http_auth` to `form`. Restart the FreshRSS container to revert to password login.
|
||||
30
docs/docs/client-examples/gitea.md
Normal file
30
docs/docs/client-examples/gitea.md
Normal file
@@ -0,0 +1,30 @@
|
||||
---
|
||||
id: gitea
|
||||
---
|
||||
|
||||
# Gitea
|
||||
|
||||
## Pocket ID Setup
|
||||
|
||||
1. In Pocket ID, create a new OIDC client named `Gitea` (or any name you prefer).
|
||||
2. (Optional) Set a logo for the OIDC client.
|
||||
3. Set the callback URL to: `https://<Gitea Host>/user/oauth2/PocketID/callback`
|
||||
4. Copy the `Client ID`, `Client Secret`, and `OIDC Discovery URL` for the next steps.
|
||||
|
||||
## Gitea Setup
|
||||
|
||||
1. Log in to Gitea as an admin.
|
||||
2. Go to **Site Administration → Identity & Access → Authentication Sources**.
|
||||
3. Click **Add Authentication Source**.
|
||||
4. Set **Authentication Type** to `OAuth2`.
|
||||
5. Set **Authentication Name** to `PocketID`.
|
||||
:::important
|
||||
If you change this name, update the callback URL in Pocket ID to match.
|
||||
:::
|
||||
6. Set **OAuth2 Provider** to `OpenID Connect`.
|
||||
7. Enter the `Client ID` into the **Client ID (Key)** field.
|
||||
8. Enter the `Client Secret` into the **Client Secret** field.
|
||||
9. Enter the `OIDC Discovery URL` into the **OpenID Connect Auto Discovery URL** field.
|
||||
10. Enable **Skip local 2FA**.
|
||||
11. Set **Additional Scopes** to `openid email profile`.
|
||||
12. Save the settings and test the OAuth login.
|
||||
34
docs/docs/client-examples/headscale.md
Normal file
34
docs/docs/client-examples/headscale.md
Normal file
@@ -0,0 +1,34 @@
|
||||
---
|
||||
id: headscale
|
||||
---
|
||||
# Headscale
|
||||
|
||||
## Create OIDC Client in Pocket ID
|
||||
1. Create a new OIDC Client in Pocket ID (e.g., `Headscale`).
|
||||
2. Set the callback URL: `https://<HEADSCALE-DOMAIN>/oidc/callback`
|
||||
3. Enable `PKCE`.
|
||||
4. Copy the **Client ID** and **Client Secret**.
|
||||
|
||||
## Configure Headscale
|
||||
> Refer to the example [`config.yaml`](https://github.com/juanfont/headscale/blob/main/config-example.yaml) for full OIDC configuration options.
|
||||
|
||||
Add the following to `config.yaml`:
|
||||
|
||||
```yaml
|
||||
oidc:
|
||||
issuer: "https://<POCKET-ID-DOMAIN>"
|
||||
client_id: "<CLIENT-ID>"
|
||||
client_secret: "<CLIENT-SECRET>"
|
||||
pkce:
|
||||
enabled: true
|
||||
method: S256
|
||||
```
|
||||
|
||||
### (Optional) Restrict Access to Certain Groups
|
||||
To allow only specific groups, add:
|
||||
|
||||
```yaml
|
||||
scope: ["openid", "profile", "email", "groups"]
|
||||
allowed_groups:
|
||||
- <POCKET-ID-GROUP-NAME> #example: headscale
|
||||
```
|
||||
26
docs/docs/client-examples/immich.md
Normal file
26
docs/docs/client-examples/immich.md
Normal file
@@ -0,0 +1,26 @@
|
||||
---
|
||||
id: immich
|
||||
---
|
||||
# Immich
|
||||
|
||||
## Create OIDC Client in Pocket ID
|
||||
1. Create a new OIDC Client in Pocket ID (e.g., `immich`).
|
||||
2. Set the callback URLs:
|
||||
```
|
||||
https://<IMMICH-DOMAIN>/auth/login
|
||||
https://<IMMICH-DOMAIN>/user-settings
|
||||
app.immich:///oauth-callback
|
||||
```
|
||||
4. Copy the **Client ID**, **Client Secret**, and **OIDC Discovery URL**.
|
||||
|
||||
## Configure Immich
|
||||
1. Open Immich and navigate to:
|
||||
**`Administration > Settings > Authentication Settings > OAuth`**
|
||||
2. Enable **Login with OAuth**.
|
||||
3. Fill in the required fields:
|
||||
- **Issuer URL**: Paste the `Authorization URL` from Pocket ID.
|
||||
- **Client ID**: Paste the `Client ID` from Pocket ID.
|
||||
- **Client Secret**: Paste the `Client Secret` from Pocket ID.
|
||||
4. *(Optional)* Change `Button Text` to `Login with Pocket ID`.
|
||||
5. Save the settings.
|
||||
6. Test the OAuth login to ensure it works.
|
||||
28
docs/docs/client-examples/memos.md
Normal file
28
docs/docs/client-examples/memos.md
Normal file
@@ -0,0 +1,28 @@
|
||||
---
|
||||
id: memos
|
||||
---
|
||||
|
||||
# Memos
|
||||
|
||||
## Pocket ID Setup
|
||||
|
||||
1. In Pocket ID, create a new OIDC client named `Memos` (or any name you prefer).
|
||||
2. (Optional) Set a logo for the OIDC client.
|
||||
3. Set the callback URL to: `https://< Memos Host >/auth/callback`
|
||||
4. Copy the `Client ID`, `Client Secret`, `Authorization endpoint`, `Token endpoint`, and `User endpoint` for the next steps.
|
||||
|
||||
## Gitea Setup
|
||||
|
||||
1. Log in to Memos as an admin.
|
||||
2. Go to **Settings → SSO → Create**.
|
||||
3. Set **Template** to `Custom`.
|
||||
4. Enter the `Client ID` into the **Client ID** field.
|
||||
5. Enter the `Client Secret` into the **Client secret** field.
|
||||
6. Enter the `Authorization URL` into the **Authorization endpoint** field.
|
||||
7. Enter the `Token URL` into the **Token endpoint** field.
|
||||
8. Enter the `Userinfo URL` into the **User endpoint** field.
|
||||
11. Set **Scopes** to `openid email profile`.
|
||||
12. Set **Identifier** to `preferred_username`
|
||||
13. Set **Display Name** to `profile`.
|
||||
14. Set **Email** to `email`.
|
||||
15. Save the settings and test the OAuth login.
|
||||
@@ -13,7 +13,7 @@ Pocket ID can sync users and groups from an LDAP Source (lldap, OpenLDAP, Active
|
||||
|
||||
### Generic LDAP Setup
|
||||
|
||||
1. Follow the installation guide [here](/pocket-id/setup/installation).
|
||||
1. Follow the installation guide [here](/setup/installation).
|
||||
2. Once you have signed in with the initial admin account, navigate to the Application Configuration section at `https://pocket.id/settings/admin/application-configuration`.
|
||||
3. Client Configuration Setup
|
||||
|
||||
|
||||
@@ -14,11 +14,11 @@ Additionally, what makes Pocket ID special is that it only supports [passkey](ht
|
||||
|
||||
## Get to know Pocket ID
|
||||
|
||||
→ [Try the Demo of Pocket ID](https://pocket-id.eliasschneider.com/)<br/>
|
||||
→ [Try the Demo of Pocket ID](https://demo.pocket-id.org)
|
||||
|
||||
<img src="https://github.com/user-attachments/assets/96ac549d-b897-404a-8811-f42b16ea58e2" width="700"/>
|
||||
|
||||
## Useful Links
|
||||
- [Installation](/pocket-id/setup/installation)
|
||||
- [Proxy Services](/pocket-id/guides/proxy-services)
|
||||
- [Client Examples](/pocket-id/client-examples)
|
||||
- [Installation](/setup/installation)
|
||||
- [Proxy Services](/guides/proxy-services)
|
||||
- [Client Examples](/client-examples)
|
||||
@@ -18,11 +18,23 @@ Pocket ID requires a [secure context](https://developer.mozilla.org/en-US/docs/W
|
||||
curl -o .env https://raw.githubusercontent.com/stonith404/pocket-id/main/.env.example
|
||||
```
|
||||
|
||||
2. Edit the `.env` file so that it fits your needs. See the [environment variables](/pocket-id/configuration/environment-variables) section for more information.
|
||||
2. Edit the `.env` file so that it fits your needs. See the [environment variables](/configuration/environment-variables) section for more information.
|
||||
3. Run `docker compose up -d`
|
||||
|
||||
You can now sign in with the admin account on `http://localhost/login/setup`.
|
||||
|
||||
### Proxmox
|
||||
|
||||
Run the [helper script](https://community-scripts.github.io/ProxmoxVE/scripts?id=pocketid) as root in your Proxmox shell.
|
||||
|
||||
**Configuration Paths**
|
||||
- /opt/pocket-id/backend/.env
|
||||
- /opt/pocket-id/frontend/.env
|
||||
|
||||
```bash
|
||||
bash -c "$(wget -qLO - https://github.com/community-scripts/ProxmoxVE/raw/main/ct/pocketid.sh)"
|
||||
```
|
||||
|
||||
### Unraid
|
||||
|
||||
Pocket ID is available as a template on the Community Apps store.
|
||||
|
||||
@@ -8,8 +8,8 @@ const config: Config = {
|
||||
"Pocket ID is a simple OIDC provider that allows users to authenticate with their passkeys to your services.",
|
||||
favicon: "img/pocket-id.png",
|
||||
|
||||
url: "https://stonith404.github.io",
|
||||
baseUrl: "/pocket-id/",
|
||||
url: "https://docs.pocket-id.org",
|
||||
baseUrl: "/",
|
||||
organizationName: "stonith404",
|
||||
projectName: "pocket-id",
|
||||
|
||||
@@ -47,6 +47,12 @@ const config: Config = {
|
||||
src: "img/pocket-id.png",
|
||||
},
|
||||
items: [
|
||||
// Version gets replaced by the version-label.ts script
|
||||
{
|
||||
to: "#version",
|
||||
label: " ",
|
||||
position: "right",
|
||||
},
|
||||
{
|
||||
href: "https://github.com/stonith404/pocket-id",
|
||||
label: "GitHub",
|
||||
@@ -59,6 +65,7 @@ const config: Config = {
|
||||
darkTheme: prismThemes.dracula,
|
||||
},
|
||||
} satisfies Preset.ThemeConfig,
|
||||
};
|
||||
|
||||
clientModules: [require.resolve("./src/version-label.ts")],
|
||||
};
|
||||
export default config;
|
||||
|
||||
@@ -60,8 +60,11 @@ const sidebars: SidebarsConfig = {
|
||||
},
|
||||
items: [
|
||||
"client-examples/cloudflare-zero-trust",
|
||||
"client-examples/freshrss",
|
||||
"client-examples/grist",
|
||||
"client-examples/headscale",
|
||||
"client-examples/hoarder",
|
||||
"client-examples/immich",
|
||||
"client-examples/jellyfin",
|
||||
"client-examples/netbox",
|
||||
"client-examples/open-webui",
|
||||
@@ -70,6 +73,8 @@ const sidebars: SidebarsConfig = {
|
||||
"client-examples/proxmox",
|
||||
"client-examples/semaphore-ui",
|
||||
"client-examples/vikunja",
|
||||
"client-examples/gitea",
|
||||
"client-examples/memos",
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -96,7 +101,7 @@ const sidebars: SidebarsConfig = {
|
||||
{
|
||||
type: "link",
|
||||
label: "Demo",
|
||||
href: "https://pocket-id.eliasschneider.com/",
|
||||
href: "https://demo.pocket-id.org",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import React from "react";
|
||||
import { Redirect } from "react-router-dom";
|
||||
|
||||
export default function Home() {
|
||||
return <Redirect to="/pocket-id/introduction" />;
|
||||
return <Redirect to="/introduction" />;
|
||||
}
|
||||
|
||||
23
docs/src/version-label.ts
Normal file
23
docs/src/version-label.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import ExecutionEnvironment from "@docusaurus/ExecutionEnvironment";
|
||||
|
||||
if (ExecutionEnvironment.canUseDOM) {
|
||||
function readVersionFile() {
|
||||
return fetch(
|
||||
"https://raw.githubusercontent.com/stonith404/pocket-id/refs/heads/main/.version"
|
||||
)
|
||||
.then((response) => response.text())
|
||||
.catch((error) => `Error reading version file: ${error}`);
|
||||
}
|
||||
|
||||
function getVersion() {
|
||||
readVersionFile()
|
||||
.then((version) => {
|
||||
const versionLabels = document.querySelectorAll('[href="#version"]');
|
||||
versionLabels.forEach((label) => {
|
||||
(label as HTMLElement).innerText = `v${version}`;
|
||||
});
|
||||
})
|
||||
.catch((error) => console.error("Error fetching version:", error));
|
||||
}
|
||||
window.addEventListener("load", getVersion);
|
||||
}
|
||||
1
docs/static/CNAME
vendored
Normal file
1
docs/static/CNAME
vendored
Normal file
@@ -0,0 +1 @@
|
||||
docs.pocket-id.org
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "pocket-id-frontend",
|
||||
"version": "0.27.2",
|
||||
"version": "0.28.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -77,6 +77,10 @@
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
|
||||
button{
|
||||
@apply cursor-pointer;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Playfair Display';
|
||||
font-weight: 400;
|
||||
|
||||
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 };
|
||||
</script>
|
||||
|
||||
<p class={cn('text-sm text-muted-foreground', className)} {...$$restProps}>
|
||||
<p class={cn('text-sm text-muted-foreground mt-1', className)} {...$$restProps}>
|
||||
<slot />
|
||||
</p>
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
|
||||
<DropdownMenuPrimitive.Item
|
||||
class={cn(
|
||||
'relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[disabled]:pointer-events-none data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground data-[disabled]:opacity-50',
|
||||
'relative flex select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[disabled]:pointer-events-none data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground data-[disabled]:opacity-50',
|
||||
inset && 'pl-8',
|
||||
className
|
||||
)}
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
|
||||
<DropdownMenuPrimitive.SubTrigger
|
||||
class={cn(
|
||||
'flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[highlighted]:bg-accent data-[state=open]:bg-accent data-[highlighted]:text-accent-foreground data-[state=open]:text-accent-foreground',
|
||||
'flex select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[highlighted]:bg-accent data-[state=open]:bg-accent data-[highlighted]:text-accent-foreground data-[state=open]:text-accent-foreground',
|
||||
inset && 'pl-8',
|
||||
className
|
||||
)}
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
{disabled}
|
||||
{label}
|
||||
class={cn(
|
||||
'relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none data-[disabled]:pointer-events-none data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground data-[disabled]:opacity-50',
|
||||
'relative flex w-full select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none data-[disabled]:pointer-events-none data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground data-[disabled]:opacity-50',
|
||||
className
|
||||
)}
|
||||
{...$$restProps}
|
||||
|
||||
@@ -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 APIService from './api-service';
|
||||
|
||||
@@ -23,24 +28,13 @@ class OidcService extends APIService {
|
||||
return res.data as AuthorizeResponse;
|
||||
}
|
||||
|
||||
async authorizeNewClient(
|
||||
clientId: string,
|
||||
scope: string,
|
||||
callbackURL: string,
|
||||
nonce?: string,
|
||||
codeChallenge?: string,
|
||||
codeChallengeMethod?: string
|
||||
) {
|
||||
const res = await this.api.post('/oidc/authorize/new-client', {
|
||||
async isAuthorizationRequired(clientId: string, scope: string) {
|
||||
const res = await this.api.post('/oidc/authorization-required', {
|
||||
scope,
|
||||
nonce,
|
||||
callbackURL,
|
||||
clientId,
|
||||
codeChallenge,
|
||||
codeChallengeMethod
|
||||
clientId
|
||||
});
|
||||
|
||||
return res.data as AuthorizeResponse;
|
||||
return res.data.authorizationRequired as boolean;
|
||||
}
|
||||
|
||||
async listClients(options?: SearchPaginationSortRequest) {
|
||||
@@ -59,7 +53,7 @@ class OidcService extends APIService {
|
||||
}
|
||||
|
||||
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) {
|
||||
@@ -88,6 +82,11 @@ class OidcService extends APIService {
|
||||
async createClientSecret(id: 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;
|
||||
|
||||
@@ -2,6 +2,7 @@ export type AppConfig = {
|
||||
appName: string;
|
||||
allowOwnAccountEdit: boolean;
|
||||
emailOneTimeAccessEnabled: boolean;
|
||||
ldapEnabled: boolean;
|
||||
};
|
||||
|
||||
export type AllAppConfig = AppConfig & {
|
||||
@@ -18,7 +19,6 @@ export type AllAppConfig = AppConfig & {
|
||||
smtpSkipCertVerify: boolean;
|
||||
emailLoginNotificationEnabled: boolean;
|
||||
// LDAP
|
||||
ldapEnabled: boolean;
|
||||
ldapUrl: string;
|
||||
ldapBindDn: string;
|
||||
ldapBindPassword: string;
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { UserGroup } from './user-group.type';
|
||||
|
||||
export type OidcClient = {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -8,6 +10,10 @@ export type OidcClient = {
|
||||
pkceEnabled: boolean;
|
||||
};
|
||||
|
||||
export type OidcClientWithAllowedUserGroups = OidcClient & {
|
||||
allowedUserGroups: UserGroup[];
|
||||
};
|
||||
|
||||
export type OidcClientCreate = Omit<OidcClient, 'id' | 'logoURL' | 'hasLogo'>;
|
||||
|
||||
export type OidcClientCreateWithLogo = OidcClientCreate & {
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
let success = false;
|
||||
let errorMessage: string | null = null;
|
||||
let authorizationRequired = false;
|
||||
let authorizationConfirmed = false;
|
||||
|
||||
export let data: PageData;
|
||||
let { scope, nonce, client, state, callbackURL, codeChallenge, codeChallengeMethod } = data;
|
||||
@@ -40,7 +41,17 @@
|
||||
if (!$userStore?.id) {
|
||||
const loginOptions = await webauthnService.getLoginOptions();
|
||||
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
|
||||
@@ -49,7 +60,7 @@
|
||||
onSuccess(code, callbackURL);
|
||||
});
|
||||
} catch (e) {
|
||||
if (e instanceof AxiosError && e.response?.status === 403) {
|
||||
if (e instanceof AxiosError && e.response?.data.error === 'Missing authorization') {
|
||||
authorizationRequired = true;
|
||||
} else {
|
||||
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) {
|
||||
success = true;
|
||||
setTimeout(() => {
|
||||
@@ -100,14 +90,14 @@
|
||||
{:else}
|
||||
<SignInWrapper showEmailOneTimeAccessButton={$appConfigStore.emailOneTimeAccessEnabled}>
|
||||
<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}
|
||||
<p class="mb-10 mt-2 text-muted-foreground">
|
||||
{errorMessage}. Please try again.
|
||||
<p class="text-muted-foreground mb-10 mt-2">
|
||||
{errorMessage}.
|
||||
</p>
|
||||
{/if}
|
||||
{#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
|
||||
<b>{$appConfigStore.appName}</b> account?
|
||||
</p>
|
||||
@@ -115,7 +105,7 @@
|
||||
<div transition:slide={{ duration: 300 }}>
|
||||
<Card.Root class="mb-10 mt-6">
|
||||
<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:
|
||||
</p>
|
||||
</Card.Header>
|
||||
@@ -146,13 +136,7 @@
|
||||
<div class="flex w-full justify-stretch gap-2">
|
||||
<Button onclick={() => history.back()} class="w-full" variant="secondary">Cancel</Button>
|
||||
{#if !errorMessage}
|
||||
<Button
|
||||
class="w-full"
|
||||
{isLoading}
|
||||
on:click={authorizationRequired ? authorizeNewClient : authorize}
|
||||
>
|
||||
Sign in
|
||||
</Button>
|
||||
<Button class="w-full" {isLoading} on:click={authorize}>Sign in</Button>
|
||||
{:else}
|
||||
<Button class="w-full" on:click={() => (errorMessage = null)}>Try again</Button>
|
||||
{/if}
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
|
||||
<div class="flex justify-center gap-3">
|
||||
<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]'
|
||||
: ''}"
|
||||
>
|
||||
@@ -38,10 +38,12 @@
|
||||
</div>
|
||||
|
||||
<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
|
||||
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
|
||||
? '-translate-x-[108px]'
|
||||
: ''} {animationDone ? (success ? 'bg-green-200' : 'bg-red-200') : 'bg-muted'}"
|
||||
|
||||
@@ -64,7 +64,7 @@
|
||||
</Alert.Root>
|
||||
{/if}
|
||||
|
||||
<fieldset disabled={!$appConfigStore.allowOwnAccountEdit || !!account.ldapId}>
|
||||
<fieldset disabled={!$appConfigStore.allowOwnAccountEdit || (!!account.ldapId && $appConfigStore.ldapEnabled)}>
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<Card.Title>Account Details</Card.Title>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<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 appConfigStore from '$lib/stores/application-configuration-store';
|
||||
import type { AllAppConfig } from '$lib/types/application-configuration';
|
||||
@@ -55,45 +55,27 @@
|
||||
<title>Application Configuration</title>
|
||||
</svelte:head>
|
||||
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<Card.Title>General</Card.Title>
|
||||
</Card.Header>
|
||||
<Card.Content>
|
||||
<AppConfigGeneralForm {appConfig} callback={updateAppConfig} />
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
<CollapsibleCard id="application-configuration-general" title="General" defaultExpanded>
|
||||
<AppConfigGeneralForm {appConfig} callback={updateAppConfig} />
|
||||
</CollapsibleCard>
|
||||
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<Card.Title>Email</Card.Title>
|
||||
<Card.Description>
|
||||
Enable email notifications to alert users when a login is detected from a new device or
|
||||
location.
|
||||
</Card.Description>
|
||||
</Card.Header>
|
||||
<Card.Content>
|
||||
<AppConfigEmailForm {appConfig} callback={updateAppConfig} />
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
<CollapsibleCard
|
||||
id="application-configuration-email"
|
||||
title="Email"
|
||||
description="Enable email notifications to alert users when a login is detected from a new device or
|
||||
location."
|
||||
>
|
||||
<AppConfigEmailForm {appConfig} callback={updateAppConfig} />
|
||||
</CollapsibleCard>
|
||||
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<Card.Title>LDAP</Card.Title>
|
||||
<Card.Description>
|
||||
Configure LDAP settings to sync users and groups from an LDAP server.
|
||||
</Card.Description>
|
||||
</Card.Header>
|
||||
<Card.Content>
|
||||
<AppConfigLdapForm {appConfig} callback={updateAppConfig} />
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
<CollapsibleCard
|
||||
id="application-configuration-ldap"
|
||||
title="LDAP"
|
||||
description="Configure LDAP settings to sync users and groups from an LDAP server."
|
||||
>
|
||||
<AppConfigLdapForm {appConfig} callback={updateAppConfig} />
|
||||
</CollapsibleCard>
|
||||
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<Card.Title>Images</Card.Title>
|
||||
</Card.Header>
|
||||
<Card.Content>
|
||||
<UpdateApplicationImages callback={updateImages} />
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
<CollapsibleCard id="application-configuration-images" title="Images">
|
||||
<UpdateApplicationImages callback={updateImages} />
|
||||
</CollapsibleCard>
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
<script lang="ts">
|
||||
import { beforeNavigate } from '$app/navigation';
|
||||
import { page } from '$app/stores';
|
||||
import CollapsibleCard from '$lib/components/collapsible-card.svelte';
|
||||
import { openConfirmDialog } from '$lib/components/confirm-dialog';
|
||||
import CopyToClipboard from '$lib/components/copy-to-clipboard.svelte';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import * as Card from '$lib/components/ui/card';
|
||||
import Label from '$lib/components/ui/label/label.svelte';
|
||||
import OidcService from '$lib/services/oidc-service';
|
||||
import UserGroupService from '$lib/services/user-group-service';
|
||||
import clientSecretStore from '$lib/stores/client-secret-store';
|
||||
import type { OidcClientCreateWithLogo } from '$lib/types/oidc.type';
|
||||
import { axiosErrorToast } from '$lib/utils/error-util';
|
||||
@@ -14,12 +16,17 @@
|
||||
import { toast } from 'svelte-sonner';
|
||||
import { slide } from 'svelte/transition';
|
||||
import OidcForm from '../oidc-client-form.svelte';
|
||||
import UserGroupSelection from '../user-group-selection.svelte';
|
||||
|
||||
let { data } = $props();
|
||||
let client = $state(data);
|
||||
let client = $state({
|
||||
...data,
|
||||
allowedUserGroupIds: data.allowedUserGroups.map((g) => g.id)
|
||||
});
|
||||
let showAllDetails = $state(false);
|
||||
|
||||
const oidcService = new OidcService();
|
||||
const userGroupService = new UserGroupService();
|
||||
|
||||
const setupDetails = $state({
|
||||
'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(() => {
|
||||
clientSecretStore.clear();
|
||||
});
|
||||
@@ -84,7 +102,7 @@
|
||||
</svelte:head>
|
||||
|
||||
<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
|
||||
>
|
||||
</div>
|
||||
@@ -97,7 +115,7 @@
|
||||
<div class="mb-2 flex">
|
||||
<Label class="mb-0 w-44">Client ID</Label>
|
||||
<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>
|
||||
</div>
|
||||
{#if !client.isPublic}
|
||||
@@ -105,12 +123,12 @@
|
||||
<Label class="w-44">Client secret</Label>
|
||||
{#if $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}
|
||||
</span>
|
||||
</CopyToClipboard>
|
||||
{:else}
|
||||
<span class="text-sm text-muted-foreground" data-testid="client-secret"
|
||||
<span class="text-muted-foreground text-sm" data-testid="client-secret"
|
||||
>••••••••••••••••••••••••••••••••</span
|
||||
>
|
||||
<Button
|
||||
@@ -129,7 +147,7 @@
|
||||
<div class="mb-5 flex">
|
||||
<Label class="mb-0 w-44">{key}</Label>
|
||||
<CopyToClipboard {value}>
|
||||
<span class="text-sm text-muted-foreground">{value}</span>
|
||||
<span class="text-muted-foreground text-sm">{value}</span>
|
||||
</CopyToClipboard>
|
||||
</div>
|
||||
{/each}
|
||||
@@ -151,3 +169,15 @@
|
||||
<OidcForm existingClient={client} callback={updateClient} />
|
||||
</Card.Content>
|
||||
</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>
|
||||
|
||||
<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} />
|
||||
<OidcCallbackUrlInput
|
||||
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">
|
||||
import CollapsibleCard from '$lib/components/collapsible-card.svelte';
|
||||
import CustomClaimsInput from '$lib/components/custom-claims-input.svelte';
|
||||
import { Badge } from '$lib/components/ui/badge';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
@@ -12,6 +13,7 @@
|
||||
import { toast } from 'svelte-sonner';
|
||||
import UserGroupForm from '../user-group-form.svelte';
|
||||
import UserSelection from '../user-selection.svelte';
|
||||
import appConfigStore from '$lib/stores/application-configuration-store';
|
||||
|
||||
let { data } = $props();
|
||||
let userGroup = $state({
|
||||
@@ -60,7 +62,7 @@
|
||||
</svelte:head>
|
||||
|
||||
<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
|
||||
>
|
||||
{#if !!userGroup.ldapId}
|
||||
@@ -88,30 +90,24 @@
|
||||
<UserSelection
|
||||
{users}
|
||||
bind:selectedUserIds={userGroup.userIds}
|
||||
selectionDisabled={!!userGroup.ldapId}
|
||||
selectionDisabled={!!userGroup.ldapId && $appConfigStore.ldapEnabled}
|
||||
/>
|
||||
{/await}
|
||||
<div class="mt-5 flex justify-end">
|
||||
<Button disabled={!!userGroup.ldapId} on:click={() => updateUserGroupUsers(userGroup.userIds)}
|
||||
<Button disabled={!!userGroup.ldapId && $appConfigStore.ldapEnabled} on:click={() => updateUserGroupUsers(userGroup.userIds)}
|
||||
>Save</Button
|
||||
>
|
||||
</div>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<Card.Title>Custom Claims</Card.Title>
|
||||
<Card.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.
|
||||
</Card.Description>
|
||||
</Card.Header>
|
||||
<Card.Content>
|
||||
<CustomClaimsInput bind:customClaims={userGroup.customClaims} />
|
||||
<div class="mt-5 flex justify-end">
|
||||
<Button onclick={updateCustomClaims} type="submit">Save</Button>
|
||||
</div>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
<CollapsibleCard
|
||||
id="user-group-custom-claims"
|
||||
title="Custom Claims"
|
||||
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."
|
||||
>
|
||||
<CustomClaimsInput bind:customClaims={userGroup.customClaims} />
|
||||
<div class="mt-5 flex justify-end">
|
||||
<Button onclick={updateCustomClaims} type="submit">Save</Button>
|
||||
</div>
|
||||
</CollapsibleCard>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import FormInput from '$lib/components/form-input.svelte';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import appConfigStore from '$lib/stores/application-configuration-store';
|
||||
import type { UserGroupCreate } from '$lib/types/user-group.type';
|
||||
import { createForm } from '$lib/utils/form-util';
|
||||
import { z } from 'zod';
|
||||
@@ -14,7 +15,7 @@
|
||||
} = $props();
|
||||
|
||||
let isLoading = $state(false);
|
||||
let inputDisabled = $derived(!!existingUserGroup?.ldapId);
|
||||
let inputDisabled = $derived(!!existingUserGroup?.ldapId && $appConfigStore.ldapEnabled);
|
||||
let hasManualNameEdit = $state(!!existingUserGroup?.friendlyName);
|
||||
|
||||
const userGroup = {
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
|
||||
import * as Table from '$lib/components/ui/table';
|
||||
import UserGroupService from '$lib/services/user-group-service';
|
||||
import appConfigStore from '$lib/stores/application-configuration-store';
|
||||
import type { Paginated, SearchPaginationSortRequest } from '$lib/types/pagination.type';
|
||||
import type { UserGroup, UserGroupWithUserCount } from '$lib/types/user-group.type';
|
||||
import { axiosErrorToast } from '$lib/utils/error-util';
|
||||
@@ -31,10 +32,10 @@
|
||||
try {
|
||||
await userGroupService.remove(userGroup.id);
|
||||
userGroups = await userGroupService.list(requestOptions!);
|
||||
toast.success('User group deleted successfully');
|
||||
} catch (e) {
|
||||
axiosErrorToast(e);
|
||||
}
|
||||
toast.success('User group deleted successfully');
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -68,7 +69,7 @@
|
||||
<DropdownMenu.Item href="/settings/admin/user-groups/{item.id}"
|
||||
><LucidePencil class="mr-2 h-4 w-4" /> Edit</DropdownMenu.Item
|
||||
>
|
||||
{#if !item.ldapId}
|
||||
{#if !item.ldapId || !$appConfigStore.ldapEnabled}
|
||||
<DropdownMenu.Item
|
||||
class="text-red-500 focus:!text-red-700"
|
||||
on:click={() => deleteUserGroup(item)}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import CollapsibleCard from '$lib/components/collapsible-card.svelte';
|
||||
import Badge from '$lib/components/ui/badge/badge.svelte';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import * as Card from '$lib/components/ui/card';
|
||||
@@ -45,7 +46,7 @@
|
||||
</svelte:head>
|
||||
|
||||
<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
|
||||
>
|
||||
{#if !!user.ldapId}
|
||||
@@ -61,18 +62,13 @@
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<Card.Title>Custom Claims</Card.Title>
|
||||
<Card.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.
|
||||
</Card.Description>
|
||||
</Card.Header>
|
||||
<Card.Content>
|
||||
<CustomClaimsInput bind:customClaims={user.customClaims} />
|
||||
<div class="mt-5 flex justify-end">
|
||||
<Button onclick={updateCustomClaims} type="submit">Save</Button>
|
||||
</div>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
<CollapsibleCard
|
||||
id="user-custom-claims"
|
||||
title="Custom Claims"
|
||||
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."
|
||||
>
|
||||
<CustomClaimsInput bind:customClaims={user.customClaims} />
|
||||
<div class="mt-5 flex justify-end">
|
||||
<Button onclick={updateCustomClaims} type="submit">Save</Button>
|
||||
</div>
|
||||
</CollapsibleCard>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import CheckboxWithLabel from '$lib/components/checkbox-with-label.svelte';
|
||||
import FormInput from '$lib/components/form-input.svelte';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import appConfigStore from '$lib/stores/application-configuration-store';
|
||||
import type { User, UserCreate } from '$lib/types/user.type';
|
||||
import { createForm } from '$lib/utils/form-util';
|
||||
import { z } from 'zod';
|
||||
@@ -15,7 +16,7 @@
|
||||
} = $props();
|
||||
|
||||
let isLoading = $state(false);
|
||||
let inputDisabled = $derived(!!existingUser?.ldapId);
|
||||
let inputDisabled = $derived(!!existingUser?.ldapId && $appConfigStore.ldapEnabled);
|
||||
|
||||
const user = {
|
||||
firstName: existingUser?.firstName || '',
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
import Ellipsis from 'lucide-svelte/icons/ellipsis';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import OneTimeLinkModal from './one-time-link-modal.svelte';
|
||||
import appConfigStore from '$lib/stores/application-configuration-store';
|
||||
|
||||
let { users = $bindable() }: { users: Paginated<User> } = $props();
|
||||
let requestOptions: SearchPaginationSortRequest | undefined = $state();
|
||||
@@ -95,7 +96,7 @@
|
||||
<DropdownMenu.Item onclick={() => goto(`/settings/admin/users/${item.id}`)}
|
||||
><LucidePencil class="mr-2 h-4 w-4" /> Edit</DropdownMenu.Item
|
||||
>
|
||||
{#if !item.ldapId}
|
||||
{#if !item.ldapId || !$appConfigStore.ldapEnabled}
|
||||
<DropdownMenu.Item
|
||||
class="text-red-500 focus:!text-red-700"
|
||||
onclick={() => deleteUser(item)}
|
||||
|
||||
@@ -40,7 +40,7 @@ test('Update account details fails with already taken username', async ({ page }
|
||||
test('Add passkey to an account', async ({ page }) => {
|
||||
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")');
|
||||
|
||||
|
||||
@@ -24,6 +24,8 @@ test('Update general configuration', async ({ page }) => {
|
||||
test('Update email configuration', async ({ page }) => {
|
||||
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 Port').fill('587');
|
||||
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();
|
||||
});
|
||||
|
||||
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 }) => {
|
||||
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('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('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');
|
||||
|
||||
|
||||
@@ -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 }) {
|
||||
return new URLSearchParams({
|
||||
client_id: oidcClient.id,
|
||||
|
||||
@@ -77,6 +77,8 @@ test('Delete user group', async ({ page }) => {
|
||||
test('Update user group custom claims', async ({ page }) => {
|
||||
await page.goto(`/settings/admin/user-groups/${userGroups.designers.id}`);
|
||||
|
||||
await page.getByRole('button', { name: 'Expand card' }).click();
|
||||
|
||||
// Add two custom claims
|
||||
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 }) => {
|
||||
await page.goto(`/settings/admin/users/${users.craig.id}`);
|
||||
|
||||
await page.getByRole('button', { name: 'Expand card' }).click();
|
||||
|
||||
// Add two custom claims
|
||||
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
|
||||
const passkeys = {
|
||||
existing1: {
|
||||
credentialId: 'test-credential-1',
|
||||
tim: {
|
||||
credentialId: 'test-credential-tim',
|
||||
userHandle: 'f4b89dc2-62fb-46bf-9f5f-c34f4eafe93e',
|
||||
privateKey:
|
||||
'MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQg3rNKkGApsEA1TpGiphKh6axTq3Vh6wBghLLea/YkIp+hRANCAATBw6jkpXXr0pHrtAQetxiR5cTcILG/YGDCdKrhVhNDHIu12YrF6B7Frwl3AUqEpdrYEwj3Fo3XkGgvrBIJEUmG'
|
||||
},
|
||||
existing2: {
|
||||
credentialId: 'test-credential-2',
|
||||
craig: {
|
||||
credentialId: 'test-credential-craig',
|
||||
userHandle: '1cd19686-f9a6-43f4-a41f-14a0bf5b4036',
|
||||
privateKey:
|
||||
'MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQg3rNKkGApsEA1TpGiphKh6axTq3Vh6wBghLLea/YkIp+hRANCAATBw6jkpXXr0pHrtAQetxiR5cTcILG/YGDCdKrhVhNDHIu12YrF6B7Frwl3AUqEpdrYEwj3Fo3XkGgvrBIJEUmG'
|
||||
'MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgL1UaeWG1KYpN+HcxQvXEJysiQjT9Fn7Zif3i5cY+s+yhRANCAASPioDQ+tnODwKjULbufJRvOunwTCOvt46UYjYt+vOZsvmc+FlEB0neERqqscxKckGF8yq1AYrANiloshAUAouH'
|
||||
},
|
||||
new: {
|
||||
credentialId: 'new-test-credential',
|
||||
timNew: {
|
||||
credentialId: 'new-test-credential-tim',
|
||||
userHandle: 'f4b89dc2-62fb-46bf-9f5f-c34f4eafe93e',
|
||||
privateKey:
|
||||
'MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgFl2lIlRyc2G7O9D8WWrw2N8D7NTlhgWcKFY7jYxrfcmhRANCAASmvbCFrXshUvW7avTIysV9UymbhmUwGb7AonUMQPgqK2Jur7PWp9V0AIe5YMuXYH1oxsqY5CoAbdY2YsPmhYoX'
|
||||
}
|
||||
@@ -48,9 +51,9 @@ async function addVirtualAuthenticator(client: CDPSession): Promise<string> {
|
||||
async function addPasskey(
|
||||
authenticatorId: string,
|
||||
client: CDPSession,
|
||||
passkeyName?: keyof typeof passkeys
|
||||
passkeyName: keyof typeof passkeys = 'tim'
|
||||
): Promise<void> {
|
||||
const passkey = passkeys[passkeyName ?? 'existing1'];
|
||||
const passkey = passkeys[passkeyName];
|
||||
await client.send('WebAuthn.addCredential', {
|
||||
authenticatorId,
|
||||
credential: {
|
||||
@@ -58,9 +61,8 @@ async function addPasskey(
|
||||
isResidentCredential: true,
|
||||
rpId: 'localhost',
|
||||
privateKey: passkey.privateKey,
|
||||
userHandle: btoa('f4b89dc2-62fb-46bf-9f5f-c34f4eafe93e'),
|
||||
userHandle: btoa(passkey.userHandle),
|
||||
signCount: Math.round((new Date().getTime() - 1704444610871) / 1000 / 2)
|
||||
// signCount: 2,
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
:{$CADDY_PORT:80} {
|
||||
reverse_proxy /api/* http://localhost:{$BACKEND_PORT:8080} {
|
||||
trusted_proxies 0.0.0.0/0
|
||||
trusted_proxies ::/0
|
||||
}
|
||||
reverse_proxy /.well-known/* http://localhost:{$BACKEND_PORT:8080} {
|
||||
trusted_proxies 0.0.0.0/0
|
||||
trusted_proxies ::/0
|
||||
}
|
||||
reverse_proxy /* http://localhost:{$PORT:3000} {
|
||||
trusted_proxies 0.0.0.0/0
|
||||
trusted_proxies ::/0
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user