Compare commits

..

17 Commits

Author SHA1 Message Date
Elias Schneider
9370292fe5 release: 0.15.0 2024-11-21 18:46:15 +01:00
Elias Schneider
46eef1fcb7 chore: make Docker image run without root user (#67) 2024-11-21 18:44:43 +01:00
Elias Schneider
e784093342 fix: mobile layout overflow on application configuration page 2024-11-21 18:41:21 +01:00
Elias Schneider
653d948f73 feat: add option to skip TLS certificate check and ability to send test email 2024-11-21 18:24:01 +01:00
Elias Schneider
a1302ef7bf refactor: move checkboxes with label in seperate component 2024-11-21 14:28:23 +01:00
Elias Schneider
5f44fef85f ci/cd: add Docker image to ghcr.io and add Docker metadata action 2024-11-21 13:11:08 +01:00
Elias Schneider
3613ac261c feat: add PKCE support 2024-11-17 17:13:38 +01:00
Elias Schneider
760c8e83bb docs: add info that PKCE isn't implemented yet 2024-11-15 11:20:28 +01:00
Elias Schneider
3f29325f45 release: 0.14.0 2024-11-11 18:26:15 +01:00
Elias Schneider
aca2240a50 feat: add audit log event for one time access token sign in 2024-11-11 18:25:57 +01:00
Elias Schneider
de45398903 fix: overflow of pagination control on mobile 2024-11-11 18:09:17 +01:00
Elias Schneider
3d3fb4d855 fix: time displayed incorrectly in audit log 2024-11-11 18:02:19 +01:00
Elias Schneider
725388fcc7 chore: fix build warnings 2024-11-02 00:04:27 +01:00
Elias Schneider
ad1d3560f9 release: 0.13.1 2024-11-01 23:52:30 +01:00
Elias Schneider
becfc0004a feat: add list empty indicator 2024-11-01 23:52:01 +01:00
Elias Schneider
376d747616 fix: errors in middleware do not abort the request 2024-11-01 23:41:57 +01:00
Elias Schneider
5b9f4d7326 fix: typo in Self-Account Editing description 2024-11-01 23:33:50 +01:00
54 changed files with 700 additions and 285 deletions

View File

@@ -11,18 +11,36 @@ jobs:
- name: checkout code - name: checkout code
uses: actions/checkout@v3 uses: actions/checkout@v3
- name: Docker metadata
id: meta
uses: docker/metadata-action@v5
with:
images: |
ghcr.io/${{ github.repository }}
${{ github.repository }}
tags: |
type=semver,pattern={{version}},prefix=v
type=semver,pattern={{major}}.{{minor}},prefix=v
- name: Set up QEMU - name: Set up QEMU
uses: docker/setup-qemu-action@v2 uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2 uses: docker/setup-buildx-action@v3
- name: Login to Docker registry - name: Login to Docker Hub
uses: docker/login-action@v2 uses: docker/login-action@v3
with: with:
username: ${{ secrets.DOCKER_REGISTRY_USERNAME }} username: ${{ secrets.DOCKER_REGISTRY_USERNAME }}
password: ${{ secrets.DOCKER_REGISTRY_PASSWORD }} password: ${{ secrets.DOCKER_REGISTRY_PASSWORD }}
- name: 'Login to GitHub Container Registry'
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{github.repository_owner}}
password: ${{secrets.GITHUB_TOKEN}}
- name: Download GeoLite2 City database - name: Download GeoLite2 City database
run: MAXMIND_LICENSE_KEY=${{ secrets.MAXMIND_LICENSE_KEY }} sh scripts/download-ip-database.sh run: MAXMIND_LICENSE_KEY=${{ secrets.MAXMIND_LICENSE_KEY }} sh scripts/download-ip-database.sh
@@ -32,6 +50,7 @@ jobs:
context: . context: .
platforms: linux/amd64,linux/arm64 platforms: linux/amd64,linux/arm64
push: true push: true
tags: stonith404/pocket-id:latest,stonith404/pocket-id:${{ github.ref_name }} tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha cache-from: type=gha
cache-to: type=gha,mode=max cache-to: type=gha,mode=max

View File

@@ -1 +1 @@
0.13.0 0.15.0

View File

