Compare commits

...

8 Commits

Author SHA1 Message Date
Elias Schneider
dc9e64de3d release: 0.33.0 2025-02-14 17:10:14 +01:00
Elias Schneider
6207e10279 Merge branch 'main' of https://github.com/pocket-id/pocket-id 2025-02-14 17:09:39 +01:00
Elias Schneider
7550333fe2 feat: add end session endpoint (#232) 2025-02-14 17:09:27 +01:00
Elias Schneider
3de1301fa8 fix: layout of OIDC client details page on mobile 2025-02-14 16:03:17 +01:00
Elias Schneider
c3980d3d28 fix: alignment of OIDC client details 2025-02-14 15:53:30 +01:00
Elias Schneider
4d0fff821e fix: show "Sync Now" and "Test Email" button even if UI config is disabled 2025-02-14 13:32:01 +01:00
Elias Schneider
2e66211b7f release: 0.32.0 2025-02-13 21:02:20 +01:00
Giovanni
2071d002fc feat: add ability to set custom Geolite DB URL 2025-02-13 21:01:43 +01:00
36 changed files with 598 additions and 311 deletions

View File

@@ -1 +1 @@
0.31.0 0.33.0

View File

@@ -1,3 +1,24 @@
## [](https://github.com/pocket-id/pocket-id/compare/v0.32.0...v) (2025-02-14)
### Features
* add end session endpoint ([#232](https://github.com/pocket-id/pocket-id/issues/232)) ([7550333](https://github.com/pocket-id/pocket-id/commit/7550333fe2ff6424f3168f63c5179d76767532fd))
### Bug Fixes
* alignment of OIDC client details ([c3980d3](https://github.com/pocket-id/pocket-id/commit/c3980d3d28a7158a4dc9369af41f185b891e485e))
* layout of OIDC client details page on mobile ([3de1301](https://github.com/pocket-id/pocket-id/commit/3de1301fa84b3ab4fff4242d827c7794d44910f2))
* show "Sync Now" and "Test Email" button even if UI config is disabled ([4d0fff8](https://github.com/pocket-id/pocket-id/commit/4d0fff821e2245050ce631b4465969510466dfae))
## [](https://github.com/pocket-id/pocket-id/compare/v0.31.0...v) (2025-02-13)
### Features
* add ability to set custom Geolite DB URL ([2071d00](https://github.com/pocket-id/pocket-id/commit/2071d002fc5c3b5ff7a3fca6a5c99f5517196853))
## [](https://github.com/pocket-id/pocket-id/compare/v0.30.0...v) (2025-02-12) ## [](https://github.com/pocket-id/pocket-id/compare/v0.30.0...v) (2025-02-12)

View File

@@ -41,7 +41,7 @@ func initRouter(db *gorm.DB, appConfigService *service.AppConfigService) {
userService := service.NewUserService(db, jwtService, auditLogService, emailService, appConfigService) userService := service.NewUserService(db, jwtService, auditLogService, emailService, appConfigService)
customClaimService := service.NewCustomClaimService(db) customClaimService := service.NewCustomClaimService(db)
oidcService := service.NewOidcService(db, jwtService, appConfigService, auditLogService, customClaimService) oidcService := service.NewOidcService(db, jwtService, appConfigService, auditLogService, customClaimService)
testService := service.NewTestService(db, appConfigService) testService := service.NewTestService(db, appConfigService, jwtService)
userGroupService := service.NewUserGroupService(db, appConfigService) userGroupService := service.NewUserGroupService(db, appConfigService)
ldapService := service.NewLdapService(db, appConfigService, userService, userGroupService) ldapService := service.NewLdapService(db, appConfigService, userService, userGroupService)

View File

@@ -10,8 +10,9 @@ import (
type DbProvider string type DbProvider string
const ( const (
DbProviderSqlite DbProvider = "sqlite" DbProviderSqlite DbProvider = "sqlite"
DbProviderPostgres DbProvider = "postgres" DbProviderPostgres DbProvider = "postgres"
MaxMindGeoLiteCityUrl string = "https://download.maxmind.com/app/geoip_download?edition_id=GeoLite2-City&license_key=%s&suffix=tar.gz"
) )
type EnvConfigSchema struct { type EnvConfigSchema struct {
@@ -25,6 +26,7 @@ type EnvConfigSchema struct {
Host string `env:"HOST"` Host string `env:"HOST"`
MaxMindLicenseKey string `env:"MAXMIND_LICENSE_KEY"` MaxMindLicenseKey string `env:"MAXMIND_LICENSE_KEY"`
GeoLiteDBPath string `env:"GEOLITE_DB_PATH"` GeoLiteDBPath string `env:"GEOLITE_DB_PATH"`
GeoLiteDBUrl string `env:"GEOLITE_DB_URL"`
UiConfigDisabled bool `env:"PUBLIC_UI_CONFIG_DISABLED"` UiConfigDisabled bool `env:"PUBLIC_UI_CONFIG_DISABLED"`
} }
@@ -39,6 +41,7 @@ var EnvConfig = &EnvConfigSchema{
Host: "0.0.0.0", Host: "0.0.0.0",
MaxMindLicenseKey: "", MaxMindLicenseKey: "",
GeoLiteDBPath: "data/GeoLite2-City.mmdb", GeoLiteDBPath: "data/GeoLite2-City.mmdb",
GeoLiteDBUrl: MaxMindGeoLiteCityUrl,
UiConfigDisabled: false, UiConfigDisabled: false,
} }

View File

@@ -31,6 +31,13 @@ type TokenInvalidOrExpiredError struct{}
func (e *TokenInvalidOrExpiredError) Error() string { return "token is invalid or expired" } func (e *TokenInvalidOrExpiredError) Error() string { return "token is invalid or expired" }
func (e *TokenInvalidOrExpiredError) HttpStatusCode() int { return 400 } func (e *TokenInvalidOrExpiredError) HttpStatusCode() int { return 400 }
type TokenInvalidError struct{}
func (e *TokenInvalidError) Error() string {
return "Token is invalid"
}
func (e *TokenInvalidError) HttpStatusCode() int { return 400 }
type OidcMissingAuthorizationError struct{} type OidcMissingAuthorizationError struct{}
func (e *OidcMissingAuthorizationError) Error() string { return "missing authorization" } func (e *OidcMissingAuthorizationError) Error() string { return "missing authorization" }
@@ -182,9 +189,22 @@ type OidcAccessDeniedError struct{}
func (e *OidcAccessDeniedError) Error() string { func (e *OidcAccessDeniedError) Error() string {
return "You're not allowed to access this service" return "You're not allowed to access this service"
} }
func (e *OidcAccessDeniedError) HttpStatusCode() int { return http.StatusForbidden } func (e *OidcAccessDeniedError) HttpStatusCode() int { return http.StatusForbidden }
type OidcClientIdNotMatchingError struct{}
func (e *OidcClientIdNotMatchingError) Error() string {
return "Client id in request doesn't match client id in token"
}
func (e *OidcClientIdNotMatchingError) HttpStatusCode() int { return http.StatusBadRequest }
type OidcNoCallbackURLError struct{}
func (e *OidcNoCallbackURLError) Error() string {
return "No callback URL provided"
}
func (e *OidcNoCallbackURLError) HttpStatusCode() int { return http.StatusBadRequest }
type UiConfigDisabledError struct{} type UiConfigDisabledError struct{}
func (e *UiConfigDisabledError) Error() string { func (e *UiConfigDisabledError) Error() string {

View File

@@ -1,7 +1,11 @@
package controller package controller
import ( import (
"github.com/pocket-id/pocket-id/backend/internal/common"
"github.com/pocket-id/pocket-id/backend/internal/utils/cookie"
"log"
"net/http" "net/http"
"net/url"
"strings" "strings"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
@@ -19,6 +23,8 @@ func NewOidcController(group *gin.RouterGroup, jwtAuthMiddleware *middleware.Jwt
group.POST("/oidc/token", oc.createTokensHandler) group.POST("/oidc/token", oc.createTokensHandler)
group.GET("/oidc/userinfo", oc.userInfoHandler) group.GET("/oidc/userinfo", oc.userInfoHandler)
group.POST("/oidc/end-session", oc.EndSessionHandler)
group.GET("/oidc/end-session", oc.EndSessionHandler)
group.GET("/oidc/clients", jwtAuthMiddleware.Add(true), oc.listClientsHandler) group.GET("/oidc/clients", jwtAuthMiddleware.Add(true), oc.listClientsHandler)
group.POST("/oidc/clients", jwtAuthMiddleware.Add(true), oc.createClientHandler) group.POST("/oidc/clients", jwtAuthMiddleware.Add(true), oc.createClientHandler)
@@ -122,6 +128,44 @@ func (oc *OidcController) userInfoHandler(c *gin.Context) {
c.JSON(http.StatusOK, claims) c.JSON(http.StatusOK, claims)
} }
func (oc *OidcController) EndSessionHandler(c *gin.Context) {
var input dto.OidcLogoutDto
// Bind query parameters to the struct
if c.Request.Method == http.MethodGet {
if err := c.ShouldBindQuery(&input); err != nil {
c.Error(err)
return
}
} else if c.Request.Method == http.MethodPost {
// Bind form parameters to the struct
if err := c.ShouldBind(&input); err != nil {
c.Error(err)
return
}
}
callbackURL, err := oc.oidcService.ValidateEndSession(input, c.GetString("userID"))
if err != nil {
// If the validation fails, the user has to confirm the logout manually and doesn't get redirected
log.Printf("Error getting logout callback URL, the user has to confirm the logout manually: %v", err)
c.Redirect(http.StatusFound, common.EnvConfig.AppURL+"/logout")
return
}
// The validation was successful, so we can log out and redirect the user to the callback URL without confirmation
cookie.AddAccessTokenCookie(c, 0, "")
logoutCallbackURL, _ := url.Parse(callbackURL)
if input.State != "" {
q := logoutCallbackURL.Query()
q.Set("state", input.State)
logoutCallbackURL.RawQuery = q.Encode()
}
c.Redirect(http.StatusFound, logoutCallbackURL.String())
}
func (oc *OidcController) getClientHandler(c *gin.Context) { func (oc *OidcController) getClientHandler(c *gin.Context) {
clientId := c.Param("id") clientId := c.Param("id")
client, err := oc.oidcService.GetClient(clientId) client, err := oc.oidcService.GetClient(clientId)

View File

@@ -38,5 +38,7 @@ func (tc *TestController) resetAndSeedHandler(c *gin.Context) {
return return
} }
tc.TestService.SetJWTKeys()
c.Status(http.StatusNoContent) c.Status(http.StatusNoContent)
} }

View File

@@ -35,6 +35,7 @@ func (wkc *WellKnownController) openIDConfigurationHandler(c *gin.Context) {
"authorization_endpoint": appUrl + "/authorize", "authorization_endpoint": appUrl + "/authorize",
"token_endpoint": appUrl + "/api/oidc/token", "token_endpoint": appUrl + "/api/oidc/token",
"userinfo_endpoint": appUrl + "/api/oidc/userinfo", "userinfo_endpoint": appUrl + "/api/oidc/userinfo",
"end_session_endpoint": appUrl + "/api/oidc/end-session",
"jwks_uri": appUrl + "/.well-known/jwks.json", "jwks_uri": appUrl + "/.well-known/jwks.json",
"scopes_supported": []string{"openid", "profile", "email"}, "scopes_supported": []string{"openid", "profile", "email"},
"claims_supported": []string{"sub", "given_name", "family_name", "name", "email", "email_verified", "preferred_username"}, "claims_supported": []string{"sub", "given_name", "family_name", "name", "email", "email_verified", "preferred_username"},

View File

@@ -8,24 +8,27 @@ type PublicOidcClientDto struct {
type OidcClientDto struct { type OidcClientDto struct {
PublicOidcClientDto PublicOidcClientDto
CallbackURLs []string `json:"callbackURLs"` CallbackURLs []string `json:"callbackURLs"`
IsPublic bool `json:"isPublic"` LogoutCallbackURLs []string `json:"logoutCallbackURLs"`
PkceEnabled bool `json:"pkceEnabled"` IsPublic bool `json:"isPublic"`
PkceEnabled bool `json:"pkceEnabled"`
} }
type OidcClientWithAllowedUserGroupsDto struct { type OidcClientWithAllowedUserGroupsDto struct {
PublicOidcClientDto PublicOidcClientDto
CallbackURLs []string `json:"callbackURLs"` CallbackURLs []string `json:"callbackURLs"`
IsPublic bool `json:"isPublic"` LogoutCallbackURLs []string `json:"logoutCallbackURLs"`
PkceEnabled bool `json:"pkceEnabled"` IsPublic bool `json:"isPublic"`
AllowedUserGroups []UserGroupDtoWithUserCount `json:"allowedUserGroups"` PkceEnabled bool `json:"pkceEnabled"`
AllowedUserGroups []UserGroupDtoWithUserCount `json:"allowedUserGroups"`
} }
type OidcClientCreateDto struct { type OidcClientCreateDto struct {
Name string `json:"name" binding:"required,max=50"` Name string `json:"name" binding:"required,max=50"`
CallbackURLs []string `json:"callbackURLs" binding:"required"` CallbackURLs []string `json:"callbackURLs" binding:"required"`
IsPublic bool `json:"isPublic"` LogoutCallbackURLs []string `json:"logoutCallbackURLs"`
PkceEnabled bool `json:"pkceEnabled"` IsPublic bool `json:"isPublic"`
PkceEnabled bool `json:"pkceEnabled"`
} }
type AuthorizeOidcClientRequestDto struct { type AuthorizeOidcClientRequestDto struct {
@@ -58,3 +61,10 @@ type OidcCreateTokensDto struct {
type OidcUpdateAllowedUserGroupsDto struct { type OidcUpdateAllowedUserGroupsDto struct {
UserGroupIDs []string `json:"userGroupIds" binding:"required"` UserGroupIDs []string `json:"userGroupIds" binding:"required"`
} }
type OidcLogoutDto struct {
IdTokenHint string `form:"id_token_hint"`
ClientId string `form:"client_id"`
PostLogoutRedirectUri string `form:"post_logout_redirect_uri"`
State string `form:"state"`
}

View File

@@ -37,13 +37,14 @@ type OidcAuthorizationCode struct {
type OidcClient struct { type OidcClient struct {
Base Base
Name string `sortable:"true"` Name string `sortable:"true"`
Secret string Secret string
CallbackURLs CallbackURLs CallbackURLs UrlList
ImageType *string LogoutCallbackURLs UrlList
HasLogo bool `gorm:"-"` ImageType *string
IsPublic bool HasLogo bool `gorm:"-"`
PkceEnabled bool IsPublic bool
PkceEnabled bool
AllowedUserGroups []UserGroup `gorm:"many2many:oidc_clients_allowed_user_groups;"` AllowedUserGroups []UserGroup `gorm:"many2many:oidc_clients_allowed_user_groups;"`
CreatedByID string CreatedByID string
@@ -56,9 +57,9 @@ func (c *OidcClient) AfterFind(_ *gorm.DB) (err error) {
return nil return nil
} }
type CallbackURLs []string type UrlList []string
func (cu *CallbackURLs) Scan(value interface{}) error { func (cu *UrlList) Scan(value interface{}) error {
if v, ok := value.([]byte); ok { if v, ok := value.([]byte); ok {
return json.Unmarshal(v, cu) return json.Unmarshal(v, cu)
} else { } else {
@@ -66,6 +67,6 @@ func (cu *CallbackURLs) Scan(value interface{}) error {
} }
} }
func (cu CallbackURLs) Value() (driver.Value, error) { func (cu UrlList) Value() (driver.Value, error) {
return json.Marshal(cu) return json.Marshal(cu)
} }

View File

@@ -21,7 +21,8 @@ import (
) )
type GeoLiteService struct { type GeoLiteService struct {
mutex sync.Mutex disableUpdater bool
mutex sync.Mutex
} }
var localhostIPNets = []*net.IPNet{ var localhostIPNets = []*net.IPNet{
@@ -43,6 +44,12 @@ var tailscaleIPNets = []*net.IPNet{
func NewGeoLiteService() *GeoLiteService { func NewGeoLiteService() *GeoLiteService {
service := &GeoLiteService{} service := &GeoLiteService{}
if common.EnvConfig.MaxMindLicenseKey == "" && common.EnvConfig.GeoLiteDBUrl == common.MaxMindGeoLiteCityUrl {
// Warn the user, and disable the updater.
log.Println("MAXMIND_LICENSE_KEY environment variable is empty. The GeoLite2 City database won't be updated.")
service.disableUpdater = true
}
go func() { go func() {
if err := service.updateDatabase(); err != nil { if err := service.updateDatabase(); err != nil {
log.Printf("Failed to update GeoLite2 City database: %v\n", err) log.Printf("Failed to update GeoLite2 City database: %v\n", err)
@@ -104,18 +111,19 @@ func (s *GeoLiteService) GetLocationByIP(ipAddress string) (country, city string
// UpdateDatabase checks the age of the database and updates it if it's older than 14 days. // UpdateDatabase checks the age of the database and updates it if it's older than 14 days.
func (s *GeoLiteService) updateDatabase() error { func (s *GeoLiteService) updateDatabase() error {
if s.disableUpdater {
// Avoid updating the GeoLite2 City database.
return nil
}
if s.isDatabaseUpToDate() { if s.isDatabaseUpToDate() {
log.Println("GeoLite2 City database is up-to-date.") log.Println("GeoLite2 City database is up-to-date.")
return nil return nil
} }
log.Println("Updating GeoLite2 City database...") log.Println("Updating GeoLite2 City database...")
downloadUrl := fmt.Sprintf(common.EnvConfig.GeoLiteDBUrl, common.EnvConfig.MaxMindLicenseKey)
// Download and extract the database
downloadUrl := fmt.Sprintf(
"https://download.maxmind.com/app/geoip_download?edition_id=GeoLite2-City&license_key=%s&suffix=tar.gz",
common.EnvConfig.MaxMindLicenseKey,
)
// Download the database tar.gz file // Download the database tar.gz file
resp, err := http.Get(downloadUrl) resp, err := http.Get(downloadUrl)
if err != nil { if err != nil {

View File

@@ -8,7 +8,6 @@ import (
"encoding/base64" "encoding/base64"
"encoding/pem" "encoding/pem"
"errors" "errors"
"fmt"
"log" "log"
"math/big" "math/big"
"os" "os"
@@ -28,8 +27,8 @@ const (
) )
type JwtService struct { type JwtService struct {
publicKey *rsa.PublicKey PublicKey *rsa.PublicKey
privateKey *rsa.PrivateKey PrivateKey *rsa.PrivateKey
appConfigService *AppConfigService appConfigService *AppConfigService
} }
@@ -72,7 +71,7 @@ func (s *JwtService) loadOrGenerateKeys() error {
if err != nil { if err != nil {
return errors.New("can't read jwt private key: " + err.Error()) return errors.New("can't read jwt private key: " + err.Error())
} }
s.privateKey, err = jwt.ParseRSAPrivateKeyFromPEM(privateKeyBytes) s.PrivateKey, err = jwt.ParseRSAPrivateKeyFromPEM(privateKeyBytes)
if err != nil { if err != nil {
return errors.New("can't parse jwt private key: " + err.Error()) return errors.New("can't parse jwt private key: " + err.Error())
} }
@@ -81,7 +80,7 @@ func (s *JwtService) loadOrGenerateKeys() error {
if err != nil { if err != nil {
return errors.New("can't read jwt public key: " + err.Error()) return errors.New("can't read jwt public key: " + err.Error())
} }
s.publicKey, err = jwt.ParseRSAPublicKeyFromPEM(publicKeyBytes) s.PublicKey, err = jwt.ParseRSAPublicKeyFromPEM(publicKeyBytes)
if err != nil { if err != nil {
return errors.New("can't parse jwt public key: " + err.Error()) return errors.New("can't parse jwt public key: " + err.Error())
} }
@@ -101,7 +100,7 @@ func (s *JwtService) GenerateAccessToken(user model.User) (string, error) {
IsAdmin: user.IsAdmin, IsAdmin: user.IsAdmin,
} }
kid, err := s.generateKeyID(s.publicKey) kid, err := s.generateKeyID(s.PublicKey)
if err != nil { if err != nil {
return "", errors.New("failed to generate key ID: " + err.Error()) return "", errors.New("failed to generate key ID: " + err.Error())
} }
@@ -109,12 +108,12 @@ func (s *JwtService) GenerateAccessToken(user model.User) (string, error) {
token := jwt.NewWithClaims(jwt.SigningMethodRS256, claim) token := jwt.NewWithClaims(jwt.SigningMethodRS256, claim)
token.Header["kid"] = kid token.Header["kid"] = kid
return token.SignedString(s.privateKey) return token.SignedString(s.PrivateKey)
} }
func (s *JwtService) VerifyAccessToken(tokenString string) (*AccessTokenJWTClaims, error) { func (s *JwtService) VerifyAccessToken(tokenString string) (*AccessTokenJWTClaims, error) {
token, err := jwt.ParseWithClaims(tokenString, &AccessTokenJWTClaims{}, func(token *jwt.Token) (interface{}, error) { token, err := jwt.ParseWithClaims(tokenString, &AccessTokenJWTClaims{}, func(token *jwt.Token) (interface{}, error) {
return s.publicKey, nil return s.PublicKey, nil
}) })
if err != nil || !token.Valid { if err != nil || !token.Valid {
return nil, errors.New("couldn't handle this token") return nil, errors.New("couldn't handle this token")
@@ -147,7 +146,7 @@ func (s *JwtService) GenerateIDToken(userClaims map[string]interface{}, clientID
claims["nonce"] = nonce claims["nonce"] = nonce
} }
kid, err := s.generateKeyID(s.publicKey) kid, err := s.generateKeyID(s.PublicKey)
if err != nil { if err != nil {
return "", errors.New("failed to generate key ID: " + err.Error()) return "", errors.New("failed to generate key ID: " + err.Error())
} }
@@ -155,7 +154,7 @@ func (s *JwtService) GenerateIDToken(userClaims map[string]interface{}, clientID
token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims) token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims)
token.Header["kid"] = kid token.Header["kid"] = kid
return token.SignedString(s.privateKey) return token.SignedString(s.PrivateKey)
} }
func (s *JwtService) GenerateOauthAccessToken(user model.User, clientID string) (string, error) { func (s *JwtService) GenerateOauthAccessToken(user model.User, clientID string) (string, error) {
@@ -167,7 +166,7 @@ func (s *JwtService) GenerateOauthAccessToken(user model.User, clientID string)
Issuer: common.EnvConfig.AppURL, Issuer: common.EnvConfig.AppURL,
} }
kid, err := s.generateKeyID(s.publicKey) kid, err := s.generateKeyID(s.PublicKey)
if err != nil { if err != nil {
return "", errors.New("failed to generate key ID: " + err.Error()) return "", errors.New("failed to generate key ID: " + err.Error())
} }
@@ -175,12 +174,12 @@ func (s *JwtService) GenerateOauthAccessToken(user model.User, clientID string)
token := jwt.NewWithClaims(jwt.SigningMethodRS256, claim) token := jwt.NewWithClaims(jwt.SigningMethodRS256, claim)
token.Header["kid"] = kid token.Header["kid"] = kid
return token.SignedString(s.privateKey) return token.SignedString(s.PrivateKey)
} }
func (s *JwtService) VerifyOauthAccessToken(tokenString string) (*jwt.RegisteredClaims, error) { func (s *JwtService) VerifyOauthAccessToken(tokenString string) (*jwt.RegisteredClaims, error) {
token, err := jwt.ParseWithClaims(tokenString, &jwt.RegisteredClaims{}, func(token *jwt.Token) (interface{}, error) { token, err := jwt.ParseWithClaims(tokenString, &jwt.RegisteredClaims{}, func(token *jwt.Token) (interface{}, error) {
return s.publicKey, nil return s.PublicKey, nil
}) })
if err != nil || !token.Valid { if err != nil || !token.Valid {
return nil, errors.New("couldn't handle this token") return nil, errors.New("couldn't handle this token")
@@ -194,13 +193,30 @@ func (s *JwtService) VerifyOauthAccessToken(tokenString string) (*jwt.Registered
return claims, nil return claims, nil
} }
func (s *JwtService) VerifyIdToken(tokenString string) (*jwt.RegisteredClaims, error) {
token, err := jwt.ParseWithClaims(tokenString, &jwt.RegisteredClaims{}, func(token *jwt.Token) (interface{}, error) {
return s.PublicKey, nil
}, jwt.WithIssuer(common.EnvConfig.AppURL))
if err != nil && !errors.Is(err, jwt.ErrTokenExpired) {
return nil, errors.New("couldn't handle this token")
}
claims, isValid := token.Claims.(*jwt.RegisteredClaims)
if !isValid {
return nil, errors.New("can't parse claims")
}
return claims, nil
}
// GetJWK returns the JSON Web Key (JWK) for the public key. // GetJWK returns the JSON Web Key (JWK) for the public key.
func (s *JwtService) GetJWK() (JWK, error) { func (s *JwtService) GetJWK() (JWK, error) {
if s.publicKey == nil { if s.PublicKey == nil {
return JWK{}, errors.New("public key is not initialized") return JWK{}, errors.New("public key is not initialized")
} }
kid, err := s.generateKeyID(s.publicKey) kid, err := s.generateKeyID(s.PublicKey)
if err != nil { if err != nil {
return JWK{}, err return JWK{}, err
} }
@@ -210,8 +226,8 @@ func (s *JwtService) GetJWK() (JWK, error) {
Kty: "RSA", Kty: "RSA",
Use: "sig", Use: "sig",
Alg: "RS256", Alg: "RS256",
N: base64.RawURLEncoding.EncodeToString(s.publicKey.N.Bytes()), N: base64.RawURLEncoding.EncodeToString(s.PublicKey.N.Bytes()),
E: base64.RawURLEncoding.EncodeToString(big.NewInt(int64(s.publicKey.E)).Bytes()), E: base64.RawURLEncoding.EncodeToString(big.NewInt(int64(s.PublicKey.E)).Bytes()),
} }
return jwk, nil return jwk, nil
@@ -246,14 +262,14 @@ func (s *JwtService) generateKeys() error {
if err != nil { if err != nil {
return errors.New("failed to generate private key: " + err.Error()) return errors.New("failed to generate private key: " + err.Error())
} }
s.privateKey = privateKey s.PrivateKey = privateKey
if err := s.savePEMKey(privateKeyPath, x509.MarshalPKCS1PrivateKey(privateKey), "RSA PRIVATE KEY"); err != nil { if err := s.savePEMKey(privateKeyPath, x509.MarshalPKCS1PrivateKey(privateKey), "RSA PRIVATE KEY"); err != nil {
return err return err
} }
publicKey := &privateKey.PublicKey publicKey := &privateKey.PublicKey
s.publicKey = publicKey s.PublicKey = publicKey
if err := s.savePEMKey(publicKeyPath, x509.MarshalPKCS1PublicKey(publicKey), "RSA PUBLIC KEY"); err != nil { if err := s.savePEMKey(publicKeyPath, x509.MarshalPKCS1PublicKey(publicKey), "RSA PUBLIC KEY"); err != nil {
return err return err
@@ -281,32 +297,3 @@ func (s *JwtService) savePEMKey(path string, keyBytes []byte, keyType string) er
return nil return nil
} }
// loadKeys loads RSA keys from the given paths.
func (s *JwtService) loadKeys() error {
if _, err := os.Stat(privateKeyPath); os.IsNotExist(err) {
if err := s.generateKeys(); err != nil {
return err
}
}
privateKeyBytes, err := os.ReadFile(privateKeyPath)
if err != nil {
return fmt.Errorf("can't read jwt private key: %w", err)
}
s.privateKey, err = jwt.ParseRSAPrivateKeyFromPEM(privateKeyBytes)
if err != nil {
return fmt.Errorf("can't parse jwt private key: %w", err)
}
publicKeyBytes, err := os.ReadFile(publicKeyPath)
if err != nil {
return fmt.Errorf("can't read jwt public key: %w", err)
}
s.publicKey, err = jwt.ParseRSAPublicKeyFromPEM(publicKeyBytes)
if err != nil {
return fmt.Errorf("can't parse jwt public key: %w", err)
}
return nil
}

View File

@@ -51,7 +51,7 @@ func (s *OidcService) Authorize(input dto.AuthorizeOidcClientRequestDto, userID,
} }
// Get the callback URL of the client. Return an error if the provided callback URL is not allowed // 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.CallbackURLs, input.CallbackURL)
if err != nil { if err != nil {
return "", "", err return "", "", err
} }
@@ -228,11 +228,12 @@ func (s *OidcService) ListClients(searchTerm string, sortedPaginationRequest uti
func (s *OidcService) CreateClient(input dto.OidcClientCreateDto, userID string) (model.OidcClient, error) { func (s *OidcService) CreateClient(input dto.OidcClientCreateDto, userID string) (model.OidcClient, error) {
client := model.OidcClient{ client := model.OidcClient{
Name: input.Name, Name: input.Name,
CallbackURLs: input.CallbackURLs, CallbackURLs: input.CallbackURLs,
CreatedByID: userID, LogoutCallbackURLs: input.LogoutCallbackURLs,
IsPublic: input.IsPublic, CreatedByID: userID,
PkceEnabled: input.IsPublic || input.PkceEnabled, IsPublic: input.IsPublic,
PkceEnabled: input.IsPublic || input.PkceEnabled,
} }
if err := s.db.Create(&client).Error; err != nil { if err := s.db.Create(&client).Error; err != nil {
@@ -250,6 +251,7 @@ func (s *OidcService) UpdateClient(clientID string, input dto.OidcClientCreateDt
client.Name = input.Name client.Name = input.Name
client.CallbackURLs = input.CallbackURLs client.CallbackURLs = input.CallbackURLs
client.LogoutCallbackURLs = input.LogoutCallbackURLs
client.IsPublic = input.IsPublic client.IsPublic = input.IsPublic
client.PkceEnabled = input.IsPublic || input.PkceEnabled client.PkceEnabled = input.IsPublic || input.PkceEnabled
@@ -460,6 +462,46 @@ func (s *OidcService) UpdateAllowedUserGroups(id string, input dto.OidcUpdateAll
return client, nil return client, nil
} }
// ValidateEndSession returns the logout callback URL for the client if all the validations pass
func (s *OidcService) ValidateEndSession(input dto.OidcLogoutDto, userID string) (string, error) {
// If no ID token hint is provided, return an error
if input.IdTokenHint == "" {
return "", &common.TokenInvalidError{}
}
// If the ID token hint is provided, verify the ID token
claims, err := s.jwtService.VerifyIdToken(input.IdTokenHint)
if err != nil {
return "", &common.TokenInvalidError{}
}
// If the client ID is provided check if the client ID in the ID token matches the client ID in the request
if input.ClientId != "" && claims.Audience[0] != input.ClientId {
return "", &common.OidcClientIdNotMatchingError{}
}
clientId := claims.Audience[0]
// Check if the user has authorized the client before
var userAuthorizedOIDCClient model.UserAuthorizedOidcClient
if err := s.db.Preload("Client").First(&userAuthorizedOIDCClient, "client_id = ? AND user_id = ?", clientId, userID).Error; err != nil {
return "", &common.OidcMissingAuthorizationError{}
}
// If the client has no logout callback URLs, return an error
if len(userAuthorizedOIDCClient.Client.LogoutCallbackURLs) == 0 {
return "", &common.OidcNoCallbackURLError{}
}
callbackURL, err := s.getCallbackURL(userAuthorizedOIDCClient.Client.LogoutCallbackURLs, input.PostLogoutRedirectUri)
if err != nil {
return "", err
}
return callbackURL, 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 {
@@ -506,12 +548,12 @@ func (s *OidcService) validateCodeVerifier(codeVerifier, codeChallenge string, c
return encodedVerifierHash == codeChallenge return encodedVerifierHash == codeChallenge
} }
func (s *OidcService) getCallbackURL(client model.OidcClient, inputCallbackURL string) (callbackURL string, err error) { func (s *OidcService) getCallbackURL(urls []string, inputCallbackURL string) (callbackURL string, err error) {
if inputCallbackURL == "" { if inputCallbackURL == "" {
return client.CallbackURLs[0], nil return urls[0], nil
} }
for _, callbackPattern := range client.CallbackURLs { for _, callbackPattern := range urls {
regexPattern := strings.ReplaceAll(regexp.QuoteMeta(callbackPattern), `\*`, ".*") + "$" regexPattern := strings.ReplaceAll(regexp.QuoteMeta(callbackPattern), `\*`, ".*") + "$"
matched, err := regexp.MatchString(regexPattern, inputCallbackURL) matched, err := regexp.MatchString(regexPattern, inputCallbackURL)
if err != nil { if err != nil {

View File

@@ -4,6 +4,7 @@ import (
"crypto/ecdsa" "crypto/ecdsa"
"crypto/x509" "crypto/x509"
"encoding/base64" "encoding/base64"
"encoding/pem"
"fmt" "fmt"
"log" "log"
"os" "os"
@@ -11,8 +12,8 @@ import (
"time" "time"
"github.com/fxamacker/cbor/v2" "github.com/fxamacker/cbor/v2"
"github.com/pocket-id/pocket-id/backend/resources"
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types" datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
"github.com/pocket-id/pocket-id/backend/resources"
"github.com/go-webauthn/webauthn/protocol" "github.com/go-webauthn/webauthn/protocol"
"github.com/pocket-id/pocket-id/backend/internal/common" "github.com/pocket-id/pocket-id/backend/internal/common"
@@ -23,11 +24,12 @@ import (
type TestService struct { type TestService struct {
db *gorm.DB db *gorm.DB
jwtService *JwtService
appConfigService *AppConfigService appConfigService *AppConfigService
} }
func NewTestService(db *gorm.DB, appConfigService *AppConfigService) *TestService { func NewTestService(db *gorm.DB, appConfigService *AppConfigService, jwtService *JwtService) *TestService {
return &TestService{db: db, appConfigService: appConfigService} return &TestService{db: db, appConfigService: appConfigService, jwtService: jwtService}
} }
func (s *TestService) SeedDatabase() error { func (s *TestService) SeedDatabase() error {
@@ -112,11 +114,12 @@ func (s *TestService) SeedDatabase() error {
Base: model.Base{ Base: model.Base{
ID: "3654a746-35d4-4321-ac61-0bdcff2b4055", ID: "3654a746-35d4-4321-ac61-0bdcff2b4055",
}, },
Name: "Nextcloud", Name: "Nextcloud",
Secret: "$2a$10$9dypwot8nGuCjT6wQWWpJOckZfRprhe2EkwpKizxS/fpVHrOLEJHC", // w2mUeZISmEvIDMEDvpY0PnxQIpj1m3zY Secret: "$2a$10$9dypwot8nGuCjT6wQWWpJOckZfRprhe2EkwpKizxS/fpVHrOLEJHC", // w2mUeZISmEvIDMEDvpY0PnxQIpj1m3zY
CallbackURLs: model.CallbackURLs{"http://nextcloud/auth/callback"}, CallbackURLs: model.UrlList{"http://nextcloud/auth/callback"},
ImageType: utils.StringPointer("png"), LogoutCallbackURLs: model.UrlList{"http://nextcloud/auth/logout/callback"},
CreatedByID: users[0].ID, ImageType: utils.StringPointer("png"),
CreatedByID: users[0].ID,
}, },
{ {
Base: model.Base{ Base: model.Base{
@@ -124,7 +127,7 @@ 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.UrlList{"http://immich/auth/callback"},
CreatedByID: users[1].ID, CreatedByID: users[1].ID,
AllowedUserGroups: []model.UserGroup{ AllowedUserGroups: []model.UserGroup{
userGroups[1], userGroups[1],
@@ -288,6 +291,43 @@ func (s *TestService) ResetAppConfig() error {
return s.appConfigService.LoadDbConfigFromDb() return s.appConfigService.LoadDbConfigFromDb()
} }
func (s *TestService) SetJWTKeys() {
privateKeyString := `-----BEGIN RSA PRIVATE KEY-----
MIIEpQIBAAKCAQEAyaeEL0VKoPBXIAaWXsUgmu05lAvEIIdJn0FX9lHh4JE5UY9B
83C5sCNdhs9iSWzpeP11EVjWp8i3Yv2CF7c7u50BXnVBGtxpZpFC+585UXacoJ0c
hUmarL9GRFJcM1nPHBTFu68aRrn1rIKNHUkNaaxFo0NFGl/4EDDTO8HwawTjwkPo
QlRzeByhlvGPVvwgB3Fn93B8QJ/cZhXKxJvjjrC/8Pk76heC/ntEMru71Ix77BoC
3j2TuyiN7m9RNBW8BU5q6lKoIdvIeZfTFLzi37iufyfvMrJTixp9zhNB1NxlLCeO
Zl2MXegtiGqd2H3cbAyqoOiv9ihUWTfXj7SxJwIDAQABAoIBAQCa8wNZJ08+9y6b
RzSIQcTaBuq1XY0oyYvCuX0ToruDyVNX3lJ48udb9vDIw9XsQans9CTeXXsjldGE
WPN7sapOcUg6ArMyJqc+zuO/YQu0EwYrTE48BOC7WIZvvTFnq9y+4R9HJjd0nTOv
iOlR1W5fAqbH2srgh1mfZ0UIp+9K6ymoinPXVGEXUAuuoMuTEZW/tnA2HT9WEllT
2FyMbmXrFzutAQqk9GRmnQh2OQZLxnQWyShVqJEhYBtm6JUUH1YJbyTVzMLgdBM8
ukgjTVtRDHaW51ubRSVdGBVT2m1RRtTsYAiZCpM5bwt88aSUS9yDOUiVH+irDg/3
IHEuL7IxAoGBAP2MpXPXtOwinajUQ9hKLDAtpq4axGvY+aGP5dNEMsuPo5ggOfUP
b4sqr73kaNFO3EbxQOQVoFjehhi4dQxt1/kAala9HZ5N7s26G2+eUWFF8jy7gWSN
qusNqGrG4g8D3WOyqZFb/x/m6SE0Jcg7zvIYbnAOq1Fexeik0Fc/DNzLAoGBAMua
d4XIfu4ydtU5AIaf1ZNXywgLg+LWxK8ELNqH/Y2vLAeIiTrOVp+hw9z+zHPD5cnu
6mix783PCOYNLTylrwtAz3fxSz14lsDFQM3ntzVF/6BniTTkKddctcPyqnTvamah
0hD2dzXBS/0mTBYIIMYTNbs0Yj87FTdJZw/+qa2VAoGBAKbzQkp54W6PCIMPabD0
fg4nMRZ5F5bv4seIKcunn068QPs9VQxQ4qCfNeLykDYqGA86cgD9YHzD4UZLxv6t
IUWbCWod0m/XXwPlpIUlmO5VEUD+MiAUzFNDxf6xAE7ku5UXImJNUjseX6l2Xd5v
yz9L6QQuFI5aujQKugiIwp5rAoGATtUVGCCkPNgfOLmkYXu7dxxUCV5kB01+xAEK
2OY0n0pG8vfDophH4/D/ZC7nvJ8J9uDhs/3JStexq1lIvaWtG99RNTChIEDzpdn6
GH9yaVcb/eB4uJjrNm64FhF8PGCCwxA+xMCZMaARKwhMB2/IOMkxUbWboL3gnhJ2
rDO/QO0CgYEA2Grt6uXHm61ji3xSdkBWNtUnj19vS1+7rFJp5SoYztVQVThf/W52
BAiXKBdYZDRVoItC/VS2NvAOjeJjhYO/xQ/q3hK7MdtuXfEPpLnyXKkmWo3lrJ26
wbeF6l05LexCkI7ShsOuSt+dsyaTJTszuKDIA6YOfWvfo3aVZmlWRaI=
-----END RSA PRIVATE KEY-----
`
block, _ := pem.Decode([]byte(privateKeyString))
privateKey, _ := x509.ParsePKCS1PrivateKey(block.Bytes)
s.jwtService.PrivateKey = privateKey
s.jwtService.PublicKey = &privateKey.PublicKey
}
// getCborPublicKey decodes a Base64 encoded public key and returns the CBOR encoded COSE key // getCborPublicKey decodes a Base64 encoded public key and returns the CBOR encoded COSE key
func (s *TestService) getCborPublicKey(base64PublicKey string) ([]byte, error) { func (s *TestService) getCborPublicKey(base64PublicKey string) ([]byte, error) {
decodedKey, err := base64.StdEncoding.DecodeString(base64PublicKey) decodedKey, err := base64.StdEncoding.DecodeString(base64PublicKey)

View File

@@ -11,9 +11,9 @@ import (
"github.com/pocket-id/pocket-id/backend/internal/common" "github.com/pocket-id/pocket-id/backend/internal/common"
"github.com/pocket-id/pocket-id/backend/internal/dto" "github.com/pocket-id/pocket-id/backend/internal/dto"
"github.com/pocket-id/pocket-id/backend/internal/model" "github.com/pocket-id/pocket-id/backend/internal/model"
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
"github.com/pocket-id/pocket-id/backend/internal/utils" "github.com/pocket-id/pocket-id/backend/internal/utils"
"github.com/pocket-id/pocket-id/backend/internal/utils/email" "github.com/pocket-id/pocket-id/backend/internal/utils/email"
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
"gorm.io/gorm" "gorm.io/gorm"
) )

View File

@@ -0,0 +1 @@
ALTER TABLE oidc_clients DROP COLUMN logout_callback_urls;

View File

@@ -0,0 +1 @@
ALTER TABLE oidc_clients ADD COLUMN logout_callback_urls JSONB;

View File

@@ -0,0 +1 @@
ALTER TABLE oidc_clients DROP COLUMN logout_callback_urls;

View File

@@ -0,0 +1 @@
ALTER TABLE oidc_clients ADD COLUMN logout_callback_urls BLOB;

View File

@@ -1,6 +1,6 @@
{ {
"name": "pocket-id-frontend", "name": "pocket-id-frontend",
"version": "0.31.0", "version": "0.33.0",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {

View File

@@ -12,7 +12,11 @@ process.env.INTERNAL_BACKEND_URL = env.INTERNAL_BACKEND_URL ?? 'http://localhost
export const handle: Handle = async ({ event, resolve }) => { export const handle: Handle = async ({ event, resolve }) => {
const { isSignedIn, isAdmin } = verifyJwt(event.cookies.get(ACCESS_TOKEN_COOKIE_NAME)); const { isSignedIn, isAdmin } = verifyJwt(event.cookies.get(ACCESS_TOKEN_COOKIE_NAME));
if (event.url.pathname.startsWith('/settings') && !event.url.pathname.startsWith('/login')) { const isUnauthenticatedOnlyPath = event.url.pathname.startsWith('/login');
const isPublicPath = ['/authorize', '/health'].includes(event.url.pathname);
const isAdminPath = event.url.pathname.startsWith('/settings/admin');
if (!isUnauthenticatedOnlyPath && !isPublicPath) {
if (!isSignedIn) { if (!isSignedIn) {
return new Response(null, { return new Response(null, {
status: 302, status: 302,
@@ -21,14 +25,14 @@ export const handle: Handle = async ({ event, resolve }) => {
} }
} }
if (event.url.pathname.startsWith('/login') && isSignedIn) { if (isUnauthenticatedOnlyPath && isSignedIn) {
return new Response(null, { return new Response(null, {
status: 302, status: 302,
headers: { location: '/settings' } headers: { location: '/settings' }
}); });
} }
if (event.url.pathname.startsWith('/settings/admin') && !isAdmin) { if (isAdminPath && !isAdmin) {
return new Response(null, { return new Response(null, {
status: 302, status: 302,
headers: { location: '/settings' } headers: { location: '/settings' }

View File

@@ -27,15 +27,13 @@
} }
</script> </script>
<button onclick={onClick}> <Tooltip.Root closeOnPointerDown={false} {onOpenChange} {open}>
<Tooltip.Root closeOnPointerDown={false} {onOpenChange} {open}> <Tooltip.Trigger class="text-start" onclick={onClick}>{@render children()}</Tooltip.Trigger>
<Tooltip.Trigger>{@render children()}</Tooltip.Trigger> <Tooltip.Content onclick={copyToClipboard}>
<Tooltip.Content onclick={copyToClipboard}> {#if copied}
{#if copied} <span class="flex items-center"><LucideCheck class="mr-1 h-4 w-4" /> Copied</span>
<span class="flex items-center"><LucideCheck class="mr-1 h-4 w-4" /> Copied</span> {:else}
{:else} <span>Click to copy</span>
<span>Click to copy</span> {/if}
{/if} </Tooltip.Content>
</Tooltip.Content> </Tooltip.Root>
</Tooltip.Root>
</button>

View File

@@ -5,10 +5,9 @@
import Logo from '../logo.svelte'; import Logo from '../logo.svelte';
import HeaderAvatar from './header-avatar.svelte'; import HeaderAvatar from './header-avatar.svelte';
let isAuthPage = $derived( const authUrls = ['/authorize', '/login', '/logout'];
!$page.error && let isAuthPage = $derived(!$page.error && authUrls.includes($page.url.pathname));
($page.url.pathname.startsWith('/authorize') || $page.url.pathname.startsWith('/login'))
);
</script> </script>
<div class=" w-full {isAuthPage ? 'absolute top-0 z-10 mt-4' : 'border-b'}"> <div class=" w-full {isAuthPage ? 'absolute top-0 z-10 mt-4' : 'border-b'}">

View File

@@ -5,6 +5,7 @@ export type OidcClient = {
name: string; name: string;
logoURL: string; logoURL: string;
callbackURLs: [string, ...string[]]; callbackURLs: [string, ...string[]];
logoutCallbackURLs: string[];
hasLogo: boolean; hasLogo: boolean;
isPublic: boolean; isPublic: boolean;
pkceEnabled: boolean; pkceEnabled: boolean;

View File

@@ -8,7 +8,6 @@
import userStore from '$lib/stores/user-store'; import userStore from '$lib/stores/user-store';
import { getWebauthnErrorMessage } from '$lib/utils/error-util'; import { getWebauthnErrorMessage } from '$lib/utils/error-util';
import { startAuthentication } from '@simplewebauthn/browser'; import { startAuthentication } from '@simplewebauthn/browser';
import { AxiosError } from 'axios';
import { LucideMail, LucideUser, LucideUsers } from 'lucide-svelte'; import { LucideMail, LucideUser, LucideUsers } from 'lucide-svelte';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { slide } from 'svelte/transition'; import { slide } from 'svelte/transition';
@@ -60,11 +59,7 @@
onSuccess(code, callbackURL); onSuccess(code, callbackURL);
}); });
} catch (e) { } catch (e) {
if (e instanceof AxiosError && e.response?.data.error === 'Missing authorization') { errorMessage = getWebauthnErrorMessage(e);
authorizationRequired = true;
} else {
errorMessage = getWebauthnErrorMessage(e);
}
isLoading = false; isLoading = false;
} }
} }

View File

@@ -0,0 +1,43 @@
<script lang="ts">
import { goto } from '$app/navigation';
import SignInWrapper from '$lib/components/login-wrapper.svelte';
import Logo from '$lib/components/logo.svelte';
import { Button } from '$lib/components/ui/button';
import WebAuthnService from '$lib/services/webauthn-service';
import userStore from '$lib/stores/user-store.js';
import { axiosErrorToast } from '$lib/utils/error-util.js';
let isLoading = $state(false);
const webauthnService = new WebAuthnService();
async function signOut() {
isLoading = true;
await webauthnService
.logout()
.then(() => goto('/'))
.catch(axiosErrorToast);
isLoading = false;
}
</script>
<svelte:head>
<title>Logout</title>
</svelte:head>
<SignInWrapper>
<div class="flex justify-center">
<div class="bg-muted rounded-2xl p-3">
<Logo class="h-10 w-10" />
</div>
</div>
<h1 class="font-playfair mt-5 text-4xl font-bold">Sign out</h1>
<p class="text-muted-foreground mt-2">
Do you want to sign out of Pocket ID with the account <b>{$userStore?.username}</b>?
</p>
<div class="mt-10 flex w-full justify-stretch gap-2">
<Button class="w-full" variant="secondary" onclick={() => history.back()}>Cancel</Button>
<Button class="w-full" {isLoading} onclick={signOut}>Sign out</Button>
</div>
</SignInWrapper>

View File

@@ -1,5 +1,4 @@
<script lang="ts"> <script lang="ts">
import { env } from '$env/dynamic/public';
import CollapsibleCard from '$lib/components/collapsible-card.svelte'; 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';
@@ -14,7 +13,6 @@
let { data } = $props(); let { data } = $props();
let appConfig = $state(data.appConfig); let appConfig = $state(data.appConfig);
const uiConfigDisabled = env.PUBLIC_UI_CONFIG_DISABLED === 'true';
const appConfigService = new AppConfigService(); const appConfigService = new AppConfigService();
async function updateAppConfig(updatedAppConfig: Partial<AllAppConfig>) { async function updateAppConfig(updatedAppConfig: Partial<AllAppConfig>) {
@@ -57,28 +55,26 @@
<title>Application Configuration</title> <title>Application Configuration</title>
</svelte:head> </svelte:head>
<fieldset class="flex flex-col gap-5" disabled={uiConfigDisabled}> <CollapsibleCard id="application-configuration-general" title="General" defaultExpanded>
<CollapsibleCard id="application-configuration-general" title="General" defaultExpanded> <AppConfigGeneralForm {appConfig} callback={updateAppConfig} />
<AppConfigGeneralForm {appConfig} callback={updateAppConfig} /> </CollapsibleCard>
</CollapsibleCard>
<CollapsibleCard <CollapsibleCard
id="application-configuration-email" id="application-configuration-email"
title="Email" title="Email"
description="Enable email notifications to alert users when a login is detected from a new device or description="Enable email notifications to alert users when a login is detected from a new device or
location." location."
> >
<AppConfigEmailForm {appConfig} callback={updateAppConfig} /> <AppConfigEmailForm {appConfig} callback={updateAppConfig} />
</CollapsibleCard> </CollapsibleCard>
<CollapsibleCard <CollapsibleCard
id="application-configuration-ldap" id="application-configuration-ldap"
title="LDAP" title="LDAP"
description="Configure LDAP settings to sync users and groups from an LDAP server." description="Configure LDAP settings to sync users and groups from an LDAP server."
> >
<AppConfigLdapForm {appConfig} callback={updateAppConfig} /> <AppConfigLdapForm {appConfig} callback={updateAppConfig} />
</CollapsibleCard> </CollapsibleCard>
</fieldset>
<CollapsibleCard id="application-configuration-images" title="Images"> <CollapsibleCard id="application-configuration-images" title="Images">
<UpdateApplicationImages callback={updateImages} /> <UpdateApplicationImages callback={updateImages} />

View File

@@ -1,4 +1,5 @@
<script lang="ts"> <script lang="ts">
import { env } from '$env/dynamic/public';
import CheckboxWithLabel from '$lib/components/checkbox-with-label.svelte'; import CheckboxWithLabel from '$lib/components/checkbox-with-label.svelte';
import { openConfirmDialog } from '$lib/components/confirm-dialog'; import { openConfirmDialog } from '$lib/components/confirm-dialog';
import FormInput from '$lib/components/form-input.svelte'; import FormInput from '$lib/components/form-input.svelte';
@@ -18,6 +19,7 @@
} = $props(); } = $props();
const appConfigService = new AppConfigService(); const appConfigService = new AppConfigService();
const uiConfigDisabled = env.PUBLIC_UI_CONFIG_DISABLED === 'true';
let isSendingTestEmail = $state(false); let isSendingTestEmail = $state(false);
@@ -86,46 +88,47 @@
</script> </script>
<form onsubmit={onSubmit}> <form onsubmit={onSubmit}>
<h4 class="text-lg font-semibold">SMTP Configuration</h4> <fieldset disabled={uiConfigDisabled}>
<div class="mt-4 grid grid-cols-1 items-end gap-5 md:grid-cols-2"> <h4 class="text-lg font-semibold">SMTP Configuration</h4>
<FormInput label="SMTP Host" bind:input={$inputs.smtpHost} /> <div class="mt-4 grid grid-cols-1 items-end gap-5 md:grid-cols-2">
<FormInput label="SMTP Port" type="number" bind:input={$inputs.smtpPort} /> <FormInput label="SMTP Host" bind:input={$inputs.smtpHost} />
<FormInput label="SMTP User" bind:input={$inputs.smtpUser} /> <FormInput label="SMTP Port" type="number" bind:input={$inputs.smtpPort} />
<FormInput label="SMTP Password" type="password" bind:input={$inputs.smtpPassword} /> <FormInput label="SMTP User" bind:input={$inputs.smtpUser} />
<FormInput label="SMTP From" bind:input={$inputs.smtpFrom} /> <FormInput label="SMTP Password" type="password" bind:input={$inputs.smtpPassword} />
<CheckboxWithLabel <FormInput label="SMTP From" bind:input={$inputs.smtpFrom} />
id="tls" <CheckboxWithLabel
label="TLS" id="tls"
description="Enable TLS for the SMTP connection." label="TLS"
bind:checked={$inputs.smtpTls.value} description="Enable TLS for the SMTP connection."
/> bind:checked={$inputs.smtpTls.value}
<CheckboxWithLabel />
id="skip-cert-verify" <CheckboxWithLabel
label="Skip Certificate Verification" id="skip-cert-verify"
description="This can be useful for self-signed certificates." label="Skip Certificate Verification"
bind:checked={$inputs.smtpSkipCertVerify.value} description="This can be useful for self-signed certificates."
/> bind:checked={$inputs.smtpSkipCertVerify.value}
</div> />
<h4 class="mt-10 text-lg font-semibold">Enabled Emails</h4> </div>
<div class="mt-4 flex flex-col gap-5"> <h4 class="mt-10 text-lg font-semibold">Enabled Emails</h4>
<CheckboxWithLabel <div class="mt-4 flex flex-col gap-5">
id="email-login-notification" <CheckboxWithLabel
label="Email Login Notification" id="email-login-notification"
description="Send an email to the user when they log in from a new device." label="Email Login Notification"
bind:checked={$inputs.emailLoginNotificationEnabled.value} description="Send an email to the user when they log in from a new device."
/> bind:checked={$inputs.emailLoginNotificationEnabled.value}
<CheckboxWithLabel />
id="email-one-time-access" <CheckboxWithLabel
label="Email One Time Access" id="email-one-time-access"
description="Allows users to sign in with a link sent to their email. This reduces the security significantly as anyone with access to the user's email can gain entry." label="Email One Time Access"
bind:checked={$inputs.emailOneTimeAccessEnabled.value} description="Allows users to sign in with a link sent to their email. This reduces the security significantly as anyone with access to the user's email can gain entry."
/> bind:checked={$inputs.emailOneTimeAccessEnabled.value}
</div> />
</div>
</fieldset>
<div class="mt-8 flex flex-wrap justify-end gap-3"> <div class="mt-8 flex flex-wrap justify-end gap-3">
<Button isLoading={isSendingTestEmail} variant="secondary" onclick={onTestEmail} <Button isLoading={isSendingTestEmail} variant="secondary" onclick={onTestEmail}
>Send test email</Button >Send test email</Button
> >
<Button type="submit">Save</Button> <Button type="submit" disabled={uiConfigDisabled}>Save</Button>
</div> </div>
</form> </form>

View File

@@ -1,4 +1,5 @@
<script lang="ts"> <script lang="ts">
import { env } from '$env/dynamic/public';
import CheckboxWithLabel from '$lib/components/checkbox-with-label.svelte'; import CheckboxWithLabel from '$lib/components/checkbox-with-label.svelte';
import FormInput from '$lib/components/form-input.svelte'; import FormInput from '$lib/components/form-input.svelte';
import { Button } from '$lib/components/ui/button'; import { Button } from '$lib/components/ui/button';
@@ -15,6 +16,7 @@
callback: (appConfig: Partial<AllAppConfig>) => Promise<void>; callback: (appConfig: Partial<AllAppConfig>) => Promise<void>;
} = $props(); } = $props();
const uiConfigDisabled = env.PUBLIC_UI_CONFIG_DISABLED === 'true';
let isLoading = $state(false); let isLoading = $state(false);
const updatedAppConfig = { const updatedAppConfig = {
@@ -42,28 +44,30 @@
</script> </script>
<form onsubmit={onSubmit}> <form onsubmit={onSubmit}>
<div class="flex flex-col gap-5"> <fieldset class="flex flex-col gap-5" disabled={uiConfigDisabled}>
<FormInput label="Application Name" bind:input={$inputs.appName} /> <div class="flex flex-col gap-5">
<FormInput <FormInput label="Application Name" bind:input={$inputs.appName} />
label="Session Duration" <FormInput
type="number" label="Session Duration"
description="The duration of a session in minutes before the user has to sign in again." type="number"
bind:input={$inputs.sessionDuration} description="The duration of a session in minutes before the user has to sign in again."
/> bind:input={$inputs.sessionDuration}
<CheckboxWithLabel />
id="self-account-editing" <CheckboxWithLabel
label="Enable Self-Account Editing" id="self-account-editing"
description="Whether the users should be able to edit their own account details." label="Enable Self-Account Editing"
bind:checked={$inputs.allowOwnAccountEdit.value} description="Whether the users should be able to edit their own account details."
/> bind:checked={$inputs.allowOwnAccountEdit.value}
<CheckboxWithLabel />
id="emails-verified" <CheckboxWithLabel
label="Emails Verified" id="emails-verified"
description="Whether the user's email should be marked as verified for the OIDC clients." label="Emails Verified"
bind:checked={$inputs.emailsVerified.value} description="Whether the user's email should be marked as verified for the OIDC clients."
/> bind:checked={$inputs.emailsVerified.value}
</div> />
<div class="mt-5 flex justify-end"> </div>
<Button {isLoading} type="submit">Save</Button> <div class="mt-5 flex justify-end">
</div> <Button {isLoading} type="submit">Save</Button>
</div>
</fieldset>
</form> </form>

View File

@@ -1,4 +1,5 @@
<script lang="ts"> <script lang="ts">
import { env } from '$env/dynamic/public';
import CheckboxWithLabel from '$lib/components/checkbox-with-label.svelte'; import CheckboxWithLabel from '$lib/components/checkbox-with-label.svelte';
import FormInput from '$lib/components/form-input.svelte'; import FormInput from '$lib/components/form-input.svelte';
import { Button } from '$lib/components/ui/button'; import { Button } from '$lib/components/ui/button';
@@ -18,6 +19,7 @@
} = $props(); } = $props();
const appConfigService = new AppConfigService(); const appConfigService = new AppConfigService();
const uiConfigDisabled = env.PUBLIC_UI_CONFIG_DISABLED === 'true';
let ldapEnabled = $state(appConfig.ldapEnabled); let ldapEnabled = $state(appConfig.ldapEnabled);
let ldapSyncing = $state(false); let ldapSyncing = $state(false);
@@ -96,89 +98,98 @@
</script> </script>
<form onsubmit={onSubmit}> <form onsubmit={onSubmit}>
<h4 class="text-lg font-semibold">Client Configuration</h4> <fieldset disabled={uiConfigDisabled}>
<div class="mt-4 grid grid-cols-1 items-start gap-5 md:grid-cols-2"> <h4 class="text-lg font-semibold">Client Configuration</h4>
<FormInput label="LDAP URL" placeholder="ldap://example.com:389" bind:input={$inputs.ldapUrl} /> <div class="mt-4 grid grid-cols-1 items-start gap-5 md:grid-cols-2">
<FormInput <FormInput
label="LDAP Bind DN" label="LDAP URL"
placeholder="cn=people,dc=example,dc=com" placeholder="ldap://example.com:389"
bind:input={$inputs.ldapBindDn} bind:input={$inputs.ldapUrl}
/> />
<FormInput label="LDAP Bind Password" type="password" bind:input={$inputs.ldapBindPassword} /> <FormInput
<FormInput label="LDAP Base DN" placeholder="dc=example,dc=com" bind:input={$inputs.ldapBase} /> label="LDAP Bind DN"
<FormInput placeholder="cn=people,dc=example,dc=com"
label="User Search Filter" bind:input={$inputs.ldapBindDn}
description="The Search filter to use to search/sync users." />
placeholder="(objectClass=person)" <FormInput label="LDAP Bind Password" type="password" bind:input={$inputs.ldapBindPassword} />
bind:input={$inputs.ldapUserSearchFilter} <FormInput
/> label="LDAP Base DN"
<FormInput placeholder="dc=example,dc=com"
label="Groups Search Filter" bind:input={$inputs.ldapBase}
description="The Search filter to use to search/sync groups." />
placeholder="(objectClass=groupOfNames)" <FormInput
bind:input={$inputs.ldapUserGroupSearchFilter} label="User Search Filter"
/> description="The Search filter to use to search/sync users."
<CheckboxWithLabel placeholder="(objectClass=person)"
id="skip-cert-verify" bind:input={$inputs.ldapUserSearchFilter}
label="Skip Certificate Verification" />
description="This can be useful for self-signed certificates." <FormInput
bind:checked={$inputs.ldapSkipCertVerify.value} label="Groups Search Filter"
/> description="The Search filter to use to search/sync groups."
</div> placeholder="(objectClass=groupOfNames)"
<h4 class="mt-10 text-lg font-semibold">Attribute Mapping</h4> bind:input={$inputs.ldapUserGroupSearchFilter}
<div class="mt-4 grid grid-cols-1 items-end gap-5 md:grid-cols-2"> />
<FormInput <CheckboxWithLabel
label="User Unique Identifier Attribute" id="skip-cert-verify"
description="The value of this attribute should never change." label="Skip Certificate Verification"
placeholder="uuid" description="This can be useful for self-signed certificates."
bind:input={$inputs.ldapAttributeUserUniqueIdentifier} bind:checked={$inputs.ldapSkipCertVerify.value}
/> />
<FormInput </div>
label="Username Attribute" <h4 class="mt-10 text-lg font-semibold">Attribute Mapping</h4>
placeholder="uid" <div class="mt-4 grid grid-cols-1 items-end gap-5 md:grid-cols-2">
bind:input={$inputs.ldapAttributeUserUsername} <FormInput
/> label="User Unique Identifier Attribute"
<FormInput description="The value of this attribute should never change."
label="User Mail Attribute" placeholder="uuid"
placeholder="mail" bind:input={$inputs.ldapAttributeUserUniqueIdentifier}
bind:input={$inputs.ldapAttributeUserEmail} />
/> <FormInput
<FormInput label="Username Attribute"
label="User First Name Attribute" placeholder="uid"
placeholder="givenName" bind:input={$inputs.ldapAttributeUserUsername}
bind:input={$inputs.ldapAttributeUserFirstName} />
/> <FormInput
<FormInput label="User Mail Attribute"
label="User Last Name Attribute" placeholder="mail"
placeholder="sn" bind:input={$inputs.ldapAttributeUserEmail}
bind:input={$inputs.ldapAttributeUserLastName} />
/> <FormInput
<FormInput label="User First Name Attribute"
label="Group Unique Identifier Attribute" placeholder="givenName"
description="The value of this attribute should never change." bind:input={$inputs.ldapAttributeUserFirstName}
placeholder="uuid" />
bind:input={$inputs.ldapAttributeGroupUniqueIdentifier} <FormInput
/> label="User Last Name Attribute"
<FormInput placeholder="sn"
label="Group Name Attribute" bind:input={$inputs.ldapAttributeUserLastName}
placeholder="cn" />
bind:input={$inputs.ldapAttributeGroupName} <FormInput
/> label="Group Unique Identifier Attribute"
<FormInput description="The value of this attribute should never change."
label="Admin Group Name" placeholder="uuid"
description="Members of this group will have Admin Privileges in Pocket ID." bind:input={$inputs.ldapAttributeGroupUniqueIdentifier}
placeholder="_admin_group_name" />
bind:input={$inputs.ldapAttributeAdminGroup} <FormInput
/> label="Group Name Attribute"
</div> placeholder="cn"
bind:input={$inputs.ldapAttributeGroupName}
/>
<FormInput
label="Admin Group Name"
description="Members of this group will have Admin Privileges in Pocket ID."
placeholder="_admin_group_name"
bind:input={$inputs.ldapAttributeAdminGroup}
/>
</div>
</fieldset>
<div class="mt-8 flex flex-wrap justify-end gap-3"> <div class="mt-8 flex flex-wrap justify-end gap-3">
{#if ldapEnabled} {#if ldapEnabled}
<Button variant="secondary" onclick={onDisable}>Disable</Button> <Button variant="secondary" onclick={onDisable} disabled={uiConfigDisabled}>Disable</Button>
<Button variant="secondary" onclick={syncLdap} isLoading={ldapSyncing}>Sync now</Button> <Button variant="secondary" onclick={syncLdap} isLoading={ldapSyncing}>Sync now</Button>
<Button type="submit">Save</Button> <Button type="submit" disabled={uiConfigDisabled}>Save</Button>
{:else} {:else}
<Button onclick={onEnable}>Enable</Button> <Button onclick={onEnable} disabled={uiConfigDisabled}>Enable</Button>
{/if} {/if}
</div> </div>
</form> </form>

View File

@@ -1,7 +1,6 @@
<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';
@@ -17,6 +16,7 @@
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'; import UserGroupSelection from '../user-group-selection.svelte';
import CollapsibleCard from '$lib/components/collapsible-card.svelte';
let { data } = $props(); let { data } = $props();
let client = $state({ let client = $state({
@@ -33,6 +33,7 @@
'OIDC Discovery URL': `https://${$page.url.hostname}/.well-known/openid-configuration`, 'OIDC Discovery URL': `https://${$page.url.hostname}/.well-known/openid-configuration`,
'Token URL': `https://${$page.url.hostname}/api/oidc/token`, 'Token URL': `https://${$page.url.hostname}/api/oidc/token`,
'Userinfo URL': `https://${$page.url.hostname}/api/oidc/userinfo`, 'Userinfo URL': `https://${$page.url.hostname}/api/oidc/userinfo`,
'Logout URL': `https://${$page.url.hostname}/api/oidc/end-session`,
'Certificate URL': `https://${$page.url.hostname}/.well-known/jwks.json`, 'Certificate URL': `https://${$page.url.hostname}/.well-known/jwks.json`,
PKCE: client.pkceEnabled ? 'Enabled' : 'Disabled' PKCE: client.pkceEnabled ? 'Enabled' : 'Disabled'
}); });
@@ -112,15 +113,15 @@
</Card.Header> </Card.Header>
<Card.Content> <Card.Content>
<div class="flex flex-col"> <div class="flex flex-col">
<div class="mb-2 flex"> <div class="mb-2 flex flex-col sm:flex-row sm:items-center">
<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-muted-foreground text-sm" 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}
<div class="mb-2 mt-1 flex items-center"> <div class="mb-2 mt-1 flex flex-col sm:flex-row sm:items-center">
<Label class="w-44">Client secret</Label> <Label class="mb-0 w-44">Client secret</Label>
{#if $clientSecretStore} {#if $clientSecretStore}
<CopyToClipboard value={$clientSecretStore}> <CopyToClipboard value={$clientSecretStore}>
<span class="text-muted-foreground text-sm" data-testid="client-secret"> <span class="text-muted-foreground text-sm" data-testid="client-secret">
@@ -128,23 +129,25 @@
</span> </span>
</CopyToClipboard> </CopyToClipboard>
{:else} {:else}
<span class="text-muted-foreground text-sm" data-testid="client-secret" <div>
>••••••••••••••••••••••••••••••••</span <span class="text-muted-foreground text-sm" data-testid="client-secret"
> >••••••••••••••••••••••••••••••••</span
<Button >
class="ml-2" <Button
onclick={createClientSecret} class="ml-2"
size="sm" onclick={createClientSecret}
variant="ghost" size="sm"
aria-label="Create new client secret"><LucideRefreshCcw class="h-3 w-3" /></Button variant="ghost"
> aria-label="Create new client secret"><LucideRefreshCcw class="h-3 w-3" /></Button
>
</div>
{/if} {/if}
</div> </div>
{/if} {/if}
{#if showAllDetails} {#if showAllDetails}
<div transition:slide> <div transition:slide>
{#each Object.entries(setupDetails) as [key, value]} {#each Object.entries(setupDetails) as [key, value]}
<div class="mb-5 flex"> <div class="mb-5 flex flex-col sm:flex-row sm:items-center">
<Label class="mb-0 w-44">{key}</Label> <Label class="mb-0 w-44">{key}</Label>
<CopyToClipboard {value}> <CopyToClipboard {value}>
<span class="text-muted-foreground text-sm">{value}</span> <span class="text-muted-foreground text-sm">{value}</span>

View File

@@ -7,12 +7,16 @@
import type { HTMLAttributes } from 'svelte/elements'; import type { HTMLAttributes } from 'svelte/elements';
let { let {
label,
callbackURLs = $bindable(), callbackURLs = $bindable(),
error = $bindable(null), error = $bindable(null),
allowEmpty = false,
...restProps ...restProps
}: HTMLAttributes<HTMLDivElement> & { }: HTMLAttributes<HTMLDivElement> & {
label: string;
callbackURLs: string[]; callbackURLs: string[];
error?: string | null; error?: string | null;
allowEmpty?: boolean;
children?: Snippet; children?: Snippet;
} = $props(); } = $props();
@@ -20,12 +24,12 @@
</script> </script>
<div {...restProps}> <div {...restProps}>
<FormInput label="Callback URLs"> <FormInput {label}>
<div class="flex flex-col gap-y-2"> <div class="flex flex-col gap-y-2">
{#each callbackURLs as _, i} {#each callbackURLs as _, i}
<div class="flex gap-x-2"> <div class="flex gap-x-2">
<Input data-testid={`callback-url-${i + 1}`} bind:value={callbackURLs[i]} /> <Input data-testid={`callback-url-${i + 1}`} bind:value={callbackURLs[i]} />
{#if callbackURLs.length > 1} {#if callbackURLs.length > 1 || allowEmpty}
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
@@ -49,7 +53,7 @@
on:click={() => (callbackURLs = [...callbackURLs, ''])} on:click={() => (callbackURLs = [...callbackURLs, ''])}
> >
<LucidePlus class="mr-1 h-4 w-4" /> <LucidePlus class="mr-1 h-4 w-4" />
Add another {callbackURLs.length === 0 ? 'Add' : 'Add another'}
</Button> </Button>
{/if} {/if}
</div> </div>

View File

@@ -10,7 +10,7 @@
OidcClientCreateWithLogo OidcClientCreateWithLogo
} from '$lib/types/oidc.type'; } from '$lib/types/oidc.type';
import { createForm } from '$lib/utils/form-util'; import { createForm } from '$lib/utils/form-util';
import { set, z } from 'zod'; import { z } from 'zod';
import OidcCallbackUrlInput from './oidc-callback-url-input.svelte'; import OidcCallbackUrlInput from './oidc-callback-url-input.svelte';
let { let {
@@ -30,6 +30,7 @@
const client: OidcClientCreate = { const client: OidcClientCreate = {
name: existingClient?.name || '', name: existingClient?.name || '',
callbackURLs: existingClient?.callbackURLs || [''], callbackURLs: existingClient?.callbackURLs || [''],
logoutCallbackURLs: existingClient?.logoutCallbackURLs || [],
isPublic: existingClient?.isPublic || false, isPublic: existingClient?.isPublic || false,
pkceEnabled: existingClient?.isPublic == true || existingClient?.pkceEnabled || false pkceEnabled: existingClient?.isPublic == true || existingClient?.pkceEnabled || false
}; };
@@ -37,6 +38,7 @@
const formSchema = z.object({ const formSchema = z.object({
name: z.string().min(2).max(50), name: z.string().min(2).max(50),
callbackURLs: z.array(z.string()).nonempty(), callbackURLs: z.array(z.string()).nonempty(),
logoutCallbackURLs: z.array(z.string()),
isPublic: z.boolean(), isPublic: z.boolean(),
pkceEnabled: z.boolean() pkceEnabled: z.boolean()
}); });
@@ -76,13 +78,22 @@
</script> </script>
<form onsubmit={onSubmit}> <form onsubmit={onSubmit}>
<div class="grid grid-cols-2 gap-x-3 gap-y-7 sm:flex-row"> <div class="grid grid-cols-1 md: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} />
<div></div>
<OidcCallbackUrlInput <OidcCallbackUrlInput
label="Callback URLs"
class="w-full" class="w-full"
bind:callbackURLs={$inputs.callbackURLs.value} bind:callbackURLs={$inputs.callbackURLs.value}
bind:error={$inputs.callbackURLs.error} bind:error={$inputs.callbackURLs.error}
/> />
<OidcCallbackUrlInput
label="Logout Callback URLs"
class="w-full"
allowEmpty
bind:callbackURLs={$inputs.logoutCallbackURLs.value}
bind:error={$inputs.logoutCallbackURLs.error}
/>
<CheckboxWithLabel <CheckboxWithLabel
id="public-client" id="public-client"
label="Public Client" label="Public Client"
@@ -104,7 +115,7 @@
<Label for="logo">Logo</Label> <Label for="logo">Logo</Label>
<div class="mt-2 flex items-end gap-3"> <div class="mt-2 flex items-end gap-3">
{#if logoDataURL} {#if logoDataURL}
<div class="h-32 w-32 rounded-2xl bg-muted p-3"> <div class="bg-muted h-32 w-32 rounded-2xl p-3">
<img <img
class="m-auto max-h-full max-w-full object-contain" class="m-auto max-h-full max-w-full object-contain"
src={logoDataURL} src={logoDataURL}

View File

@@ -25,7 +25,8 @@ export const oidcClients = {
nextcloud: { nextcloud: {
id: '3654a746-35d4-4321-ac61-0bdcff2b4055', id: '3654a746-35d4-4321-ac61-0bdcff2b4055',
name: 'Nextcloud', name: 'Nextcloud',
callbackUrl: 'http://nextcloud/auth/callback' callbackUrl: 'http://nextcloud/auth/callback',
logoutCallbackUrl: 'http://nextcloud/auth/logout/callback'
}, },
immich: { immich: {
id: '606c7782-f2b1-49e5-8ea9-26eb1b06d018', id: '606c7782-f2b1-49e5-8ea9-26eb1b06d018',

View File

@@ -37,7 +37,7 @@ test('Edit OIDC client', async ({ page }) => {
await page.goto(`/settings/admin/oidc-clients/${oidcClient.id}`); await page.goto(`/settings/admin/oidc-clients/${oidcClient.id}`);
await page.getByLabel('Name').fill('Nextcloud updated'); await page.getByLabel('Name').fill('Nextcloud updated');
await page.getByTestId('callback-url-1').fill('http://nextcloud-updated/auth/callback'); await page.getByTestId('callback-url-1').first().fill('http://nextcloud-updated/auth/callback');
await page.getByLabel('logo').setInputFiles('tests/assets/nextcloud-logo.png'); await page.getByLabel('logo').setInputFiles('tests/assets/nextcloud-logo.png');
await page.getByRole('button', { name: 'Save' }).click(); await page.getByRole('button', { name: 'Save' }).click();

View File

@@ -89,10 +89,11 @@ test('Authorize new client fails with user group not allowed', async ({ page })
await page.getByRole('button', { name: 'Sign in' }).click(); await page.getByRole('button', { name: 'Sign in' }).click();
await expect(page.getByRole('paragraph').first()).toHaveText("You're not allowed to access this service."); 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,
@@ -103,3 +104,33 @@ function createUrlParams(oidcClient: { id: string; callbackUrl: string }) {
nonce: 'P1gN3PtpKHJgKUVcLpLjm' nonce: 'P1gN3PtpKHJgKUVcLpLjm'
}); });
} }
test('End session without id token hint shows confirmation page', async ({ page }) => {
await page.goto('/api/oidc/end-session');
await expect(page).toHaveURL('/logout');
await page.getByRole('button', { name: 'Sign out' }).click();
await expect(page).toHaveURL('/login');
});
test('End session with id token hint redirects to callback URL', async ({ page }) => {
const client = oidcClients.nextcloud;
const idToken =
'eyJhbGciOiJSUzI1NiIsImtpZCI6Ijh1SER3M002cmY4IiwidHlwIjoiSldUIn0.eyJhdWQiOiIzNjU0YTc0Ni0zNWQ0LTQzMjEtYWM2MS0wYmRjZmYyYjQwNTUiLCJlbWFpbCI6InRpbS5jb29rQHRlc3QuY29tIiwiZW1haWxfdmVyaWZpZWQiOnRydWUsImV4cCI6MTY5MDAwMDAwMSwiZmFtaWx5X25hbWUiOiJUaW0iLCJnaXZlbl9uYW1lIjoiQ29vayIsImlhdCI6MTY5MDAwMDAwMCwiaXNzIjoiaHR0cDovL2xvY2FsaG9zdCIsIm5hbWUiOiJUaW0gQ29vayIsIm5vbmNlIjoib1cxQTFPNzhHUTE1RDczT3NIRXg3V1FLajdacXZITFp1XzM3bWRYSXFBUSIsInN1YiI6IjRiODlkYzItNjJmYi00NmJmLTlmNWYtYzM0ZjRlYWZlOTNlIn0.ruYCyjA2BNjROpmLGPNHrhgUNLnpJMEuncvjDYVuv1dAZwvOPfG-Rn-OseAgJDJbV7wJ0qf6ZmBkGWiifwc_B9h--fgd4Vby9fefj0MiHbSDgQyaU5UmpvJU8OlvM-TueD6ICJL0NeT3DwoW5xpIWaHtt3JqJIdP__Q-lTONL2Zokq50kWm0IO-bIw2QrQviSfHNpv8A5rk1RTzpXCPXYNB-eJbm3oBqYQWzerD9HaNrSvrKA7mKG8Te1mI9aMirPpG9FvcAU-I3lY8ky1hJZDu42jHpVEUdWPAmUZPZafoX8iYtlPfkoklDnHj_cdg4aZBGN5bfjM6xf1Oe_rLDWg';
let redirectedCorrectly = false;
await page
.goto(
`/api/oidc/end-session?id_token_hint=${idToken}&post_logout_redirect_uri=${client.logoutCallbackUrl}`
)
.catch((e) => {
if (e.message.includes('net::ERR_NAME_NOT_RESOLVED')) {
redirectedCorrectly = true;
} else {
throw e;
}
});
expect(redirectedCorrectly).toBeTruthy();
});