@@ -1,3 +1,42 @@
## [](https://github.com/stonith404/pocket-id/compare/v0.14.0...v) (2024-11-21)
### Features
* add option to skip TLS certificate check and ability to send test email ([653d948](https://github.com/stonith404/pocket-id/commit/653d948f73b61e6d1fd3484398fef1a2a37e6d92))
* add PKCE support ([3613ac2](https://github.com/stonith404/pocket-id/commit/3613ac261cf65a2db0620ff16dc6df239f6e5ecd))
### Bug Fixes
* mobile layout overflow on application configuration page ([e784093](https://github.com/stonith404/pocket-id/commit/e784093342f9977ea08cac65ff0c3de4d2644872))
## [](https://github.com/stonith404/pocket-id/compare/v0.13.1...v) (2024-11-11)
### Features
* add audit log event for one time access token sign in ([aca2240](https://github.com/stonith404/pocket-id/commit/aca2240a50a12e849cfb6e1aa56390b000aebae0))
### Bug Fixes
* overflow of pagination control on mobile ([de45398](https://github.com/stonith404/pocket-id/commit/de4539890349153c467013c24c4d6b30feb8fed8))
* time displayed incorrectly in audit log ([3d3fb4d](https://github.com/stonith404/pocket-id/commit/3d3fb4d855ef510f2292e98fcaaaf83debb5d3e0))
## [](https://github.com/stonith404/pocket-id/compare/v0.13.0...v) (2024-11-01)
### Features
* add list empty indicator ([becfc00](https://github.com/stonith404/pocket-id/commit/becfc0004a87c01e18eb92ac85bf4e33f105b6a3))
### Bug Fixes
* errors in middleware do not abort the request ([376d747](https://github.com/stonith404/pocket-id/commit/376d747616b1e835f252d20832c5ae42b8b0b737))
* typo in Self-Account Editing description ([5b9f4d7](https://github.com/stonith404/pocket-id/commit/5b9f4d732615f428c13d3317da96a86c5daebd89))
## [](https://github.com/stonith404/pocket-id/compare/v0.12.0...v) (2024-10-31) ## [](https://github.com/stonith404/pocket-id/compare/v0.12.0...v) (2024-10-31)

View File

@@ -21,7 +21,10 @@ RUN CGO_ENABLED=1 GOOS=linux go build -o /app/backend/pocket-id-backend .
# Stage 3: Production Image # Stage 3: Production Image
FROM node:20-alpine FROM node:20-alpine
RUN apk add --no-cache caddy # Delete default node user
RUN deluser --remove-home node
RUN apk add --no-cache caddy su-exec
COPY ./reverse-proxy /etc/caddy/ COPY ./reverse-proxy /etc/caddy/
WORKDIR /app WORKDIR /app
@@ -41,5 +44,5 @@ RUN chmod +x ./scripts/*.sh
EXPOSE 80 EXPOSE 80
ENV APP_ENV=production ENV APP_ENV=production
# Use a shell form to run both the frontend and backend ENTRYPOINT ["sh", "./scripts/docker/create-user.sh"]
CMD ["sh", "./scripts/docker-entrypoint.sh"] CMD ["sh", "./scripts/docker/entrypoint.sh"]

View File

@@ -11,7 +11,7 @@ Additionally, what makes Pocket ID special is that it only supports [passkey](ht
## Setup ## Setup
> [!WARNING] > [!WARNING]
> Pocket ID is in its early stages and may contain bugs. > Pocket ID is in its early stages and may contain bugs. There might be OIDC features that are not yet implemented. If you encounter any issues, please open an issue.
### Before you start ### Before you start
@@ -150,6 +150,7 @@ docker compose up -d
| ---------------------- | ----------------------- | --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | ---------------------- | ----------------------- | --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `PUBLIC_APP_URL` | `http://localhost` | yes | The URL where you will access the app. | | `PUBLIC_APP_URL` | `http://localhost` | yes | The URL where you will access the app. |
| `TRUST_PROXY` | `false` | yes | Whether the app is behind a reverse proxy. | | `TRUST_PROXY` | `false` | yes | Whether the app is behind a reverse proxy. |
| `PUID` and `PGID` | `1000` | yes | The user and group ID of the user who should run Pocket ID inside the Docker container and owns the files that are mounted with the volume. You can get the `PUID` and `GUID` of your user on your host machine by using the command `id`. For more information see [this article](https://docs.linuxserver.io/general/understanding-puid-and-pgid/#using-the-variables). |
| `DB_PATH` | `data/pocket-id.db` | no | The path to the SQLite database. | | `DB_PATH` | `data/pocket-id.db` | no | The path to the SQLite database. |
| `UPLOAD_PATH` | `data/uploads` | no | The path where the uploaded files are stored. | | `UPLOAD_PATH` | `data/uploads` | no | The path where the uploaded files are stored. |
| `INTERNAL_BACKEND_URL` | `http://localhost:8080` | no | The URL where the backend is accessible. | | `INTERNAL_BACKEND_URL` | `http://localhost:8080` | no | The URL where the backend is accessible. |

View File

@@ -0,0 +1,11 @@
{{ define "base" -}}
<div class="header">
<div class="logo">
<img src="{{ .LogoURL }}" alt="Pocket ID"/>
<h1>{{ .AppName }}</h1>
</div>
</div>
<div class="content">
<p>This is a test email.</p>
</div>
{{ end -}}

View File

@@ -0,0 +1,3 @@
{{ define "base" -}}
This is a test email.
{{ end -}}

View File

@@ -30,7 +30,7 @@ func initRouter(db *gorm.DB, appConfigService *service.AppConfigService) {
// Initialize services // Initialize services
templateDir := os.DirFS(common.EnvConfig.EmailTemplatesPath) templateDir := os.DirFS(common.EnvConfig.EmailTemplatesPath)
emailService, err := service.NewEmailService(appConfigService, templateDir) emailService, err := service.NewEmailService(appConfigService, db, templateDir)
if err != nil { if err != nil {
log.Fatalf("Unable to create email service: %s", err) log.Fatalf("Unable to create email service: %s", err)
} }
@@ -38,7 +38,7 @@ func initRouter(db *gorm.DB, appConfigService *service.AppConfigService) {
auditLogService := service.NewAuditLogService(db, appConfigService, emailService) auditLogService := service.NewAuditLogService(db, appConfigService, emailService)
jwtService := service.NewJwtService(appConfigService) jwtService := service.NewJwtService(appConfigService)
webauthnService := service.NewWebAuthnService(db, jwtService, auditLogService, appConfigService) webauthnService := service.NewWebAuthnService(db, jwtService, auditLogService, appConfigService)
userService := service.NewUserService(db, jwtService) userService := service.NewUserService(db, jwtService, auditLogService)
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)
@@ -58,7 +58,7 @@ func initRouter(db *gorm.DB, appConfigService *service.AppConfigService) {
controller.NewWebauthnController(apiGroup, jwtAuthMiddleware, middleware.NewRateLimitMiddleware(), webauthnService) controller.NewWebauthnController(apiGroup, jwtAuthMiddleware, middleware.NewRateLimitMiddleware(), webauthnService)
controller.NewOidcController(apiGroup, jwtAuthMiddleware, fileSizeLimitMiddleware, oidcService, jwtService) controller.NewOidcController(apiGroup, jwtAuthMiddleware, fileSizeLimitMiddleware, oidcService, jwtService)
controller.NewUserController(apiGroup, jwtAuthMiddleware, middleware.NewRateLimitMiddleware(), userService, appConfigService) controller.NewUserController(apiGroup, jwtAuthMiddleware, middleware.NewRateLimitMiddleware(), userService, appConfigService)
controller.NewAppConfigController(apiGroup, jwtAuthMiddleware, appConfigService) controller.NewAppConfigController(apiGroup, jwtAuthMiddleware, appConfigService, emailService)
controller.NewAuditLogController(apiGroup, auditLogService, jwtAuthMiddleware) controller.NewAuditLogController(apiGroup, auditLogService, jwtAuthMiddleware)
controller.NewUserGroupController(apiGroup, jwtAuthMiddleware, userGroupService) controller.NewUserGroupController(apiGroup, jwtAuthMiddleware, userGroupService)
controller.NewCustomClaimController(apiGroup, jwtAuthMiddleware, customClaimService) controller.NewCustomClaimController(apiGroup, jwtAuthMiddleware, customClaimService)

View File

@@ -102,7 +102,7 @@ func (e *TooManyRequestsError) HttpStatusCode() int { return http.StatusTooManyR
type ClientIdOrSecretNotProvidedError struct{} type ClientIdOrSecretNotProvidedError struct{}
func (e *ClientIdOrSecretNotProvidedError) Error() string { func (e *ClientIdOrSecretNotProvidedError) Error() string {
return "Client id and secret not provided" return "Client id or secret not provided"
} }
func (e *ClientIdOrSecretNotProvidedError) HttpStatusCode() int { return http.StatusBadRequest } func (e *ClientIdOrSecretNotProvidedError) HttpStatusCode() int { return http.StatusBadRequest }
@@ -146,3 +146,17 @@ func (e *AccountEditNotAllowedError) Error() string {
return "You are not allowed to edit your account" return "You are not allowed to edit your account"
} }
func (e *AccountEditNotAllowedError) HttpStatusCode() int { return http.StatusForbidden } func (e *AccountEditNotAllowedError) HttpStatusCode() int { return http.StatusForbidden }
type OidcInvalidCodeVerifierError struct{}
func (e *OidcInvalidCodeVerifierError) Error() string {
return "Invalid code verifier"
}
func (e *OidcInvalidCodeVerifierError) HttpStatusCode() int { return http.StatusBadRequest }
type OidcMissingCodeChallengeError struct{}
func (e *OidcMissingCodeChallengeError) Error() string {
return "Missing code challenge"
}
func (e *OidcMissingCodeChallengeError) HttpStatusCode() int { return http.StatusBadRequest }

View File

@@ -14,10 +14,13 @@ import (
func NewAppConfigController( func NewAppConfigController(
group *gin.RouterGroup, group *gin.RouterGroup,
jwtAuthMiddleware *middleware.JwtAuthMiddleware, jwtAuthMiddleware *middleware.JwtAuthMiddleware,
appConfigService *service.AppConfigService) { appConfigService *service.AppConfigService,
emailService *service.EmailService,
) {
acc := &AppConfigController{ acc := &AppConfigController{
appConfigService: appConfigService, appConfigService: appConfigService,
emailService: emailService,
} }
group.GET("/application-configuration", acc.listAppConfigHandler) group.GET("/application-configuration", acc.listAppConfigHandler)
group.GET("/application-configuration/all", jwtAuthMiddleware.Add(true), acc.listAllAppConfigHandler) group.GET("/application-configuration/all", jwtAuthMiddleware.Add(true), acc.listAllAppConfigHandler)
@@ -29,10 +32,13 @@ func NewAppConfigController(
group.PUT("/application-configuration/logo", jwtAuthMiddleware.Add(true), acc.updateLogoHandler) group.PUT("/application-configuration/logo", jwtAuthMiddleware.Add(true), acc.updateLogoHandler)
group.PUT("/application-configuration/favicon", jwtAuthMiddleware.Add(true), acc.updateFaviconHandler) group.PUT("/application-configuration/favicon", jwtAuthMiddleware.Add(true), acc.updateFaviconHandler)
group.PUT("/application-configuration/background-image", jwtAuthMiddleware.Add(true), acc.updateBackgroundImageHandler) group.PUT("/application-configuration/background-image", jwtAuthMiddleware.Add(true), acc.updateBackgroundImageHandler)
group.POST("/application-configuration/test-email", jwtAuthMiddleware.Add(true), acc.testEmailHandler)
} }
type AppConfigController struct { type AppConfigController struct {
appConfigService *service.AppConfigService appConfigService *service.AppConfigService
emailService *service.EmailService
} }
func (acc *AppConfigController) listAppConfigHandler(c *gin.Context) { func (acc *AppConfigController) listAppConfigHandler(c *gin.Context) {
@@ -175,3 +181,13 @@ func (acc *AppConfigController) updateImage(c *gin.Context, imageName string, ol
c.Status(http.StatusNoContent) c.Status(http.StatusNoContent)
} }
func (acc *AppConfigController) testEmailHandler(c *gin.Context) {
err := acc.emailService.SendTestEmail()
if err != nil {
c.Error(err)
return
}
c.Status(http.StatusNoContent)
}

View File

@@ -2,7 +2,6 @@ package controller
import ( import (
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/stonith404/pocket-id/backend/internal/common"
"github.com/stonith404/pocket-id/backend/internal/dto" "github.com/stonith404/pocket-id/backend/internal/dto"
"github.com/stonith404/pocket-id/backend/internal/middleware" "github.com/stonith404/pocket-id/backend/internal/middleware"
"github.com/stonith404/pocket-id/backend/internal/service" "github.com/stonith404/pocket-id/backend/internal/service"
@@ -80,7 +79,10 @@ func (oc *OidcController) authorizeNewClientHandler(c *gin.Context) {
} }
func (oc *OidcController) createTokensHandler(c *gin.Context) { func (oc *OidcController) createTokensHandler(c *gin.Context) {
var input dto.OidcIdTokenDto // Disable cors for this endpoint
c.Writer.Header().Set("Access-Control-Allow-Origin", "*")
var input dto.OidcCreateTokensDto
if err := c.ShouldBind(&input); err != nil { if err := c.ShouldBind(&input); err != nil {
c.Error(err) c.Error(err)
@@ -91,16 +93,11 @@ func (oc *OidcController) createTokensHandler(c *gin.Context) {
clientSecret := input.ClientSecret clientSecret := input.ClientSecret
// Client id and secret can also be passed over the Authorization header // Client id and secret can also be passed over the Authorization header
if clientID == "" || clientSecret == "" { if clientID == "" && clientSecret == "" {
var ok bool clientID, clientSecret, _ = c.Request.BasicAuth()
clientID, clientSecret, ok = c.Request.BasicAuth()
if !ok {
c.Error(&common.ClientIdOrSecretNotProvidedError{})
return
}
} }
idToken, accessToken, err := oc.oidcService.CreateTokens(input.Code, input.GrantType, clientID, clientSecret) idToken, accessToken, err := oc.oidcService.CreateTokens(input.Code, input.GrantType, clientID, clientSecret, input.CodeVerifier)
if err != nil { if err != nil {
c.Error(err) c.Error(err)
return return

View File

@@ -141,7 +141,7 @@ func (uc *UserController) createOneTimeAccessTokenHandler(c *gin.Context) {
return return
} }
token, err := uc.UserService.CreateOneTimeAccessToken(input.UserID, input.ExpiresAt) token, err := uc.UserService.CreateOneTimeAccessToken(input.UserID, input.ExpiresAt, c.ClientIP(), c.Request.UserAgent())
if err != nil { if err != nil {
c.Error(err) c.Error(err)
return return

View File

@@ -22,4 +22,5 @@ type AppConfigUpdateDto struct {
SmtpFrom string `json:"smtpFrom" binding:"omitempty,email"` SmtpFrom string `json:"smtpFrom" binding:"omitempty,email"`
SmtpUser string `json:"smtpUser"` SmtpUser string `json:"smtpUser"`
SmtpPassword string `json:"smtpPassword"` SmtpPassword string `json:"smtpPassword"`
SmtpSkipCertVerify string `json:"smtpSkipCertVerify"`
} }

View File

@@ -2,12 +2,12 @@ package dto
import ( import (
"github.com/stonith404/pocket-id/backend/internal/model" "github.com/stonith404/pocket-id/backend/internal/model"
"time" datatype "github.com/stonith404/pocket-id/backend/internal/model/types"
) )
type AuditLogDto struct { type AuditLogDto struct {
ID string `json:"id"` ID string `json:"id"`
CreatedAt time.Time `json:"createdAt"` CreatedAt datatype.DateTime `json:"createdAt"`
Event model.AuditLogEvent `json:"event"` Event model.AuditLogEvent `json:"event"`
IpAddress string `json:"ipAddress"` IpAddress string `json:"ipAddress"`

View File

@@ -9,19 +9,23 @@ type PublicOidcClientDto struct {
type OidcClientDto struct { type OidcClientDto struct {
PublicOidcClientDto PublicOidcClientDto
CallbackURLs []string `json:"callbackURLs"` CallbackURLs []string `json:"callbackURLs"`
IsPublic bool `json:"isPublic"`
CreatedBy UserDto `json:"createdBy"` CreatedBy UserDto `json:"createdBy"`
} }
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,urlList"` CallbackURLs []string `json:"callbackURLs" binding:"required,urlList"`
IsPublic bool `json:"isPublic"`
} }
type AuthorizeOidcClientRequestDto struct { type AuthorizeOidcClientRequestDto struct {
ClientID string `json:"clientID" binding:"required"` ClientID string `json:"clientID" binding:"required"`
Scope string `json:"scope" binding:"required"` Scope string `json:"scope" binding:"required"`
CallbackURL string `json:"callbackURL"` CallbackURL string `json:"callbackURL"`
Nonce string `json:"nonce"` Nonce string `json:"nonce"`
CodeChallenge string `json:"codeChallenge"`
CodeChallengeMethod string `json:"codeChallengeMethod"`
} }
type AuthorizeOidcClientResponseDto struct { type AuthorizeOidcClientResponseDto struct {
@@ -29,9 +33,10 @@ type AuthorizeOidcClientResponseDto struct {
CallbackURL string `json:"callbackURL"` CallbackURL string `json:"callbackURL"`
} }
type OidcIdTokenDto struct { type OidcCreateTokensDto struct {
GrantType string `form:"grant_type" binding:"required"` GrantType string `form:"grant_type" binding:"required"`
Code string `form:"code" binding:"required"` Code string `form:"code" binding:"required"`
ClientID string `form:"client_id"` ClientID string `form:"client_id"`
ClientSecret string `form:"client_secret"` ClientSecret string `form:"client_secret"`
CodeVerifier string `form:"code_verifier"`
} }

View File

@@ -1,23 +1,25 @@
package dto package dto
import "time" import (
datatype "github.com/stonith404/pocket-id/backend/internal/model/types"
)
type UserGroupDtoWithUsers struct { type UserGroupDtoWithUsers struct {
ID string `json:"id"` ID string `json:"id"`
FriendlyName string `json:"friendlyName"` FriendlyName string `json:"friendlyName"`
Name string `json:"name"` Name string `json:"name"`
CustomClaims []CustomClaimDto `json:"customClaims"` CustomClaims []CustomClaimDto `json:"customClaims"`
Users []UserDto `json:"users"` Users []UserDto `json:"users"`
CreatedAt time.Time `json:"createdAt"` CreatedAt datatype.DateTime `json:"createdAt"`
} }
type UserGroupDtoWithUserCount struct { type UserGroupDtoWithUserCount struct {
ID string `json:"id"` ID string `json:"id"`
FriendlyName string `json:"friendlyName"` FriendlyName string `json:"friendlyName"`
Name string `json:"name"` Name string `json:"name"`
CustomClaims []CustomClaimDto `json:"customClaims"` CustomClaims []CustomClaimDto `json:"customClaims"`
UserCount int64 `json:"userCount"` UserCount int64 `json:"userCount"`
CreatedAt time.Time `json:"createdAt"` CreatedAt datatype.DateTime `json:"createdAt"`
} }
type UserGroupCreateDto struct { type UserGroupCreateDto struct {

View File

@@ -2,7 +2,7 @@ package dto
import ( import (
"github.com/go-webauthn/webauthn/protocol" "github.com/go-webauthn/webauthn/protocol"
"time" datatype "github.com/stonith404/pocket-id/backend/internal/model/types"
) )
type WebauthnCredentialDto struct { type WebauthnCredentialDto struct {
@@ -15,7 +15,7 @@ type WebauthnCredentialDto struct {
BackupEligible bool `json:"backupEligible"` BackupEligible bool `json:"backupEligible"`
BackupState bool `json:"backupState"` BackupState bool `json:"backupState"`
CreatedAt time.Time `json:"createdAt"` CreatedAt datatype.DateTime `json:"createdAt"`
} }
type WebauthnCredentialUpdateDto struct { type WebauthnCredentialUpdateDto struct {

View File

@@ -1,11 +1,8 @@
package middleware package middleware
import ( import (
"github.com/stonith404/pocket-id/backend/internal/common"
"time"
"github.com/gin-contrib/cors"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/stonith404/pocket-id/backend/internal/common"
) )
type CorsMiddleware struct{} type CorsMiddleware struct{}
@@ -15,10 +12,22 @@ func NewCorsMiddleware() *CorsMiddleware {
} }
func (m *CorsMiddleware) Add() gin.HandlerFunc { func (m *CorsMiddleware) Add() gin.HandlerFunc {
return cors.New(cors.Config{ return func(c *gin.Context) {
AllowOrigins: []string{common.EnvConfig.AppURL}, // Allow all origins for the token endpoint
AllowMethods: []string{"*"}, if c.FullPath() == "/api/oidc/token" {
AllowHeaders: []string{"*"}, c.Writer.Header().Set("Access-Control-Allow-Origin", "*")
MaxAge: 12 * time.Hour, } else {
}) c.Writer.Header().Set("Access-Control-Allow-Origin", common.EnvConfig.AppURL)
}
c.Writer.Header().Set("Access-Control-Allow-Headers", "*")
c.Writer.Header().Set("Access-Control-Allow-Methods", "POST, OPTIONS, GET, PUT")
if c.Request.Method == "OPTIONS" {
c.AbortWithStatus(204)
return
}
c.Next()
}
} }

View File

@@ -19,6 +19,7 @@ func (m *FileSizeLimitMiddleware) Add(maxSize int64) gin.HandlerFunc {
if err := c.Request.ParseMultipartForm(maxSize); err != nil { if err := c.Request.ParseMultipartForm(maxSize); err != nil {
err = &common.FileTooLargeError{MaxSize: formatFileSize(maxSize)} err = &common.FileTooLargeError{MaxSize: formatFileSize(maxSize)}
c.Error(err) c.Error(err)
c.Abort()
return return
} }
c.Next() c.Next()

View File

@@ -29,6 +29,7 @@ func (m *JwtAuthMiddleware) Add(adminOnly bool) gin.HandlerFunc {
return return
} else { } else {
c.Error(&common.NotSignedInError{}) c.Error(&common.NotSignedInError{})
c.Abort()
return return
} }
} }

View File

@@ -32,6 +32,7 @@ func (m *RateLimitMiddleware) Add(limit rate.Limit, burst int) gin.HandlerFunc {
limiter := getLimiter(ip, limit, burst) limiter := getLimiter(ip, limit, burst)
if !limiter.Allow() { if !limiter.Allow() {
c.Error(&common.TooManyRequestsError{}) c.Error(&common.TooManyRequestsError{})
c.Abort()
return return
} }

View File

@@ -19,10 +19,11 @@ type AppConfig struct {
LogoLightImageType AppConfigVariable LogoLightImageType AppConfigVariable
LogoDarkImageType AppConfigVariable LogoDarkImageType AppConfigVariable
EmailEnabled AppConfigVariable EmailEnabled AppConfigVariable
SmtpHost AppConfigVariable SmtpHost AppConfigVariable
SmtpPort AppConfigVariable SmtpPort AppConfigVariable
SmtpFrom AppConfigVariable SmtpFrom AppConfigVariable
SmtpUser AppConfigVariable SmtpUser AppConfigVariable
SmtpPassword AppConfigVariable SmtpPassword AppConfigVariable
SmtpSkipCertVerify AppConfigVariable
} }

View File

@@ -23,9 +23,10 @@ type AuditLogData map[string]string
type AuditLogEvent string type AuditLogEvent string
const ( const (
AuditLogEventSignIn AuditLogEvent = "SIGN_IN" AuditLogEventSignIn AuditLogEvent = "SIGN_IN"
AuditLogEventClientAuthorization AuditLogEvent = "CLIENT_AUTHORIZATION" AuditLogEventOneTimeAccessTokenSignIn AuditLogEvent = "TOKEN_SIGN_IN"
AuditLogEventNewClientAuthorization AuditLogEvent = "NEW_CLIENT_AUTHORIZATION" AuditLogEventClientAuthorization AuditLogEvent = "CLIENT_AUTHORIZATION"
AuditLogEventNewClientAuthorization AuditLogEvent = "NEW_CLIENT_AUTHORIZATION"
) )
// Scan and Value methods for GORM to handle the custom type // Scan and Value methods for GORM to handle the custom type

View File

@@ -20,10 +20,12 @@ type UserAuthorizedOidcClient struct {
type OidcAuthorizationCode struct { type OidcAuthorizationCode struct {
Base Base
Code string Code string
Scope string Scope string
Nonce string Nonce string
ExpiresAt datatype.DateTime CodeChallenge *string
CodeChallengeMethodSha256 *bool
ExpiresAt datatype.DateTime
UserID string UserID string
User User User User
@@ -39,6 +41,7 @@ type OidcClient struct {
CallbackURLs CallbackURLs CallbackURLs CallbackURLs
ImageType *string ImageType *string
HasLogo bool `gorm:"-"` HasLogo bool `gorm:"-"`
IsPublic bool
CreatedByID string CreatedByID string
CreatedBy User CreatedBy User

View File

@@ -59,6 +59,8 @@ func (u User) WebAuthnCredentialDescriptors() (descriptors []protocol.Credential
return descriptors return descriptors
} }
func (u User) FullName() string { return u.FirstName + " " + u.LastName }
type OneTimeAccessToken struct { type OneTimeAccessToken struct {
Base Base
Token string Token string

View File

@@ -95,6 +95,11 @@ var defaultDbConfig = model.AppConfig{
Key: "smtpPassword", Key: "smtpPassword",
Type: "string", Type: "string",
}, },
SmtpSkipCertVerify: model.AppConfigVariable{
Key: "smtpSkipCertVerify",
Type: "bool",
DefaultValue: "false",
},
} }
func (s *AppConfigService) UpdateAppConfig(input dto.AppConfigUpdateDto) ([]model.AppConfigVariable, error) { func (s *AppConfigService) UpdateAppConfig(input dto.AppConfigUpdateDto) ([]model.AppConfigVariable, error) {

View File

@@ -48,8 +48,8 @@ func (s *AuditLogService) Create(event model.AuditLogEvent, ipAddress, userAgent
} }
// CreateNewSignInWithEmail creates a new audit log entry in the database and sends an email if the device hasn't been used before // CreateNewSignInWithEmail creates a new audit log entry in the database and sends an email if the device hasn't been used before
func (s *AuditLogService) CreateNewSignInWithEmail(ipAddress, userAgent, userID string, data model.AuditLogData) model.AuditLog { func (s *AuditLogService) CreateNewSignInWithEmail(ipAddress, userAgent, userID string) model.AuditLog {
createdAuditLog := s.Create(model.AuditLogEventSignIn, ipAddress, userAgent, userID, data) createdAuditLog := s.Create(model.AuditLogEventSignIn, ipAddress, userAgent, userID, model.AuditLogData{})
// Count the number of times the user has logged in from the same device // Count the number of times the user has logged in from the same device
var count int64 var count int64

View File

@@ -2,14 +2,18 @@ package service
import ( import (
"bytes" "bytes"
"crypto/tls"
"errors" "errors"
"fmt" "fmt"
"github.com/stonith404/pocket-id/backend/internal/common" "github.com/stonith404/pocket-id/backend/internal/common"
"github.com/stonith404/pocket-id/backend/internal/model"
"github.com/stonith404/pocket-id/backend/internal/utils/email" "github.com/stonith404/pocket-id/backend/internal/utils/email"
"gorm.io/gorm"
htemplate "html/template" htemplate "html/template"
"io/fs" "io/fs"
"mime/multipart" "mime/multipart"
"mime/quotedprintable" "mime/quotedprintable"
"net"
"net/smtp" "net/smtp"
"net/textproto" "net/textproto"
ttemplate "text/template" ttemplate "text/template"
@@ -17,11 +21,12 @@ import (
type EmailService struct { type EmailService struct {
appConfigService *AppConfigService appConfigService *AppConfigService
db *gorm.DB
htmlTemplates map[string]*htemplate.Template htmlTemplates map[string]*htemplate.Template
textTemplates map[string]*ttemplate.Template textTemplates map[string]*ttemplate.Template
} }
func NewEmailService(appConfigService *AppConfigService, templateDir fs.FS) (*EmailService, error) { func NewEmailService(appConfigService *AppConfigService, db *gorm.DB, templateDir fs.FS) (*EmailService, error) {
htmlTemplates, err := email.PrepareHTMLTemplates(templateDir, emailTemplatesPaths) htmlTemplates, err := email.PrepareHTMLTemplates(templateDir, emailTemplatesPaths)
if err != nil { if err != nil {
return nil, fmt.Errorf("prepare html templates: %w", err) return nil, fmt.Errorf("prepare html templates: %w", err)
@@ -34,11 +39,25 @@ func NewEmailService(appConfigService *AppConfigService, templateDir fs.FS) (*Em
return &EmailService{ return &EmailService{
appConfigService: appConfigService, appConfigService: appConfigService,
db: db,
htmlTemplates: htmlTemplates, htmlTemplates: htmlTemplates,
textTemplates: textTemplates, textTemplates: textTemplates,
}, nil }, nil
} }
func (srv *EmailService) SendTestEmail() error {
var user model.User
if err := srv.db.First(&user).Error; err != nil {
return err
}
return SendEmail(srv,
email.Address{
Email: user.Email,
Name: user.FullName(),
}, TestTemplate, nil)
}
func SendEmail[V any](srv *EmailService, toEmail email.Address, template email.Template[V], tData *V) error { func SendEmail[V any](srv *EmailService, toEmail email.Address, template email.Template[V], tData *V) error {
// Check if SMTP settings are set // Check if SMTP settings are set
if srv.appConfigService.DbConfig.EmailEnabled.Value != "true" { if srv.appConfigService.DbConfig.EmailEnabled.Value != "true" {
@@ -71,26 +90,100 @@ func SendEmail[V any](srv *EmailService, toEmail email.Address, template email.T
) )
c.Body(body) c.Body(body)
// Set up the authentication information. // Set up the TLS configuration
tlsConfig := &tls.Config{
InsecureSkipVerify: srv.appConfigService.DbConfig.SmtpSkipCertVerify.Value == "true",
ServerName: srv.appConfigService.DbConfig.SmtpHost.Value,
}
// Connect to the SMTP server
port := srv.appConfigService.DbConfig.SmtpPort.Value
var client *smtp.Client
if port == "465" {
client, err = srv.connectToSmtpServerUsingImplicitTLS(
srv.appConfigService.DbConfig.SmtpHost.Value+":"+port,
tlsConfig,
)
} else {
client, err = srv.connectToSmtpServerUsingStartTLS(
srv.appConfigService.DbConfig.SmtpHost.Value+":"+port,
tlsConfig,
)
}
defer client.Quit()
if err != nil {
return fmt.Errorf("failed to connect to SMTP server: %w", err)
}
// Set up the authentication
auth := smtp.PlainAuth("", auth := smtp.PlainAuth("",
srv.appConfigService.DbConfig.SmtpUser.Value, srv.appConfigService.DbConfig.SmtpUser.Value,
srv.appConfigService.DbConfig.SmtpPassword.Value, srv.appConfigService.DbConfig.SmtpPassword.Value,
srv.appConfigService.DbConfig.SmtpHost.Value, srv.appConfigService.DbConfig.SmtpHost.Value,
) )
if err := client.Auth(auth); err != nil {
// Send the email return fmt.Errorf("failed to authenticate SMTP client: %w", err)
err = smtp.SendMail(
srv.appConfigService.DbConfig.SmtpHost.Value+":"+srv.appConfigService.DbConfig.SmtpPort.Value,
auth,
srv.appConfigService.DbConfig.SmtpFrom.Value,
[]string{toEmail.Email},
[]byte(c.String()),
)
if err != nil {
return fmt.Errorf("failed to send email: %w", err)
} }
// Send the email
if err := srv.sendEmailContent(client, toEmail, c); err != nil {
return fmt.Errorf("send email content: %w", err)
}
return nil
}
func (srv *EmailService) connectToSmtpServerUsingImplicitTLS(serverAddr string, tlsConfig *tls.Config) (*smtp.Client, error) {
conn, err := tls.Dial("tcp", serverAddr, tlsConfig)
if err != nil {
return nil, fmt.Errorf("failed to connect to SMTP server: %w", err)
}
client, err := smtp.NewClient(conn, srv.appConfigService.DbConfig.SmtpHost.Value)
if err != nil {
conn.Close()
return nil, fmt.Errorf("failed to create SMTP client: %w", err)
}
return client, nil
}
func (srv *EmailService) connectToSmtpServerUsingStartTLS(serverAddr string, tlsConfig *tls.Config) (*smtp.Client, error) {
conn, err := net.Dial("tcp", serverAddr)
if err != nil {
return nil, fmt.Errorf("failed to connect to SMTP server: %w", err)
}
client, err := smtp.NewClient(conn, srv.appConfigService.DbConfig.SmtpHost.Value)
if err != nil {
conn.Close()
return nil, fmt.Errorf("failed to create SMTP client: %w", err)
}
if err := client.StartTLS(tlsConfig); err != nil {
return nil, fmt.Errorf("failed to start TLS: %w", err)
}
return client, nil
}
func (srv *EmailService) sendEmailContent(client *smtp.Client, toEmail email.Address, c *email.Composer) error {
if err := client.Mail(srv.appConfigService.DbConfig.SmtpFrom.Value); err != nil {
return fmt.Errorf("failed to set sender: %w", err)
}
if err := client.Rcpt(toEmail.Email); err != nil {
return fmt.Errorf("failed to set recipient: %w", err)
}
w, err := client.Data()
if err != nil {
return fmt.Errorf("failed to start data: %w", err)
}
_, err = w.Write([]byte(c.String()))
if err != nil {
return fmt.Errorf("failed to write email data: %w", err)
}
if err := w.Close(); err != nil {
return fmt.Errorf("failed to close data writer: %w", err)
}
return nil return nil
} }

View File

@@ -27,6 +27,13 @@ var NewLoginTemplate = email.Template[NewLoginTemplateData]{
}, },
} }
var TestTemplate = email.Template[struct{}]{
Path: "test",
Title: func(data *email.TemplateData[struct{}]) string {
return "Test email"
},
}
type NewLoginTemplateData struct { type NewLoginTemplateData struct {
IPAddress string IPAddress string
Country string Country string
@@ -36,4 +43,4 @@ type NewLoginTemplateData struct {
} }
// this is list of all template paths used for preloading templates // this is list of all template paths used for preloading templates
var emailTemplatesPaths = []string{NewLoginTemplate.Path} var emailTemplatesPaths = []string{NewLoginTemplate.Path, TestTemplate.Path}

View File

@@ -1,6 +1,8 @@
package service package service
import ( import (
"crypto/sha256"
"encoding/base64"
"errors" "errors"
"fmt" "fmt"
"github.com/stonith404/pocket-id/backend/internal/common" "github.com/stonith404/pocket-id/backend/internal/common"
@@ -39,16 +41,20 @@ func (s *OidcService) Authorize(input dto.AuthorizeOidcClientRequestDto, userID,
var userAuthorizedOIDCClient model.UserAuthorizedOidcClient var userAuthorizedOIDCClient model.UserAuthorizedOidcClient
s.db.Preload("Client").First(&userAuthorizedOIDCClient, "client_id = ? AND user_id = ?", input.ClientID, userID) 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 { if userAuthorizedOIDCClient.Scope != input.Scope {
return "", "", &common.OidcMissingAuthorizationError{} return "", "", &common.OidcMissingAuthorizationError{}
} }
callbackURL, err := getCallbackURL(userAuthorizedOIDCClient.Client, input.CallbackURL) callbackURL, err := s.getCallbackURL(userAuthorizedOIDCClient.Client, input.CallbackURL)
if err != nil { if err != nil {
return "", "", err return "", "", err
} }
code, err := s.createAuthorizationCode(input.ClientID, userID, input.Scope, input.Nonce) code, err := s.createAuthorizationCode(input.ClientID, userID, input.Scope, input.Nonce, input.CodeChallenge, input.CodeChallengeMethod)
if err != nil { if err != nil {
return "", "", err return "", "", err
} }
@@ -64,7 +70,11 @@ func (s *OidcService) AuthorizeNewClient(input dto.AuthorizeOidcClientRequestDto
return "", "", err return "", "", err
} }
callbackURL, err := getCallbackURL(client, input.CallbackURL) if client.IsPublic && input.CodeChallenge == "" {
return "", "", &common.OidcMissingCodeChallengeError{}
}
callbackURL, err := s.getCallbackURL(client, input.CallbackURL)
if err != nil { if err != nil {
return "", "", err return "", "", err
} }
@@ -83,7 +93,7 @@ func (s *OidcService) AuthorizeNewClient(input dto.AuthorizeOidcClientRequestDto
} }
} }
code, err := s.createAuthorizationCode(input.ClientID, userID, input.Scope, input.Nonce) code, err := s.createAuthorizationCode(input.ClientID, userID, input.Scope, input.Nonce, input.CodeChallenge, input.CodeChallengeMethod)
if err != nil { if err != nil {
return "", "", err return "", "", err
} }
@@ -93,31 +103,41 @@ func (s *OidcService) AuthorizeNewClient(input dto.AuthorizeOidcClientRequestDto
return code, callbackURL, nil return code, callbackURL, nil
} }
func (s *OidcService) CreateTokens(code, grantType, clientID, clientSecret string) (string, string, error) { func (s *OidcService) CreateTokens(code, grantType, clientID, clientSecret, codeVerifier string) (string, string, error) {
if grantType != "authorization_code" { if grantType != "authorization_code" {
return "", "", &common.OidcGrantTypeNotSupportedError{} return "", "", &common.OidcGrantTypeNotSupportedError{}
} }
if clientID == "" || clientSecret == "" {
return "", "", &common.OidcMissingClientCredentialsError{}
}
var client model.OidcClient var client model.OidcClient
if err := s.db.First(&client, "id = ?", clientID).Error; err != nil { if err := s.db.First(&client, "id = ?", clientID).Error; err != nil {
return "", "", err return "", "", err
} }
err := bcrypt.CompareHashAndPassword([]byte(client.Secret), []byte(clientSecret)) // Verify the client secret if the client is not public
if err != nil { if !client.IsPublic {
return "", "", &common.OidcClientSecretInvalidError{} if clientID == "" || clientSecret == "" {
return "", "", &common.OidcMissingClientCredentialsError{}
}
err := bcrypt.CompareHashAndPassword([]byte(client.Secret), []byte(clientSecret))
if err != nil {
return "", "", &common.OidcClientSecretInvalidError{}
}
} }
var authorizationCodeMetaData model.OidcAuthorizationCode var authorizationCodeMetaData model.OidcAuthorizationCode
err = s.db.Preload("User").First(&authorizationCodeMetaData, "code = ?", code).Error err := s.db.Preload("User").First(&authorizationCodeMetaData, "code = ?", code).Error
if err != nil { if err != nil {
return "", "", &common.OidcInvalidAuthorizationCodeError{} return "", "", &common.OidcInvalidAuthorizationCodeError{}
} }
// If the client is public, the code verifier must match the code challenge
if client.IsPublic {
if !s.validateCodeVerifier(codeVerifier, *authorizationCodeMetaData.CodeChallenge, *authorizationCodeMetaData.CodeChallengeMethodSha256) {
return "", "", &common.OidcInvalidCodeVerifierError{}
}
}
if authorizationCodeMetaData.ClientID != clientID && authorizationCodeMetaData.ExpiresAt.ToTime().Before(time.Now()) { if authorizationCodeMetaData.ClientID != clientID && authorizationCodeMetaData.ExpiresAt.ToTime().Before(time.Now()) {
return "", "", &common.OidcInvalidAuthorizationCodeError{} return "", "", &common.OidcInvalidAuthorizationCodeError{}
} }
@@ -186,6 +206,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.IsPublic = input.IsPublic
if err := s.db.Save(&client).Error; err != nil { if err := s.db.Save(&client).Error; err != nil {
return model.OidcClient{}, err return model.OidcClient{}, err
@@ -331,7 +352,7 @@ func (s *OidcService) GetUserClaimsForClient(userID string, clientID string) (ma
profileClaims := map[string]interface{}{ profileClaims := map[string]interface{}{
"given_name": user.FirstName, "given_name": user.FirstName,
"family_name": user.LastName, "family_name": user.LastName,
"name": user.FirstName + " " + user.LastName, "name": user.FullName(),
"preferred_username": user.Username, "preferred_username": user.Username,
} }
@@ -358,19 +379,23 @@ func (s *OidcService) GetUserClaimsForClient(userID string, clientID string) (ma
return claims, nil return claims, nil
} }
func (s *OidcService) createAuthorizationCode(clientID string, userID string, scope string, nonce 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 {
return "", err return "", err
} }
codeChallengeMethodSha256 := strings.ToUpper(codeChallengeMethod) == "S256"
oidcAuthorizationCode := model.OidcAuthorizationCode{ oidcAuthorizationCode := model.OidcAuthorizationCode{
ExpiresAt: datatype.DateTime(time.Now().Add(15 * time.Minute)), ExpiresAt: datatype.DateTime(time.Now().Add(15 * time.Minute)),
Code: randomString, Code: randomString,
ClientID: clientID, ClientID: clientID,
UserID: userID, UserID: userID,
Scope: scope, Scope: scope,
Nonce: nonce, Nonce: nonce,
CodeChallenge: &codeChallenge,
CodeChallengeMethodSha256: &codeChallengeMethodSha256,
} }
if err := s.db.Create(&oidcAuthorizationCode).Error; err != nil { if err := s.db.Create(&oidcAuthorizationCode).Error; err != nil {
@@ -380,7 +405,23 @@ func (s *OidcService) createAuthorizationCode(clientID string, userID string, sc
return randomString, nil return randomString, nil
} }
func getCallbackURL(client model.OidcClient, inputCallbackURL string) (callbackURL string, err error) { func (s *OidcService) validateCodeVerifier(codeVerifier, codeChallenge string, codeChallengeMethodSha256 bool) bool {
if !codeChallengeMethodSha256 {
return codeVerifier == codeChallenge
}
// Compute SHA-256 hash of the codeVerifier
h := sha256.New()
h.Write([]byte(codeVerifier))
codeVerifierHash := h.Sum(nil)
// Base64 URL encode the verifier hash
encodedVerifierHash := base64.RawURLEncoding.EncodeToString(codeVerifierHash)
return encodedVerifierHash == codeChallenge
}
func (s *OidcService) getCallbackURL(client model.OidcClient, inputCallbackURL string) (callbackURL string, err error) {
if inputCallbackURL == "" { if inputCallbackURL == "" {
return client.CallbackURLs[0], nil return client.CallbackURLs[0], nil
} }

View File

@@ -12,12 +12,13 @@ import (
) )
type UserService struct { type UserService struct {
db *gorm.DB db *gorm.DB
jwtService *JwtService jwtService *JwtService
auditLogService *AuditLogService
} }
func NewUserService(db *gorm.DB, jwtService *JwtService) *UserService { func NewUserService(db *gorm.DB, jwtService *JwtService, auditLogService *AuditLogService) *UserService {
return &UserService{db: db, jwtService: jwtService} return &UserService{db: db, jwtService: jwtService, auditLogService: auditLogService}
} }
func (s *UserService) ListUsers(searchTerm string, page int, pageSize int) ([]model.User, utils.PaginationResponse, error) { func (s *UserService) ListUsers(searchTerm string, page int, pageSize int) ([]model.User, utils.PaginationResponse, error) {
@@ -88,7 +89,7 @@ func (s *UserService) UpdateUser(userID string, updatedUser dto.UserCreateDto, u
return user, nil return user, nil
} }
func (s *UserService) CreateOneTimeAccessToken(userID string, expiresAt time.Time) (string, error) { func (s *UserService) CreateOneTimeAccessToken(userID string, expiresAt time.Time, ipAddress, userAgent string) (string, error) {
randomString, err := utils.GenerateRandomAlphanumericString(16) randomString, err := utils.GenerateRandomAlphanumericString(16)
if err != nil { if err != nil {
return "", err return "", err
@@ -104,6 +105,8 @@ func (s *UserService) CreateOneTimeAccessToken(userID string, expiresAt time.Tim
return "", err return "", err
} }
s.auditLogService.Create(model.AuditLogEventOneTimeAccessTokenSignIn, ipAddress, userAgent, userID, model.AuditLogData{})
return oneTimeAccessToken.Token, nil return oneTimeAccessToken.Token, nil
} }

View File

@@ -165,7 +165,7 @@ func (s *WebAuthnService) VerifyLogin(sessionID, userID string, credentialAssert
return model.User{}, "", err return model.User{}, "", err
} }
s.auditLogService.CreateNewSignInWithEmail(ipAddress, userAgent, user.ID, model.AuditLogData{}) s.auditLogService.CreateNewSignInWithEmail(ipAddress, userAgent, user.ID)
return *user, token, nil return *user, token, nil
} }

View File

@@ -0,0 +1,3 @@
ALTER TABLE oidc_authorization_codes DROP COLUMN code_challenge;
ALTER TABLE oidc_authorization_codes DROP COLUMN code_challenge_method_sha256;
ALTER TABLE oidc_clients DROP COLUMN is_public;

View File

@@ -0,0 +1,3 @@
ALTER TABLE oidc_authorization_codes ADD COLUMN code_challenge TEXT;
ALTER TABLE oidc_authorization_codes ADD COLUMN code_challenge_method_sha256 NUMERIC;
ALTER TABLE oidc_clients ADD COLUMN is_public BOOLEAN DEFAULT FALSE;

View File

@@ -1,6 +1,6 @@
{ {
"name": "pocket-id-frontend", "name": "pocket-id-frontend",
"version": "0.13.0", "version": "0.15.0",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "vite dev --port 3000", "dev": "vite dev --port 3000",

View File

@@ -4,6 +4,7 @@
import * as Pagination from '$lib/components/ui/pagination'; import * as Pagination from '$lib/components/ui/pagination';
import * as Select from '$lib/components/ui/select'; import * as Select from '$lib/components/ui/select';
import * as Table from '$lib/components/ui/table/index.js'; import * as Table from '$lib/components/ui/table/index.js';
import Empty from '$lib/icons/empty.svelte';
import type { Paginated } from '$lib/types/pagination.type'; import type { Paginated } from '$lib/types/pagination.type';
import { debounced } from '$lib/utils/debounce-util'; import { debounced } from '$lib/utils/debounce-util';
import type { Snippet } from 'svelte'; import type { Snippet } from 'svelte';
@@ -66,93 +67,104 @@
} }
</script> </script>
<div class="w-full"> {#if items.data.length === 0}
{#if !withoutSearch} <div class="my-5 flex flex-col items-center">
<Input <Empty class="text-muted-foreground h-20" />
class="mb-4 max-w-sm" <p class="text-muted-foreground mt-3 text-sm">No items found</p>
placeholder={'Search...'}
type="text"
oninput={(e) => onSearch((e.target as HTMLInputElement).value)}
/>
{/if}
<Table.Root>
<Table.Header>
<Table.Row>
{#if selectedIds}
<Table.Head>
<Checkbox checked={allChecked} onCheckedChange={(c) => onAllCheck(c as boolean)} />
</Table.Head>
{/if}
{#each columns as column}
{#if typeof column === 'string'}
<Table.Head>{column}</Table.Head>
{:else}
<Table.Head class={column.hidden ? 'sr-only' : ''}>{column.label}</Table.Head>
{/if}
{/each}
</Table.Row>
</Table.Header>
<Table.Body>
{#each items.data as item}
<Table.Row class={selectedIds?.includes(item.id) ? 'bg-muted/20' : ''}>
{#if selectedIds}
<Table.Cell>
<Checkbox
checked={selectedIds.includes(item.id)}
onCheckedChange={(c) => onCheck(c as boolean, item.id)}
/>
</Table.Cell>
{/if}
{@render rows({ item })}
</Table.Row>
{/each}
</Table.Body>
</Table.Root>
<div class="mt-5 flex items-center justify-between space-x-2">
<div class="flex items-center space-x-2">
<p class="text-sm font-medium">Items per page</p>
<Select.Root
selected={{
label: items.pagination.itemsPerPage.toString(),
value: items.pagination.itemsPerPage
}}
onSelectedChange={(v) => onPageSizeChange(v?.value as number)}
>
<Select.Trigger class="h-9 w-[80px]">
<Select.Value>{items.pagination.itemsPerPage}</Select.Value>
</Select.Trigger>
<Select.Content>
{#each availablePageSizes as size}
<Select.Item value={size}>{size}</Select.Item>
{/each}
</Select.Content>
</Select.Root>
</div>
<Pagination.Root
class="mx-0 w-auto"
count={items.pagination.totalItems}
perPage={items.pagination.itemsPerPage}
{onPageChange}
page={items.pagination.currentPage}
let:pages
>
<Pagination.Content class="flex justify-end">
<Pagination.Item>
<Pagination.PrevButton />
</Pagination.Item>
{#each pages as page (page.key)}
{#if page.type !== 'ellipsis'}
<Pagination.Item>
<Pagination.Link {page} isActive={items.pagination.currentPage === page.value}>
{page.value}
</Pagination.Link>
</Pagination.Item>
{/if}
{/each}
<Pagination.Item>
<Pagination.NextButton />
</Pagination.Item>
</Pagination.Content>
</Pagination.Root>
</div> </div>
</div> {:else}
<div class="w-full">
{#if !withoutSearch}
<Input
class="mb-4 max-w-sm"
placeholder={'Search...'}
type="text"
oninput={(e) => onSearch((e.target as HTMLInputElement).value)}
/>
{/if}
<Table.Root>
<Table.Header>
<Table.Row>
{#if selectedIds}
<Table.Head>
<Checkbox checked={allChecked} onCheckedChange={(c) => onAllCheck(c as boolean)} />
</Table.Head>
{/if}
{#each columns as column}
{#if typeof column === 'string'}
<Table.Head>{column}</Table.Head>
{:else}
<Table.Head class={column.hidden ? 'sr-only' : ''}>{column.label}</Table.Head>
{/if}
{/each}
</Table.Row>
</Table.Header>
<Table.Body>
{#each items.data as item}
<Table.Row class={selectedIds?.includes(item.id) ? 'bg-muted/20' : ''}>
{#if selectedIds}
<Table.Cell>
<Checkbox
checked={selectedIds.includes(item.id)}
onCheckedChange={(c) => onCheck(c as boolean, item.id)}
/>
</Table.Cell>
{/if}
{@render rows({ item })}
</Table.Row>
{/each}
</Table.Body>
</Table.Root>
<div
class="mt-5 flex flex-col-reverse items-center justify-between gap-3 space-x-2 sm:flex-row"
>
<div class="flex items-center space-x-2">
<p class="text-sm font-medium">Items per page</p>
<Select.Root
selected={{
label: items.pagination.itemsPerPage.toString(),
value: items.pagination.itemsPerPage
}}
onSelectedChange={(v) => onPageSizeChange(v?.value as number)}
>
<Select.Trigger class="h-9 w-[80px]">
<Select.Value>{items.pagination.itemsPerPage}</Select.Value>
</Select.Trigger>
<Select.Content>
{#each availablePageSizes as size}
<Select.Item value={size}>{size}</Select.Item>
{/each}
</Select.Content>
</Select.Root>
</div>
<Pagination.Root
class="mx-0 w-auto"
count={items.pagination.totalItems}
perPage={items.pagination.itemsPerPage}
{onPageChange}
page={items.pagination.currentPage}
let:pages
>
<Pagination.Content class="flex justify-end">
<Pagination.Item>
<Pagination.PrevButton />
</Pagination.Item>
{#each pages as page (page.key)}
{#if page.type !== 'ellipsis'}
<Pagination.Item>
<Pagination.Link {page} isActive={items.pagination.currentPage === page.value}>
{page.value}
</Pagination.Link>
</Pagination.Item>
{/if}
{/each}
<Pagination.Item>
<Pagination.NextButton />
</Pagination.Item>
</Pagination.Content>
</Pagination.Root>
</div>
</div>
{/if}

View File

@@ -0,0 +1,25 @@
<script lang="ts">
import { Checkbox } from './ui/checkbox';
import { Label } from './ui/label';
let {
id,
checked = $bindable(),
label,
description
}: { id: string; checked: boolean; label: string; description?: string } = $props();
</script>
<div class="items-top mt-5 flex space-x-2">
<Checkbox {id} bind:checked />
<div class="grid gap-1.5 leading-none">
<Label for={id} class="mb-0 text-sm font-medium leading-none">
{label}
</Label>
{#if description}
<p class="text-muted-foreground text-[0.8rem]">
{description}
</p>
{/if}
</div>
</div>

View File

@@ -2,7 +2,7 @@ import { type VariantProps, tv } from "tailwind-variants";
export { default as Badge } from "./badge.svelte"; export { default as Badge } from "./badge.svelte";
export const badgeVariants = tv({ export const badgeVariants = tv({
base: "inline-flex select-none items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2", base: "inline-flex select-none items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 break-keep whitespace-nowrap",
variants: { variants: {
variant: { variant: {
default: "border-transparent bg-primary text-primary-foreground hover:bg-primary/80", default: "border-transparent bg-primary text-primary-foreground hover:bg-primary/80",

View File

@@ -10,7 +10,7 @@
</script> </script>
<span <span
aria-hidden aria-hidden="true"
class={cn("flex h-9 w-9 items-center justify-center", className)} class={cn("flex h-9 w-9 items-center justify-center", className)}
{...$$restProps} {...$$restProps}
> >

View File

@@ -0,0 +1,24 @@
<script lang="ts">
let {
class: className
}: {
class?: string;
} = $props();
</script>
<svg
version="1.1"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 336.19673868301203 129.38671875"
class={className}
>
<g stroke-linecap="round" transform="translate(10 10) rotate(0 158.09836934150601 54.693359375)">
<path
d="M27.35 0 C121.36 -0.62, 208.79 0.52, 288.85 0 M288.85 0 C305.5 3.32, 316.8 6.14, 316.2 27.35 M316.2 27.35 C315.58 42.15, 314.92 54.54, 316.2 82.04 M316.2 82.04 C313.79 100.68, 304.9 110.1, 288.85 109.39 M288.85 109.39 C192.86 108.68, 93.17 110.07, 27.35 109.39 M27.35 109.39 C13.09 109.46, -1.61 102.22, 0 82.04 M0 82.04 C-0.35 60.8, -1.11 41.01, 0 27.35 M0 27.35 C1.94 9.62, 8.6 1.41, 27.35 0"
stroke="#A1A1AA"
stroke-width="4.5"
fill="none"
stroke-dasharray="8 12"
></path>
</g>
</svg>

View File

@@ -53,6 +53,10 @@ export default class AppConfigService extends APIService {
await this.api.put(`/application-configuration/background-image`, formData); await this.api.put(`/application-configuration/background-image`, formData);
} }
async sendTestEmail() {
await this.api.post('/application-configuration/test-email');
}
async getVersionInformation() { async getVersionInformation() {
const response = ( const response = (
await axios.get('https://api.github.com/repos/stonith404/pocket-id/releases/latest') await axios.get('https://api.github.com/repos/stonith404/pocket-id/releases/latest')

View File

@@ -3,23 +3,27 @@ import type { Paginated, PaginationRequest } from '$lib/types/pagination.type';
import APIService from './api-service'; import APIService from './api-service';
class OidcService extends APIService { class OidcService extends APIService {
async authorize(clientId: string, scope: string, callbackURL: string, nonce?: string) { async authorize(clientId: string, scope: string, callbackURL: string, nonce?: string, codeChallenge?: string, codeChallengeMethod?: string) {
const res = await this.api.post('/oidc/authorize', { const res = await this.api.post('/oidc/authorize', {
scope, scope,
nonce, nonce,
callbackURL, callbackURL,
clientId clientId,
codeChallenge,
codeChallengeMethod
}); });
return res.data as AuthorizeResponse; return res.data as AuthorizeResponse;
} }
async authorizeNewClient(clientId: string, scope: string, callbackURL: string, nonce?: string) { async authorizeNewClient(clientId: string, scope: string, callbackURL: string, nonce?: string, codeChallenge?: string, codeChallengeMethod?: string) {
const res = await this.api.post('/oidc/authorize/new-client', { const res = await this.api.post('/oidc/authorize/new-client', {
scope, scope,
nonce, nonce,
callbackURL, callbackURL,
clientId clientId,
codeChallenge,
codeChallengeMethod
}); });
return res.data as AuthorizeResponse; return res.data as AuthorizeResponse;

View File

@@ -12,6 +12,7 @@ export type AllAppConfig = AppConfig & {
smtpFrom: string; smtpFrom: string;
smtpUser: string; smtpUser: string;
smtpPassword: string; smtpPassword: string;
smtpSkipCertVerify: boolean;
}; };
export type AppConfigRawResponse = { export type AppConfigRawResponse = {

View File

@@ -4,6 +4,7 @@ export type OidcClient = {
logoURL: string; logoURL: string;
callbackURLs: [string, ...string[]]; callbackURLs: [string, ...string[]];
hasLogo: boolean; hasLogo: boolean;
isPublic: boolean;
}; };
export type OidcClientCreate = Omit<OidcClient, 'id' | 'logoURL' | 'hasLogo'>; export type OidcClientCreate = Omit<OidcClient, 'id' | 'logoURL' | 'hasLogo'>;

View File

@@ -12,6 +12,8 @@ export const load: PageServerLoad = async ({ url, cookies }) => {
nonce: url.searchParams.get('nonce') || undefined, nonce: url.searchParams.get('nonce') || undefined,
state: url.searchParams.get('state')!, state: url.searchParams.get('state')!,
callbackURL: url.searchParams.get('redirect_uri')!, callbackURL: url.searchParams.get('redirect_uri')!,
client client,
codeChallenge: url.searchParams.get('code_challenge')!,
codeChallengeMethod: url.searchParams.get('code_challenge_method')!
}; };
}; };

View File

@@ -24,7 +24,7 @@
let authorizationRequired = false; let authorizationRequired = false;
export let data: PageData; export let data: PageData;
let { scope, nonce, client, state, callbackURL } = data; let { scope, nonce, client, state, callbackURL, codeChallenge, codeChallengeMethod } = data;
async function authorize() { async function authorize() {
isLoading = true; isLoading = true;
@@ -37,7 +37,7 @@
} }
await oidService await oidService
.authorize(client!.id, scope, callbackURL, nonce) .authorize(client!.id, scope, callbackURL, nonce, codeChallenge, codeChallengeMethod)
.then(async ({ code, callbackURL }) => { .then(async ({ code, callbackURL }) => {
onSuccess(code, callbackURL); onSuccess(code, callbackURL);
}); });
@@ -55,7 +55,7 @@
isLoading = true; isLoading = true;
try { try {
await oidService await oidService
.authorizeNewClient(client!.id, scope, callbackURL, nonce) .authorizeNewClient(client!.id, scope, callbackURL, nonce, codeChallenge, codeChallengeMethod)
.then(async ({ code, callbackURL }) => { .then(async ({ code, callbackURL }) => {
onSuccess(code, callbackURL); onSuccess(code, callbackURL);
}); });

View File

@@ -22,6 +22,7 @@
if ($userStore?.isAdmin) { if ($userStore?.isAdmin) {
links = [ links = [
// svelte-ignore state_referenced_locally
...links, ...links,
{ href: '/settings/admin/users', label: 'Users' }, { href: '/settings/admin/users', label: 'Users' },
{ href: '/settings/admin/user-groups', label: 'User Groups' }, { href: '/settings/admin/user-groups', label: 'User Groups' },

View File

@@ -1,6 +1,8 @@
<script lang="ts"> <script lang="ts">
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';
import AppConfigService from '$lib/services/app-config-service';
import type { AllAppConfig } from '$lib/types/application-configuration'; import type { AllAppConfig } from '$lib/types/application-configuration';
import { createForm } from '$lib/utils/form-util'; import { createForm } from '$lib/utils/form-util';
import { toast } from 'svelte-sonner'; import { toast } from 'svelte-sonner';
@@ -14,7 +16,9 @@
callback: (appConfig: Partial<AllAppConfig>) => Promise<void>; callback: (appConfig: Partial<AllAppConfig>) => Promise<void>;
} = $props(); } = $props();
let isLoading = $state(false); const appConfigService = new AppConfigService();
let isSendingTestEmail = $state(false);
let emailEnabled = $state(appConfig.emailEnabled); let emailEnabled = $state(appConfig.emailEnabled);
const updatedAppConfig = { const updatedAppConfig = {
@@ -23,7 +27,8 @@
smtpPort: appConfig.smtpPort, smtpPort: appConfig.smtpPort,
smtpUser: appConfig.smtpUser, smtpUser: appConfig.smtpUser,
smtpPassword: appConfig.smtpPassword, smtpPassword: appConfig.smtpPassword,
smtpFrom: appConfig.smtpFrom smtpFrom: appConfig.smtpFrom,
smtpSkipCertVerify: appConfig.smtpSkipCertVerify
}; };
const formSchema = z.object({ const formSchema = z.object({
@@ -31,19 +36,20 @@
smtpPort: z.number().min(1), smtpPort: z.number().min(1),
smtpUser: z.string().min(1), smtpUser: z.string().min(1),
smtpPassword: z.string().min(1), smtpPassword: z.string().min(1),
smtpFrom: z.string().email() smtpFrom: z.string().email(),
smtpSkipCertVerify: z.boolean()
}); });
const { inputs, ...form } = createForm<typeof formSchema>(formSchema, updatedAppConfig); const { inputs, ...form } = createForm<typeof formSchema>(formSchema, updatedAppConfig);
async function onSubmit() { async function onSubmit() {
console.log('submit');
const data = form.validate(); const data = form.validate();
if (!data) return false; if (!data) return false;
isLoading = true;
await callback({ await callback({
...data, ...data,
emailEnabled: true emailEnabled: true
}).finally(() => (isLoading = false)); });
toast.success('Email configuration updated successfully'); toast.success('Email configuration updated successfully');
return true; return true;
} }
@@ -59,22 +65,43 @@
emailEnabled = true; emailEnabled = true;
} }
} }
async function onTestEmail() {
isSendingTestEmail = true;
await appConfigService
.sendTestEmail()
.then(() => toast.success('Test email sent successfully to your Email address.'))
.catch(() =>
toast.error('Failed to send test email. Check the server logs for more information.')
)
.finally(() => (isSendingTestEmail = false));
}
</script> </script>
<form onsubmit={onSubmit}> <form onsubmit={onSubmit}>
<div class="mt-5 grid grid-cols-2 gap-5"> <div class="mt-5 grid grid-cols-1 gap-5 md:grid-cols-2">
<FormInput label="SMTP Host" bind:input={$inputs.smtpHost} /> <FormInput label="SMTP Host" bind:input={$inputs.smtpHost} />
<FormInput label="SMTP Port" type="number" bind:input={$inputs.smtpPort} /> <FormInput label="SMTP Port" type="number" bind:input={$inputs.smtpPort} />
<FormInput label="SMTP User" bind:input={$inputs.smtpUser} /> <FormInput label="SMTP User" bind:input={$inputs.smtpUser} />
<FormInput label="SMTP Password" type="password" bind:input={$inputs.smtpPassword} /> <FormInput label="SMTP Password" type="password" bind:input={$inputs.smtpPassword} />
<FormInput label="SMTP From" bind:input={$inputs.smtpFrom} /> <FormInput label="SMTP From" bind:input={$inputs.smtpFrom} />
<CheckboxWithLabel
id="skip-cert-verify"
label="Skip Certificate Verification"
description="This can be useful for self-signed certificates."
bind:checked={$inputs.smtpSkipCertVerify.value}
/>
</div> </div>
<div class="mt-5 flex justify-end gap-3"> <div class="mt-8 flex flex-wrap justify-end gap-3">
{#if emailEnabled} {#if emailEnabled}
<Button variant="secondary" onclick={onDisable}>Disable</Button> <Button variant="secondary" onclick={onDisable}>Disable</Button>
<Button {isLoading} onclick={onSubmit} type="submit">Save</Button> <Button isLoading={isSendingTestEmail} variant="secondary" onclick={onTestEmail}
>Send Test Email</Button
>
<Button onclick={onSubmit} type="submit">Save</Button>
{:else} {:else}
<Button {isLoading} onclick={onEnable} type="submit">Enable</Button> <Button onclick={onEnable}>Enable</Button>
{/if} {/if}
</div> </div>
</form> </form>

View File

@@ -1,4 +1,5 @@
<script lang="ts"> <script lang="ts">
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';
import { Checkbox } from '$lib/components/ui/checkbox'; import { Checkbox } from '$lib/components/ui/checkbox';
@@ -51,28 +52,18 @@
description="The duration of a session in minutes before the user has to sign in again." description="The duration of a session in minutes before the user has to sign in again."
bind:input={$inputs.sessionDuration} bind:input={$inputs.sessionDuration}
/> />
<div class="items-top mt-5 flex space-x-2"> <CheckboxWithLabel
<Checkbox id="admin-privileges" bind:checked={$inputs.allowOwnAccountEdit.value} /> id="self-account-editing"
<div class="grid gap-1.5 leading-none"> label="Enable Self-Account Editing"
<Label for="admin-privileges" class="mb-0 text-sm font-medium leading-none"> description="Whether the users should be able to edit their own account details."
Enable Self-Account Editing bind:checked={$inputs.allowOwnAccountEdit.value}
</Label> />
<p class="text-muted-foreground text-[0.8rem]"> <CheckboxWithLabel
Whether the user should be able to edit their own account details. id="emails-verified"
</p> label="Emails Verified"
</div> description="Whether the user's email should be marked as verified for the OIDC clients."
</div> bind:checked={$inputs.emailsVerified.value}
<div class="items-top mt-5 flex space-x-2"> />
<Checkbox id="admin-privileges" bind:checked={$inputs.emailsVerified.value} />
<div class="grid gap-1.5 leading-none">
<Label for="admin-privileges" class="mb-0 text-sm font-medium leading-none">
Emails Verified
</Label>
<p class="text-muted-foreground text-[0.8rem]">
Whether the user's email should be marked as verified for the OIDC clients.
</p>
</div>
</div>
</div> </div>
<div class="mt-5 flex justify-end"> <div class="mt-5 flex justify-end">
<Button {isLoading} type="submit">Save</Button> <Button {isLoading} type="submit">Save</Button>

View File

@@ -26,7 +26,8 @@
'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`,
'Certificate URL': `https://${$page.url.hostname}/.well-known/jwks.json` 'Certificate URL': `https://${$page.url.hostname}/.well-known/jwks.json`,
PKCE: client.isPublic ? 'Enabled' : 'Disabled'
}; };
async function updateClient(updatedClient: OidcClientCreateWithLogo) { async function updateClient(updatedClient: OidcClientCreateWithLogo) {
@@ -34,6 +35,8 @@
const dataPromise = oidcService.updateClient(client.id, updatedClient); const dataPromise = oidcService.updateClient(client.id, updatedClient);
const imagePromise = oidcService.updateClientLogo(client, updatedClient.logo); const imagePromise = oidcService.updateClientLogo(client, updatedClient.logo);
client.isPublic = updatedClient.isPublic;
await Promise.all([dataPromise, imagePromise]) await Promise.all([dataPromise, imagePromise])
.then(() => { .then(() => {
toast.success('OIDC client updated successfully'); toast.success('OIDC client updated successfully');
@@ -93,27 +96,29 @@
<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>
<div class="mb-2 mt-1 flex items-center"> {#if !client.isPublic}
<Label class="w-44">Client secret</Label> <div class="mb-2 mt-1 flex items-center">
{#if $clientSecretStore} <Label class="w-44">Client secret</Label>
<CopyToClipboard value={$clientSecretStore}> {#if $clientSecretStore}
<span class="text-muted-foreground text-sm" data-testid="client-secret"> <CopyToClipboard value={$clientSecretStore}>
{$clientSecretStore} <span class="text-muted-foreground text-sm" data-testid="client-secret">
</span> {$clientSecretStore}
</CopyToClipboard> </span>
{:else} </CopyToClipboard>
<span class="text-muted-foreground text-sm" data-testid="client-secret" {:else}
>••••••••••••••••••••••••••••••••</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
{/if} >
</div> {/if}
</div>
{/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]}

View File

@@ -1,4 +1,5 @@
<script lang="ts"> <script lang="ts">
import CheckboxWithLabel from '$lib/components/checkbox-with-label.svelte';
import FileInput from '$lib/components/file-input.svelte'; import FileInput from '$lib/components/file-input.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';
@@ -28,12 +29,14 @@
const client: OidcClientCreate = { const client: OidcClientCreate = {
name: existingClient?.name || '', name: existingClient?.name || '',
callbackURLs: existingClient?.callbackURLs || [""] callbackURLs: existingClient?.callbackURLs || [''],
isPublic: existingClient?.isPublic || false
}; };
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().url()).nonempty() callbackURLs: z.array(z.string().url()).nonempty(),
isPublic: z.boolean()
}); });
type FormSchema = typeof formSchema; type FormSchema = typeof formSchema;
@@ -71,15 +74,21 @@
</script> </script>
<form onsubmit={onSubmit}> <form onsubmit={onSubmit}>
<div class="flex flex-col gap-3 sm:flex-row"> <div class="grid grid-cols-2 gap-3 sm:flex-row">
<FormInput label="Name" class="w-full" bind:input={$inputs.name} /> <FormInput label="Name" class="w-full" bind:input={$inputs.name} />
<OidcCallbackUrlInput <OidcCallbackUrlInput
class="w-full" class="w-full"
bind:callbackURLs={$inputs.callbackURLs.value} bind:callbackURLs={$inputs.callbackURLs.value}
bind:error={$inputs.callbackURLs.error} bind:error={$inputs.callbackURLs.error}
/> />
<CheckboxWithLabel
id="public-client"
label="Public Client"
description="Public clients do not have a client secret and use PKCE instead. Enable this if your client is a SPA or mobile app."
bind:checked={$inputs.isPublic.value}
/>
</div> </div>
<div class="mt-3"> <div class="mt-8">
<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}

View File

@@ -1,8 +1,7 @@
<script lang="ts"> <script lang="ts">
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';
import { Checkbox } from '$lib/components/ui/checkbox';
import { Label } from '$lib/components/ui/label';
import type { UserCreate } from '$lib/types/user.type'; import type { UserCreate } from '$lib/types/user.type';
import { createForm } from '$lib/utils/form-util'; import { createForm } from '$lib/utils/form-util';
import { z } from 'zod'; import { z } from 'zod';
@@ -70,15 +69,12 @@
<FormInput label="Username" bind:input={$inputs.username} /> <FormInput label="Username" bind:input={$inputs.username} />
</div> </div>
</div> </div>
<div class="items-top mt-5 flex space-x-2"> <CheckboxWithLabel
<Checkbox id="admin-privileges" bind:checked={$inputs.isAdmin.value} /> id="admin-privileges"
<div class="grid gap-1.5 leading-none"> label="Admin Privileges"
<Label for="admin-privileges" class="mb-0 text-sm font-medium leading-none"> description="Admins have full access to the admin panel."
Admin Privileges bind:checked={$inputs.isAdmin.value}
</Label> />
<p class="text-muted-foreground text-[0.8rem]">Admins have full access to the admin panel.</p>
</div>
</div>
<div class="mt-5 flex justify-end"> <div class="mt-5 flex justify-end">
<Button {isLoading} type="submit">Save</Button> <Button {isLoading} type="submit">Save</Button>
</div> </div>

View File

@@ -0,0 +1,28 @@
echo "Creating user and group..."
PUID=${PUID:-1000}
PGID=${PGID:-1000}
# Check if the group with PGID exists; if not, create it
if ! getent group pocket-id-group > /dev/null 2>&1; then
addgroup -g "$PGID" pocket-id-group
fi
# Check if a user with PUID exists; if not, create it
if ! id -u pocket-id > /dev/null 2>&1; then
if ! getent passwd "$PUID" > /dev/null 2>&1; then
adduser -u "$PUID" -G pocket-id-group pocket-id
else
# If a user with the PUID already exists, use that user
existing_user=$(getent passwd "$PUID" | cut -d: -f1)
echo "Using existing user: $existing_user"
fi
fi
# Change ownership of the /app directory
mkdir -p /app/backend/data
find /app/backend/data \( ! -group "${PGID}" -o ! -user "${PUID}" \) -exec chown "${PUID}:${PGID}" {} +
# Switch to the non-root user
exec su-exec "$PUID:$PGID" "$@"