Compare commits

..

28 Commits

Author SHA1 Message Date
Elias Schneider
4010ee27d6 release: 0.4.1 2024-09-06 09:24:42 +02:00
Elias Schneider
4e7574a297 feat: add name claim to userinfo endpoint and id token 2024-09-06 09:19:13 +02:00
Elias Schneider
8038a111dd fix: show error message if error occurs while authorizing new client 2024-09-06 08:58:23 +02:00
Elias Schneider
c6f83a581a fix: limit width of content on large screens 2024-09-06 08:57:48 +02:00
Elias Schneider
8ad632e6c1 release: 0.4.0 2024-09-03 22:42:22 +02:00
Elias Schneider
903b0b3918 feat: add support for more username formats 2024-09-03 22:35:18 +02:00
Elias Schneider
fd21ce5aac feat: add setup details to oidc client details 2024-09-03 22:24:29 +02:00
Elias Schneider
e7861df95a fix: non pointer passed to create user 2024-08-28 08:43:44 +02:00
Elias Schneider
8e27320649 refactor: rename user service 2024-08-28 08:22:27 +02:00
Elias Schneider
2b9413c757 fix: typo in hasLogo property of oidc dto 2024-08-28 08:21:46 +02:00
Elias Schneider
fd5a881cfb Merge branch 'main' of https://github.com/stonith404/pocket-id 2024-08-27 23:27:03 +02:00
Elias Schneider
28ed064668 fix: oidc client logo not displayed on authorize page 2024-08-27 23:26:56 +02:00
Elias Schneider
5446b46b65 Merge pull request #17 from stonith404/imgbot
[ImgBot] Optimize images
2024-08-26 13:59:58 +02:00
ImgBotApp
0ce6045657 [ImgBot] Optimize images
*Total -- 4,659.87kb -> 4,503.61kb (3.35%)

/frontend/tests/assets/nextcloud-logo.png -- 163.47kb -> 87.99kb (46.17%)
/frontend/tests/assets/pingvin-share-logo.png -- 85.61kb -> 58.04kb (32.2%)
/backend/images/logo.svg -- 0.68kb -> 0.53kb (22.56%)
/frontend/tests/assets/clouds.jpg -- 577.56kb -> 528.14kb (8.56%)
/backend/images/background.jpg -- 3,832.55kb -> 3,828.92kb (0.09%)

Signed-off-by: ImgBotApp <ImgBotHelp@gmail.com>
2024-08-26 11:10:18 +00:00
Elias Schneider
3fe24a04de release: 0.3.1 2024-08-24 12:59:23 +02:00
Elias Schneider
6769cc8c10 tests: fix missing host in cleanup request 2024-08-24 12:50:27 +02:00
Elias Schneider
97f7fc4e28 fix: empty lists don't get returned correctly from the api 2024-08-24 12:44:02 +02:00
Elias Schneider
fc47c2a2a4 chore: upgrade dependencies 2024-08-24 12:43:22 +02:00
Elias Schneider
f1a6c8db85 release: 0.3.0 2024-08-24 01:20:18 +02:00
Elias Schneider
552d7ccfa5 fix: db migration for multiple callback urls 2024-08-24 01:12:33 +02:00
Elias Schneider
e45b0b3ed0 Merge branch 'main' of https://github.com/stonith404/pocket-id 2024-08-24 00:49:11 +02:00
Elias Schneider
8166e2ead7 feat: add support for multiple callback urls 2024-08-24 00:49:08 +02:00
Elias Schneider
ae7aeb0945 refactor: use dtos in controllers 2024-08-23 17:04:19 +02:00
Elias Schneider
16f273ffce docs: compress screenshot in README 2024-08-23 16:39:09 +02:00
Elias Schneider
9f49e5577e docs: add proxy guide 2024-08-20 22:40:28 +02:00
Elias Schneider
d92b80b80f release: 0.2.1 2024-08-19 23:16:02 +02:00
Elias Schneider
aaed71e1c8 tests: fix update general configuration test 2024-08-19 23:15:19 +02:00
Elias Schneider
4780548843 fix: session duration can't be updated 2024-08-19 23:10:14 +02:00
63 changed files with 1524 additions and 779 deletions

View File

@@ -1 +1 @@
0.2.0
0.4.1

View File

@@ -1,3 +1,57 @@
## [](https://github.com/stonith404/pocket-id/compare/v0.4.0...v) (2024-09-06)
### Features
* add name claim to userinfo endpoint and id token ([4e7574a](https://github.com/stonith404/pocket-id/commit/4e7574a297307395603267c7a3285d538d4111d8))
### Bug Fixes
* limit width of content on large screens ([c6f83a5](https://github.com/stonith404/pocket-id/commit/c6f83a581ad385391d77fec7eeb385060742f097))
* show error message if error occurs while authorizing new client ([8038a11](https://github.com/stonith404/pocket-id/commit/8038a111dd7fa8f5d421b29c3bc0c11d865dc71b))
## [](https://github.com/stonith404/pocket-id/compare/v0.3.1...v) (2024-09-03)
### Features
* add setup details to oidc client details ([fd21ce5](https://github.com/stonith404/pocket-id/commit/fd21ce5aac1daeba04e4e7399a0720338ea710c2))
* add support for more username formats ([903b0b3](https://github.com/stonith404/pocket-id/commit/903b0b39181c208e9411ee61849d2671e7c56dc5))
### Bug Fixes
* non pointer passed to create user ([e7861df](https://github.com/stonith404/pocket-id/commit/e7861df95a6beecab359d1c56f4383373f74bb73))
* oidc client logo not displayed on authorize page ([28ed064](https://github.com/stonith404/pocket-id/commit/28ed064668afeec8f80adda59ba94f1fc2fbce17))
* typo in hasLogo property of oidc dto ([2b9413c](https://github.com/stonith404/pocket-id/commit/2b9413c7575e1322f8547490a9b02a1836bad549))
## [](https://github.com/stonith404/pocket-id/compare/v0.3.0...v) (2024-08-24)
### Bug Fixes
* empty lists don't get returned correctly from the api ([97f7fc4](https://github.com/stonith404/pocket-id/commit/97f7fc4e288c2bb49210072a7a151b58ef44f5b5))
## [](https://github.com/stonith404/pocket-id/compare/v0.2.1...v) (2024-08-23)
### Features
* add support for multiple callback urls ([8166e2e](https://github.com/stonith404/pocket-id/commit/8166e2ead7fc71a0b7a45950b05c5c65a60833b6))
### Bug Fixes
* db migration for multiple callback urls ([552d7cc](https://github.com/stonith404/pocket-id/commit/552d7ccfa58d7922ecb94bdfe6a86651b4cf2745))
## [](https://github.com/stonith404/pocket-id/compare/v0.2.0...v) (2024-08-19)
### Bug Fixes
* session duration can't be updated ([4780548](https://github.com/stonith404/pocket-id/commit/478054884389ed8a08d707fd82da7b31177a67e5))
## [](https://github.com/stonith404/pocket-id/compare/v0.1.3...v) (2024-08-19)

View File

@@ -8,7 +8,7 @@ RUN npm run build
RUN npm prune --production
# Stage 2: Build Backend
FROM golang:1.22-alpine AS backend-builder
FROM golang:1.23-alpine AS backend-builder
WORKDIR /app/backend
COPY ./backend/go.mod ./backend/go.sum ./
RUN go mod download

View File

@@ -2,7 +2,7 @@
Pocket ID is a simple OIDC provider that allows users to authenticate with their passkeys to your services.
<img src="https://github.com/user-attachments/assets/783dc0c1-1580-476b-9bb1-d9ef1077bc1e" width="1200"/>
<img src="https://github.com/user-attachments/assets/96ac549d-b897-404a-8811-f42b16ea58e2" width="1200"/>
The goal of Pocket ID is to be a simple and easy-to-use. There are other self-hosted OIDC providers like [Keycloak](https://www.keycloak.org/) or [ORY Hydra](https://www.ory.sh/hydra/) but they are often too complex for simple use cases.
@@ -41,7 +41,7 @@ Pocket ID is available as a template on the Community Apps store.
Required tools:
- [Node.js](https://nodejs.org/en/download/) >= 20
- [Go](https://golang.org/doc/install) >= 1.22
- [Go](https://golang.org/doc/install) >= 1.23
- [Git](https://git-scm.com/downloads)
- [PM2](https://pm2.keymetrics.io/)
- [Caddy](https://caddyserver.com/docs/install) (optional)
@@ -91,10 +91,17 @@ You may need the following information:
- **Authorization URL**: `https://<your-domain>/authorize`
- **Token URL**: `https://<your-domain>/api/oidc/token`
- **Userinfo URL**: `https://<your-domain>/api/oidc/userinfo`
- **Certificate URL**: `https://<your-domain>/.well-known/jwks.json`
- **OIDC Discovery URL**: `https://<your-domain>/.well-known/openid-configuration`
- **PKCE**: `false` as this is not supported yet.
### Proxy Services with Pocket ID
As the goal of Pocket ID is to stay simple, we don't have a built-in proxy provider. However, you can use [OAuth2 Proxy](https://oauth2-proxy.github.io/) to add authentication to your services that don't support OIDC.
See the [guide](docs/proxy-services.md) for more information.
### Update
#### Docker

View File

@@ -1,19 +1,20 @@
module github.com/stonith404/pocket-id/backend
go 1.22
go 1.23
require (
github.com/caarlos0/env/v11 v11.2.0
github.com/caarlos0/env/v11 v11.2.2
github.com/fxamacker/cbor/v2 v2.7.0
github.com/gin-contrib/cors v1.7.2
github.com/gin-gonic/gin v1.10.0
github.com/go-co-op/gocron/v2 v2.11.0
github.com/go-webauthn/webauthn v0.11.0
github.com/go-playground/validator/v10 v10.22.0
github.com/go-webauthn/webauthn v0.11.1
github.com/golang-jwt/jwt/v5 v5.2.1
github.com/golang-migrate/migrate/v4 v4.17.1
github.com/google/uuid v1.6.0
github.com/joho/godotenv v1.5.1
golang.org/x/crypto v0.25.0
golang.org/x/crypto v0.26.0
golang.org/x/time v0.6.0
gorm.io/driver/sqlite v1.5.6
gorm.io/gorm v1.25.11
@@ -28,7 +29,6 @@ require (
github.com/gin-contrib/sse v0.1.0 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.22.0 // indirect
github.com/go-webauthn/x v0.1.12 // indirect
github.com/goccy/go-json v0.10.3 // indirect
github.com/google/go-tpm v0.9.1 // indirect
@@ -57,7 +57,7 @@ require (
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 // indirect
golang.org/x/net v0.27.0 // indirect
golang.org/x/sys v0.23.0 // indirect
golang.org/x/text v0.16.0 // indirect
golang.org/x/text v0.17.0 // indirect
google.golang.org/protobuf v1.34.2 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

View File

@@ -3,8 +3,8 @@ github.com/bytedance/sonic v1.12.1/go.mod h1:B8Gt/XvtZ3Fqj+iSKMypzymZxw/FVwgIGKz
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
github.com/bytedance/sonic/loader v0.2.0 h1:zNprn+lsIP06C/IqCHs3gPQIvnvpKbbxyXQP1iU4kWM=
github.com/bytedance/sonic/loader v0.2.0/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
github.com/caarlos0/env/v11 v11.2.0 h1:kvB1ZmwdWgI3JsuuVUE7z4cY/6Ujr03D0w2WkOOH4Xs=
github.com/caarlos0/env/v11 v11.2.0/go.mod h1:LwgkYk1kDvfGpHthrWWLof3Ny7PezzFwS4QrsJdHTMo=
github.com/caarlos0/env/v11 v11.2.2 h1:95fApNrUyueipoZN/EhA8mMxiNxrBwDa+oAZrMWl3Kg=
github.com/caarlos0/env/v11 v11.2.2/go.mod h1:JBfcdeQiBoI3Zh1QRAWfe+tpiNTmDtcCj/hHHHMx0vc=
github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y=
github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg=
@@ -33,8 +33,8 @@ github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJn
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.22.0 h1:k6HsTZ0sTnROkhS//R0O+55JgM8C4Bx7ia+JlgcnOao=
github.com/go-playground/validator/v10 v10.22.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
github.com/go-webauthn/webauthn v0.11.0 h1:2U0jWuGeoiI+XSZkHPFRtwaYtqmMUsqABtlfSq1rODo=
github.com/go-webauthn/webauthn v0.11.0/go.mod h1:57ZrqsZzD/eboQDVtBkvTdfqFYAh/7IwzdPT+sPWqB0=
github.com/go-webauthn/webauthn v0.11.1 h1:5G/+dg91/VcaJHTtJUfwIlNJkLwbJCcnUc4W8VtkpzA=
github.com/go-webauthn/webauthn v0.11.1/go.mod h1:YXRm1WG0OtUyDFaVAgB5KG7kVqW+6dYCJ7FTQH4SxEE=
github.com/go-webauthn/x v0.1.12 h1:RjQ5cvApzyU/xLCiP+rub0PE4HBZsLggbxGR5ZpUf/A=
github.com/go-webauthn/x v0.1.12/go.mod h1:XlRcGkNH8PT45TfeJYc6gqpOtiOendHhVmnOxh+5yHs=
github.com/goccy/go-json v0.10.3 h1:KZ5WoDbxAIgm2HNbYckL0se1fHD6rz5j4ywS6ebzDqA=
@@ -122,8 +122,8 @@ go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
golang.org/x/arch v0.9.0 h1:ub9TgUInamJ8mrZIGlBG6/4TqWeMszd4N8lNorbrr6k=
golang.org/x/arch v0.9.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
golang.org/x/crypto v0.25.0 h1:ypSNr+bnYL2YhwoMt2zPxHFmbAN1KZs/njMG3hxUp30=
golang.org/x/crypto v0.25.0/go.mod h1:T+wALwcMOSE0kXgUAnPAHqTLW+XHgcELELW8VaDgm/M=
golang.org/x/crypto v0.26.0 h1:RrRspgV4mU+YwB4FYnuBoKsUapNIL5cohGAmSH3azsw=
golang.org/x/crypto v0.26.0/go.mod h1:GY7jblb9wI+FOo5y8/S2oY4zWP07AkOJ4+jxCqdqn54=
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0LeHDbnYEryqj5Q1ug8=
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY=
golang.org/x/net v0.27.0 h1:5K3Njcw06/l2y9vpGCSdcxWOYHOUk3dVNGDXN+FvAys=
@@ -132,8 +132,8 @@ golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.23.0 h1:YfKFowiIMvtgl1UERQoTPPToxltDeZfbj4H7dVUCwmM=
golang.org/x/sys v0.23.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4=
golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI=
golang.org/x/text v0.17.0 h1:XtiM5bkSOt+ewxlOE/aE/AKEHibwj/6gvWMl9Rsh0Qc=
golang.org/x/text v0.17.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY=
golang.org/x/time v0.6.0 h1:eTDhh4ZXt5Qf0augr54TN6suAUudPcawVZeIAPU7D4U=
golang.org/x/time v0.6.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg=

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.7 MiB

After

Width:  |  Height:  |  Size: 3.7 MiB

View File

@@ -1,17 +1 @@
<svg id="a"
xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1015 1015">
<path d="M506.6,0c209.52,0,379.98,170.45,379.98,379.96,0,82.33-25.9,160.68-74.91,226.54-48.04,64.59-113.78,111.51-190.13,135.71l-21.1,6.7-50.29-248.04,13.91-6.73c45.41-21.95,74.76-68.71,74.76-119.11,0-72.91-59.31-132.23-132.21-132.23s-132.23,59.32-132.23,132.23c0,50.4,29.36,97.16,74.77,119.11l13.65,6.61-81.01,499.24h-226.36V0h351.18Z" />
<style>
@media (prefers-color-scheme: dark) {
#a path {
fill: #ffffff;
}
}
@media (prefers-color-scheme: light) {
#a path {
fill: #000000;
}
}
</style>
</svg>
<svg xmlns="http://www.w3.org/2000/svg" id="a" viewBox="0 0 1015 1015"><path d="M506.6,0c209.52,0,379.98,170.45,379.98,379.96,0,82.33-25.9,160.68-74.91,226.54-48.04,64.59-113.78,111.51-190.13,135.71l-21.1,6.7-50.29-248.04,13.91-6.73c45.41-21.95,74.76-68.71,74.76-119.11,0-72.91-59.31-132.23-132.21-132.23s-132.23,59.32-132.23,132.23c0,50.4,29.36,97.16,74.77,119.11l13.65,6.61-81.01,499.24h-226.36V0h351.18Z"/><style>@media (prefers-color-scheme:dark){#a path{fill:#fff}}@media (prefers-color-scheme:light){#a path{fill:#000}}</style></svg>

Before

Width:  |  Height:  |  Size: 696 B

After

Width:  |  Height:  |  Size: 539 B

View File

@@ -27,12 +27,6 @@ func initRouter(db *gorm.DB, appConfigService *service.AppConfigService) {
r := gin.Default()
r.Use(gin.Logger())
// Add middleware
r.Use(
middleware.NewCorsMiddleware().Add(),
middleware.NewRateLimitMiddleware().Add(rate.Every(time.Second), 60),
)
// Initialize services
webauthnService := service.NewWebAuthnService(db, appConfigService)
jwtService := service.NewJwtService(appConfigService)
@@ -40,8 +34,13 @@ func initRouter(db *gorm.DB, appConfigService *service.AppConfigService) {
oidcService := service.NewOidcService(db, jwtService)
testService := service.NewTestService(db, appConfigService)
// Add global middleware
r.Use(middleware.NewCorsMiddleware().Add())
r.Use(middleware.NewRateLimitMiddleware().Add(rate.Every(time.Second), 60))
r.Use(middleware.NewJwtAuthMiddleware(jwtService, true).Add(false))
// Initialize middleware
jwtAuthMiddleware := middleware.NewJwtAuthMiddleware(jwtService)
jwtAuthMiddleware := middleware.NewJwtAuthMiddleware(jwtService, false)
fileSizeLimitMiddleware := middleware.NewFileSizeLimitMiddleware()
// Set up API routes
@@ -49,7 +48,7 @@ func initRouter(db *gorm.DB, appConfigService *service.AppConfigService) {
controller.NewWebauthnController(apiGroup, jwtAuthMiddleware, middleware.NewRateLimitMiddleware(), webauthnService, jwtService)
controller.NewOidcController(apiGroup, jwtAuthMiddleware, fileSizeLimitMiddleware, oidcService, jwtService)
controller.NewUserController(apiGroup, jwtAuthMiddleware, middleware.NewRateLimitMiddleware(), userService)
controller.NewApplicationConfigurationController(apiGroup, jwtAuthMiddleware, appConfigService)
controller.NewAppConfigController(apiGroup, jwtAuthMiddleware, appConfigService)
// Add test controller in non-production environments
if common.EnvConfig.AppEnv != "production" {

View File

@@ -6,13 +6,13 @@ var (
ErrUsernameTaken = errors.New("username is already taken")
ErrEmailTaken = errors.New("email is already taken")
ErrSetupAlreadyCompleted = errors.New("setup already completed")
ErrInvalidBody = errors.New("invalid request body")
ErrTokenInvalidOrExpired = errors.New("token is invalid or expired")
ErrOidcMissingAuthorization = errors.New("missing authorization")
ErrOidcGrantTypeNotSupported = errors.New("grant type not supported")
ErrOidcMissingClientCredentials = errors.New("client id or secret not provided")
ErrOidcClientSecretInvalid = errors.New("invalid client secret")
ErrOidcInvalidAuthorizationCode = errors.New("invalid authorization code")
ErrOidcInvalidCallbackURL = errors.New("invalid callback URL")
ErrFileTypeNotSupported = errors.New("file type not supported")
ErrInvalidCredentials = errors.New("no user found with provided credentials")
)

View File

@@ -5,19 +5,19 @@ import (
"fmt"
"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/middleware"
"github.com/stonith404/pocket-id/backend/internal/model"
"github.com/stonith404/pocket-id/backend/internal/service"
"github.com/stonith404/pocket-id/backend/internal/utils"
"net/http"
)
func NewApplicationConfigurationController(
func NewAppConfigController(
group *gin.RouterGroup,
jwtAuthMiddleware *middleware.JwtAuthMiddleware,
appConfigService *service.AppConfigService) {
acc := &ApplicationConfigurationController{
acc := &AppConfigController{
appConfigService: appConfigService,
}
group.GET("/application-configuration", acc.listApplicationConfigurationHandler)
@@ -32,86 +32,104 @@ func NewApplicationConfigurationController(
group.PUT("/application-configuration/background-image", jwtAuthMiddleware.Add(true), acc.updateBackgroundImageHandler)
}
type ApplicationConfigurationController struct {
type AppConfigController struct {
appConfigService *service.AppConfigService
}
func (acc *ApplicationConfigurationController) listApplicationConfigurationHandler(c *gin.Context) {
func (acc *AppConfigController) listApplicationConfigurationHandler(c *gin.Context) {
configuration, err := acc.appConfigService.ListApplicationConfiguration(false)
if err != nil {
utils.UnknownHandlerError(c, err)
utils.ControllerError(c, err)
return
}
c.JSON(200, configuration)
var configVariablesDto []dto.PublicAppConfigVariableDto
if err := dto.MapStructList(configuration, &configVariablesDto); err != nil {
utils.ControllerError(c, err)
return
}
c.JSON(200, configVariablesDto)
}
func (acc *ApplicationConfigurationController) listAllApplicationConfigurationHandler(c *gin.Context) {
func (acc *AppConfigController) listAllApplicationConfigurationHandler(c *gin.Context) {
configuration, err := acc.appConfigService.ListApplicationConfiguration(true)
if err != nil {
utils.UnknownHandlerError(c, err)
utils.ControllerError(c, err)
return
}
c.JSON(200, configuration)
var configVariablesDto []dto.AppConfigVariableDto
if err := dto.MapStructList(configuration, &configVariablesDto); err != nil {
utils.ControllerError(c, err)
return
}
c.JSON(200, configVariablesDto)
}
func (acc *ApplicationConfigurationController) updateApplicationConfigurationHandler(c *gin.Context) {
var input model.AppConfigUpdateDto
func (acc *AppConfigController) updateApplicationConfigurationHandler(c *gin.Context) {
var input dto.AppConfigUpdateDto
if err := c.ShouldBindJSON(&input); err != nil {
utils.HandlerError(c, http.StatusBadRequest, common.ErrInvalidBody.Error())
utils.ControllerError(c, err)
return
}
savedConfigVariables, err := acc.appConfigService.UpdateApplicationConfiguration(input)
if err != nil {
utils.UnknownHandlerError(c, err)
utils.ControllerError(c, err)
return
}
c.JSON(http.StatusOK, savedConfigVariables)
var configVariablesDto []dto.AppConfigVariableDto
if err := dto.MapStructList(savedConfigVariables, &configVariablesDto); err != nil {
utils.ControllerError(c, err)
return
}
c.JSON(http.StatusOK, configVariablesDto)
}
func (acc *ApplicationConfigurationController) getLogoHandler(c *gin.Context) {
func (acc *AppConfigController) getLogoHandler(c *gin.Context) {
imageType := acc.appConfigService.DbConfig.LogoImageType.Value
acc.getImage(c, "logo", imageType)
}
func (acc *ApplicationConfigurationController) getFaviconHandler(c *gin.Context) {
func (acc *AppConfigController) getFaviconHandler(c *gin.Context) {
acc.getImage(c, "favicon", "ico")
}
func (acc *ApplicationConfigurationController) getBackgroundImageHandler(c *gin.Context) {
func (acc *AppConfigController) getBackgroundImageHandler(c *gin.Context) {
imageType := acc.appConfigService.DbConfig.BackgroundImageType.Value
acc.getImage(c, "background", imageType)
}
func (acc *ApplicationConfigurationController) updateLogoHandler(c *gin.Context) {
func (acc *AppConfigController) updateLogoHandler(c *gin.Context) {
imageType := acc.appConfigService.DbConfig.LogoImageType.Value
acc.updateImage(c, "logo", imageType)
}
func (acc *ApplicationConfigurationController) updateFaviconHandler(c *gin.Context) {
func (acc *AppConfigController) updateFaviconHandler(c *gin.Context) {
file, err := c.FormFile("file")
if err != nil {
utils.HandlerError(c, http.StatusBadRequest, common.ErrInvalidBody.Error())
utils.ControllerError(c, err)
return
}
fileType := utils.GetFileExtension(file.Filename)
if fileType != "ico" {
utils.HandlerError(c, http.StatusBadRequest, "File must be of type .ico")
utils.CustomControllerError(c, http.StatusBadRequest, "File must be of type .ico")
return
}
acc.updateImage(c, "favicon", "ico")
}
func (acc *ApplicationConfigurationController) updateBackgroundImageHandler(c *gin.Context) {
func (acc *AppConfigController) updateBackgroundImageHandler(c *gin.Context) {
imageType := acc.appConfigService.DbConfig.BackgroundImageType.Value
acc.updateImage(c, "background", imageType)
}
func (acc *ApplicationConfigurationController) getImage(c *gin.Context, name string, imageType string) {
func (acc *AppConfigController) getImage(c *gin.Context, name string, imageType string) {
imagePath := fmt.Sprintf("%s/application-images/%s.%s", common.EnvConfig.UploadPath, name, imageType)
mimeType := utils.GetImageMimeType(imageType)
@@ -119,19 +137,19 @@ func (acc *ApplicationConfigurationController) getImage(c *gin.Context, name str
c.File(imagePath)
}
func (acc *ApplicationConfigurationController) updateImage(c *gin.Context, imageName string, oldImageType string) {
func (acc *AppConfigController) updateImage(c *gin.Context, imageName string, oldImageType string) {
file, err := c.FormFile("file")
if err != nil {
utils.HandlerError(c, http.StatusBadRequest, common.ErrInvalidBody.Error())
utils.ControllerError(c, err)
return
}
err = acc.appConfigService.UpdateImage(file, imageName, oldImageType)
if err != nil {
if errors.Is(err, common.ErrFileTypeNotSupported) {
utils.HandlerError(c, http.StatusBadRequest, err.Error())
utils.CustomControllerError(c, http.StatusBadRequest, err.Error())
} else {
utils.UnknownHandlerError(c, err)
utils.ControllerError(c, err)
}
return
}

View File

@@ -4,8 +4,8 @@ import (
"errors"
"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/middleware"
"github.com/stonith404/pocket-id/backend/internal/model"
"github.com/stonith404/pocket-id/backend/internal/service"
"github.com/stonith404/pocket-id/backend/internal/utils"
"net/http"
@@ -40,71 +40,87 @@ type OidcController struct {
}
func (oc *OidcController) authorizeHandler(c *gin.Context) {
var parsedBody model.AuthorizeRequest
if err := c.ShouldBindJSON(&parsedBody); err != nil {
utils.HandlerError(c, http.StatusBadRequest, common.ErrInvalidBody.Error())
var input dto.AuthorizeOidcClientRequestDto
if err := c.ShouldBindJSON(&input); err != nil {
utils.ControllerError(c, err)
return
}
code, err := oc.oidcService.Authorize(parsedBody, c.GetString("userID"))
code, callbackURL, err := oc.oidcService.Authorize(input, c.GetString("userID"))
if err != nil {
if errors.Is(err, common.ErrOidcMissingAuthorization) {
utils.HandlerError(c, http.StatusForbidden, err.Error())
utils.CustomControllerError(c, http.StatusForbidden, err.Error())
} else if errors.Is(err, common.ErrOidcInvalidCallbackURL) {
utils.CustomControllerError(c, http.StatusBadRequest, err.Error())
} else {
utils.UnknownHandlerError(c, err)
utils.ControllerError(c, err)
}
return
}
c.JSON(http.StatusOK, gin.H{"code": code})
response := dto.AuthorizeOidcClientResponseDto{
Code: code,
CallbackURL: callbackURL,
}
c.JSON(http.StatusOK, response)
}
func (oc *OidcController) authorizeNewClientHandler(c *gin.Context) {
var parsedBody model.AuthorizeNewClientDto
if err := c.ShouldBindJSON(&parsedBody); err != nil {
utils.HandlerError(c, http.StatusBadRequest, common.ErrInvalidBody.Error())
var input dto.AuthorizeOidcClientRequestDto
if err := c.ShouldBindJSON(&input); err != nil {
utils.ControllerError(c, err)
return
}
code, err := oc.oidcService.AuthorizeNewClient(parsedBody, c.GetString("userID"))
code, callbackURL, err := oc.oidcService.AuthorizeNewClient(input, c.GetString("userID"))
if err != nil {
utils.UnknownHandlerError(c, err)
if errors.Is(err, common.ErrOidcInvalidCallbackURL) {
utils.CustomControllerError(c, http.StatusBadRequest, err.Error())
} else {
utils.ControllerError(c, err)
}
return
}
c.JSON(http.StatusOK, gin.H{"code": code})
response := dto.AuthorizeOidcClientResponseDto{
Code: code,
CallbackURL: callbackURL,
}
c.JSON(http.StatusOK, response)
}
func (oc *OidcController) createIDTokenHandler(c *gin.Context) {
var body model.OidcIdTokenDto
var input dto.OidcIdTokenDto
if err := c.ShouldBind(&body); err != nil {
utils.HandlerError(c, http.StatusBadRequest, common.ErrInvalidBody.Error())
if err := c.ShouldBind(&input); err != nil {
utils.ControllerError(c, err)
return
}
clientID := body.ClientID
clientSecret := body.ClientSecret
clientID := input.ClientID
clientSecret := input.ClientSecret
// Client id and secret can also be passed over the Authorization header
if clientID == "" || clientSecret == "" {
var ok bool
clientID, clientSecret, ok = c.Request.BasicAuth()
if !ok {
utils.HandlerError(c, http.StatusBadRequest, "Client id and secret not provided")
utils.CustomControllerError(c, http.StatusBadRequest, "Client id and secret not provided")
return
}
}
idToken, accessToken, err := oc.oidcService.CreateTokens(body.Code, body.GrantType, clientID, clientSecret)
idToken, accessToken, err := oc.oidcService.CreateTokens(input.Code, input.GrantType, clientID, clientSecret)
if err != nil {
if errors.Is(err, common.ErrOidcGrantTypeNotSupported) ||
errors.Is(err, common.ErrOidcMissingClientCredentials) ||
errors.Is(err, common.ErrOidcClientSecretInvalid) ||
errors.Is(err, common.ErrOidcInvalidAuthorizationCode) {
utils.HandlerError(c, http.StatusBadRequest, err.Error())
utils.CustomControllerError(c, http.StatusBadRequest, err.Error())
} else {
utils.UnknownHandlerError(c, err)
utils.ControllerError(c, err)
}
return
}
@@ -116,14 +132,14 @@ func (oc *OidcController) userInfoHandler(c *gin.Context) {
token := strings.Split(c.GetHeader("Authorization"), " ")[1]
jwtClaims, err := oc.jwtService.VerifyOauthAccessToken(token)
if err != nil {
utils.HandlerError(c, http.StatusUnauthorized, common.ErrTokenInvalidOrExpired.Error())
utils.CustomControllerError(c, http.StatusUnauthorized, common.ErrTokenInvalidOrExpired.Error())
return
}
userID := jwtClaims.Subject
clientId := jwtClaims.Audience[0]
claims, err := oc.oidcService.GetUserClaimsForClient(userID, clientId)
if err != nil {
utils.UnknownHandlerError(c, err)
utils.ControllerError(c, err)
return
}
@@ -134,11 +150,28 @@ func (oc *OidcController) getClientHandler(c *gin.Context) {
clientId := c.Param("id")
client, err := oc.oidcService.GetClient(clientId)
if err != nil {
utils.UnknownHandlerError(c, err)
utils.ControllerError(c, err)
return
}
c.JSON(http.StatusOK, client)
// Return a different DTO based on the user's role
if c.GetBool("userIsAdmin") {
clientDto := dto.OidcClientDto{}
err = dto.MapStruct(client, &clientDto)
if err == nil {
c.JSON(http.StatusOK, clientDto)
return
}
} else {
clientDto := dto.PublicOidcClientDto{}
err = dto.MapStruct(client, &clientDto)
if err == nil {
c.JSON(http.StatusOK, clientDto)
return
}
}
utils.ControllerError(c, err)
}
func (oc *OidcController) listClientsHandler(c *gin.Context) {
@@ -148,36 +181,48 @@ func (oc *OidcController) listClientsHandler(c *gin.Context) {
clients, pagination, err := oc.oidcService.ListClients(searchTerm, page, pageSize)
if err != nil {
utils.UnknownHandlerError(c, err)
utils.ControllerError(c, err)
return
}
var clientsDto []dto.OidcClientDto
if err := dto.MapStructList(clients, &clientsDto); err != nil {
utils.ControllerError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{
"data": clients,
"data": clientsDto,
"pagination": pagination,
})
}
func (oc *OidcController) createClientHandler(c *gin.Context) {
var input model.OidcClientCreateDto
var input dto.OidcClientCreateDto
if err := c.ShouldBindJSON(&input); err != nil {
utils.HandlerError(c, http.StatusBadRequest, common.ErrInvalidBody.Error())
utils.ControllerError(c, err)
return
}
client, err := oc.oidcService.CreateClient(input, c.GetString("userID"))
if err != nil {
utils.UnknownHandlerError(c, err)
utils.ControllerError(c, err)
return
}
c.JSON(http.StatusCreated, client)
var clientDto dto.OidcClientDto
if err := dto.MapStruct(client, &clientDto); err != nil {
utils.ControllerError(c, err)
return
}
c.JSON(http.StatusCreated, clientDto)
}
func (oc *OidcController) deleteClientHandler(c *gin.Context) {
err := oc.oidcService.DeleteClient(c.Param("id"))
if err != nil {
utils.HandlerError(c, http.StatusNotFound, "OIDC client not found")
utils.ControllerError(c, err)
return
}
@@ -185,25 +230,31 @@ func (oc *OidcController) deleteClientHandler(c *gin.Context) {
}
func (oc *OidcController) updateClientHandler(c *gin.Context) {
var input model.OidcClientCreateDto
var input dto.OidcClientCreateDto
if err := c.ShouldBindJSON(&input); err != nil {
utils.HandlerError(c, http.StatusBadRequest, common.ErrInvalidBody.Error())
utils.ControllerError(c, err)
return
}
client, err := oc.oidcService.UpdateClient(c.Param("id"), input)
if err != nil {
utils.UnknownHandlerError(c, err)
utils.ControllerError(c, err)
return
}
c.JSON(http.StatusNoContent, client)
var clientDto dto.OidcClientDto
if err := dto.MapStruct(client, &clientDto); err != nil {
utils.ControllerError(c, err)
return
}
c.JSON(http.StatusOK, clientDto)
}
func (oc *OidcController) createClientSecretHandler(c *gin.Context) {
secret, err := oc.oidcService.CreateClientSecret(c.Param("id"))
if err != nil {
utils.UnknownHandlerError(c, err)
utils.ControllerError(c, err)
return
}
@@ -213,7 +264,7 @@ func (oc *OidcController) createClientSecretHandler(c *gin.Context) {
func (oc *OidcController) getClientLogoHandler(c *gin.Context) {
imagePath, mimeType, err := oc.oidcService.GetClientLogo(c.Param("id"))
if err != nil {
utils.UnknownHandlerError(c, err)
utils.ControllerError(c, err)
return
}
@@ -224,16 +275,16 @@ func (oc *OidcController) getClientLogoHandler(c *gin.Context) {
func (oc *OidcController) updateClientLogoHandler(c *gin.Context) {
file, err := c.FormFile("file")
if err != nil {
utils.HandlerError(c, http.StatusBadRequest, common.ErrInvalidBody.Error())
utils.ControllerError(c, err)
return
}
err = oc.oidcService.UpdateClientLogo(c.Param("id"), file)
if err != nil {
if errors.Is(err, common.ErrFileTypeNotSupported) {
utils.HandlerError(c, http.StatusBadRequest, err.Error())
utils.CustomControllerError(c, http.StatusBadRequest, err.Error())
} else {
utils.UnknownHandlerError(c, err)
utils.ControllerError(c, err)
}
return
}
@@ -244,7 +295,7 @@ func (oc *OidcController) updateClientLogoHandler(c *gin.Context) {
func (oc *OidcController) deleteClientLogoHandler(c *gin.Context) {
err := oc.oidcService.DeleteClientLogo(c.Param("id"))
if err != nil {
utils.UnknownHandlerError(c, err)
utils.ControllerError(c, err)
return
}

View File

@@ -4,6 +4,7 @@ import (
"github.com/gin-gonic/gin"
"github.com/stonith404/pocket-id/backend/internal/service"
"github.com/stonith404/pocket-id/backend/internal/utils"
"net/http"
)
func NewTestController(group *gin.RouterGroup, testService *service.TestService) {
@@ -18,19 +19,19 @@ type TestController struct {
func (tc *TestController) resetAndSeedHandler(c *gin.Context) {
if err := tc.TestService.ResetDatabase(); err != nil {
utils.UnknownHandlerError(c, err)
utils.ControllerError(c, err)
return
}
if err := tc.TestService.ResetApplicationImages(); err != nil {
utils.UnknownHandlerError(c, err)
utils.ControllerError(c, err)
return
}
if err := tc.TestService.SeedDatabase(); err != nil {
utils.UnknownHandlerError(c, err)
utils.ControllerError(c, err)
return
}
c.JSON(200, gin.H{"message": "Database reset and seeded"})
c.Status(http.StatusNoContent)
}

View File

@@ -4,8 +4,8 @@ import (
"errors"
"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/middleware"
"github.com/stonith404/pocket-id/backend/internal/model"
"github.com/stonith404/pocket-id/backend/internal/service"
"github.com/stonith404/pocket-id/backend/internal/utils"
"golang.org/x/time/rate"
@@ -43,12 +43,18 @@ func (uc *UserController) listUsersHandler(c *gin.Context) {
users, pagination, err := uc.UserService.ListUsers(searchTerm, page, pageSize)
if err != nil {
utils.UnknownHandlerError(c, err)
utils.ControllerError(c, err)
return
}
var usersDto []dto.UserDto
if err := dto.MapStructList(users, &usersDto); err != nil {
utils.ControllerError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{
"data": users,
"data": usersDto,
"pagination": pagination,
})
}
@@ -56,25 +62,38 @@ func (uc *UserController) listUsersHandler(c *gin.Context) {
func (uc *UserController) getUserHandler(c *gin.Context) {
user, err := uc.UserService.GetUser(c.Param("id"))
if err != nil {
utils.UnknownHandlerError(c, err)
utils.ControllerError(c, err)
return
}
c.JSON(http.StatusOK, user)
var userDto dto.UserDto
if err := dto.MapStruct(user, &userDto); err != nil {
utils.ControllerError(c, err)
return
}
c.JSON(http.StatusOK, userDto)
}
func (uc *UserController) getCurrentUserHandler(c *gin.Context) {
user, err := uc.UserService.GetUser(c.GetString("userID"))
if err != nil {
utils.UnknownHandlerError(c, err)
utils.ControllerError(c, err)
return
}
c.JSON(http.StatusOK, user)
var userDto dto.UserDto
if err := dto.MapStruct(user, &userDto); err != nil {
utils.ControllerError(c, err)
return
}
c.JSON(http.StatusOK, userDto)
}
func (uc *UserController) deleteUserHandler(c *gin.Context) {
if err := uc.UserService.DeleteUser(c.Param("id")); err != nil {
utils.UnknownHandlerError(c, err)
utils.ControllerError(c, err)
return
}
@@ -82,22 +101,29 @@ func (uc *UserController) deleteUserHandler(c *gin.Context) {
}
func (uc *UserController) createUserHandler(c *gin.Context) {
var user model.User
if err := c.ShouldBindJSON(&user); err != nil {
utils.HandlerError(c, http.StatusBadRequest, common.ErrInvalidBody.Error())
var input dto.UserCreateDto
if err := c.ShouldBindJSON(&input); err != nil {
utils.ControllerError(c, err)
return
}
if err := uc.UserService.CreateUser(&user); err != nil {
user, err := uc.UserService.CreateUser(input)
if err != nil {
if errors.Is(err, common.ErrEmailTaken) || errors.Is(err, common.ErrUsernameTaken) {
utils.HandlerError(c, http.StatusConflict, err.Error())
utils.CustomControllerError(c, http.StatusConflict, err.Error())
} else {
utils.UnknownHandlerError(c, err)
utils.ControllerError(c, err)
}
return
}
c.JSON(http.StatusCreated, user)
var userDto dto.UserDto
if err := dto.MapStruct(user, &userDto); err != nil {
utils.ControllerError(c, err)
return
}
c.JSON(http.StatusCreated, userDto)
}
func (uc *UserController) updateUserHandler(c *gin.Context) {
@@ -109,15 +135,15 @@ func (uc *UserController) updateCurrentUserHandler(c *gin.Context) {
}
func (uc *UserController) createOneTimeAccessTokenHandler(c *gin.Context) {
var input model.OneTimeAccessTokenCreateDto
var input dto.OneTimeAccessTokenCreateDto
if err := c.ShouldBindJSON(&input); err != nil {
utils.HandlerError(c, http.StatusBadRequest, common.ErrInvalidBody.Error())
utils.ControllerError(c, err)
return
}
token, err := uc.UserService.CreateOneTimeAccessToken(input.UserID, input.ExpiresAt)
if err != nil {
utils.UnknownHandlerError(c, err)
utils.ControllerError(c, err)
return
}
@@ -128,9 +154,9 @@ func (uc *UserController) exchangeOneTimeAccessTokenHandler(c *gin.Context) {
user, token, err := uc.UserService.ExchangeOneTimeAccessToken(c.Param("token"))
if err != nil {
if errors.Is(err, common.ErrTokenInvalidOrExpired) {
utils.HandlerError(c, http.StatusUnauthorized, err.Error())
utils.CustomControllerError(c, http.StatusUnauthorized, err.Error())
} else {
utils.UnknownHandlerError(c, err)
utils.ControllerError(c, err)
}
return
}
@@ -143,21 +169,27 @@ func (uc *UserController) getSetupAccessTokenHandler(c *gin.Context) {
user, token, err := uc.UserService.SetupInitialAdmin()
if err != nil {
if errors.Is(err, common.ErrSetupAlreadyCompleted) {
utils.HandlerError(c, http.StatusBadRequest, err.Error())
utils.CustomControllerError(c, http.StatusBadRequest, err.Error())
} else {
utils.UnknownHandlerError(c, err)
utils.ControllerError(c, err)
}
return
}
var userDto dto.UserDto
if err := dto.MapStruct(user, &userDto); err != nil {
utils.ControllerError(c, err)
return
}
c.SetCookie("access_token", token, int(time.Hour.Seconds()), "/", "", false, true)
c.JSON(http.StatusOK, user)
c.JSON(http.StatusOK, userDto)
}
func (uc *UserController) updateUser(c *gin.Context, updateOwnUser bool) {
var updatedUser model.User
if err := c.ShouldBindJSON(&updatedUser); err != nil {
utils.HandlerError(c, http.StatusBadRequest, common.ErrInvalidBody.Error())
var input dto.UserCreateDto
if err := c.ShouldBindJSON(&input); err != nil {
utils.ControllerError(c, err)
return
}
@@ -168,15 +200,21 @@ func (uc *UserController) updateUser(c *gin.Context, updateOwnUser bool) {
userID = c.Param("id")
}
user, err := uc.UserService.UpdateUser(userID, updatedUser, updateOwnUser)
user, err := uc.UserService.UpdateUser(userID, input, updateOwnUser)
if err != nil {
if errors.Is(err, common.ErrEmailTaken) || errors.Is(err, common.ErrUsernameTaken) {
utils.HandlerError(c, http.StatusConflict, err.Error())
utils.CustomControllerError(c, http.StatusConflict, err.Error())
} else {
utils.UnknownHandlerError(c, err)
utils.ControllerError(c, err)
}
return
}
c.JSON(http.StatusOK, user)
var userDto dto.UserDto
if err := dto.MapStruct(user, &userDto); err != nil {
utils.ControllerError(c, err)
return
}
c.JSON(http.StatusOK, userDto)
}

View File

@@ -3,9 +3,8 @@ package controller
import (
"errors"
"github.com/go-webauthn/webauthn/protocol"
"github.com/stonith404/pocket-id/backend/internal/dto"
"github.com/stonith404/pocket-id/backend/internal/middleware"
"github.com/stonith404/pocket-id/backend/internal/model"
"log"
"net/http"
"time"
@@ -40,8 +39,7 @@ func (wc *WebauthnController) beginRegistrationHandler(c *gin.Context) {
userID := c.GetString("userID")
options, err := wc.webAuthnService.BeginRegistration(userID)
if err != nil {
utils.UnknownHandlerError(c, err)
log.Println(err)
utils.ControllerError(c, err)
return
}
@@ -52,24 +50,30 @@ func (wc *WebauthnController) beginRegistrationHandler(c *gin.Context) {
func (wc *WebauthnController) verifyRegistrationHandler(c *gin.Context) {
sessionID, err := c.Cookie("session_id")
if err != nil {
utils.HandlerError(c, http.StatusBadRequest, "Session ID missing")
utils.CustomControllerError(c, http.StatusBadRequest, "Session ID missing")
return
}
userID := c.GetString("userID")
credential, err := wc.webAuthnService.VerifyRegistration(sessionID, userID, c.Request)
if err != nil {
utils.UnknownHandlerError(c, err)
utils.ControllerError(c, err)
return
}
c.JSON(http.StatusOK, credential)
var credentialDto dto.WebauthnCredentialDto
if err := dto.MapStruct(credential, &credentialDto); err != nil {
utils.ControllerError(c, err)
return
}
c.JSON(http.StatusOK, credentialDto)
}
func (wc *WebauthnController) beginLoginHandler(c *gin.Context) {
options, err := wc.webAuthnService.BeginLogin()
if err != nil {
utils.UnknownHandlerError(c, err)
utils.ControllerError(c, err)
return
}
@@ -80,13 +84,13 @@ func (wc *WebauthnController) beginLoginHandler(c *gin.Context) {
func (wc *WebauthnController) verifyLoginHandler(c *gin.Context) {
sessionID, err := c.Cookie("session_id")
if err != nil {
utils.HandlerError(c, http.StatusBadRequest, "Session ID missing")
utils.CustomControllerError(c, http.StatusBadRequest, "Session ID missing")
return
}
credentialAssertionData, err := protocol.ParseCredentialRequestResponseBody(c.Request.Body)
if err != nil {
utils.HandlerError(c, http.StatusBadRequest, common.ErrInvalidBody.Error())
utils.ControllerError(c, err)
return
}
@@ -94,32 +98,44 @@ func (wc *WebauthnController) verifyLoginHandler(c *gin.Context) {
user, err := wc.webAuthnService.VerifyLogin(sessionID, userID, credentialAssertionData)
if err != nil {
if errors.Is(err, common.ErrInvalidCredentials) {
utils.HandlerError(c, http.StatusUnauthorized, err.Error())
utils.CustomControllerError(c, http.StatusUnauthorized, err.Error())
} else {
utils.UnknownHandlerError(c, err)
utils.ControllerError(c, err)
}
return
}
token, err := wc.jwtService.GenerateAccessToken(*user)
token, err := wc.jwtService.GenerateAccessToken(user)
if err != nil {
utils.UnknownHandlerError(c, err)
utils.ControllerError(c, err)
return
}
var userDto dto.UserDto
if err := dto.MapStruct(user, &userDto); err != nil {
utils.ControllerError(c, err)
return
}
c.SetCookie("access_token", token, int(time.Hour.Seconds()), "/", "", false, true)
c.JSON(http.StatusOK, user)
c.JSON(http.StatusOK, userDto)
}
func (wc *WebauthnController) listCredentialsHandler(c *gin.Context) {
userID := c.GetString("userID")
credentials, err := wc.webAuthnService.ListCredentials(userID)
if err != nil {
utils.UnknownHandlerError(c, err)
utils.ControllerError(c, err)
return
}
c.JSON(http.StatusOK, credentials)
var credentialDtos []dto.WebauthnCredentialDto
if err := dto.MapStructList(credentials, &credentialDtos); err != nil {
utils.ControllerError(c, err)
return
}
c.JSON(http.StatusOK, credentialDtos)
}
func (wc *WebauthnController) deleteCredentialHandler(c *gin.Context) {
@@ -128,7 +144,7 @@ func (wc *WebauthnController) deleteCredentialHandler(c *gin.Context) {
err := wc.webAuthnService.DeleteCredential(userID, credentialID)
if err != nil {
utils.UnknownHandlerError(c, err)
utils.ControllerError(c, err)
return
}
@@ -139,19 +155,25 @@ func (wc *WebauthnController) updateCredentialHandler(c *gin.Context) {
userID := c.GetString("userID")
credentialID := c.Param("id")
var input model.WebauthnCredentialUpdateDto
var input dto.WebauthnCredentialUpdateDto
if err := c.ShouldBindJSON(&input); err != nil {
utils.HandlerError(c, http.StatusBadRequest, common.ErrInvalidBody.Error())
utils.ControllerError(c, err)
return
}
err := wc.webAuthnService.UpdateCredential(userID, credentialID, input.Name)
credential, err := wc.webAuthnService.UpdateCredential(userID, credentialID, input.Name)
if err != nil {
utils.UnknownHandlerError(c, err)
utils.ControllerError(c, err)
return
}
c.Status(http.StatusNoContent)
var credentialDto dto.WebauthnCredentialDto
if err := dto.MapStruct(credential, &credentialDto); err != nil {
utils.ControllerError(c, err)
return
}
c.JSON(http.StatusOK, credentialDto)
}
func (wc *WebauthnController) logoutHandler(c *gin.Context) {

View File

@@ -21,7 +21,7 @@ type WellKnownController struct {
func (wkc *WellKnownController) jwksHandler(c *gin.Context) {
jwk, err := wkc.jwtService.GetJWK()
if err != nil {
utils.UnknownHandlerError(c, err)
utils.ControllerError(c, err)
return
}
@@ -37,7 +37,7 @@ func (wkc *WellKnownController) openIDConfigurationHandler(c *gin.Context) {
"userinfo_endpoint": appUrl + "/api/oidc/userinfo",
"jwks_uri": appUrl + "/.well-known/jwks.json",
"scopes_supported": []string{"openid", "profile", "email"},
"claims_supported": []string{"sub", "given_name", "family_name", "email", "preferred_username"},
"claims_supported": []string{"sub", "given_name", "family_name", "name", "email", "preferred_username"},
"response_types_supported": []string{"code", "id_token"},
"subject_types_supported": []string{"public"},
"id_token_signing_alg_values_supported": []string{"RS256"},

View File

@@ -0,0 +1,17 @@
package dto
type PublicAppConfigVariableDto struct {
Key string `json:"key"`
Type string `json:"type"`
Value string `json:"value"`
}
type AppConfigVariableDto struct {
PublicAppConfigVariableDto
IsPublic bool `json:"isPublic"`
}
type AppConfigUpdateDto struct {
AppName string `json:"appName" binding:"required,min=1,max=30"`
SessionDuration string `json:"sessionDuration" binding:"required"`
}

View File

@@ -0,0 +1,81 @@
package dto
import (
"errors"
"reflect"
)
// MapStructList maps a list of source structs to a list of destination structs
func MapStructList[S any, D any](source []S, destination *[]D) error {
*destination = make([]D, 0, len(source))
for _, item := range source {
var destItem D
if err := MapStruct(item, &destItem); err != nil {
return err
}
*destination = append(*destination, destItem)
}
return nil
}
// MapStruct maps a source struct to a destination struct
func MapStruct[S any, D any](source S, destination *D) error {
// Ensure destination is a non-nil pointer
destValue := reflect.ValueOf(destination)
if destValue.Kind() != reflect.Ptr || destValue.IsNil() {
return errors.New("destination must be a non-nil pointer to a struct")
}
// Ensure source is a struct
sourceValue := reflect.ValueOf(source)
if sourceValue.Kind() != reflect.Struct {
return errors.New("source must be a struct")
}
return mapStructInternal(sourceValue, destValue.Elem())
}
func mapStructInternal(sourceVal reflect.Value, destVal reflect.Value) error {
// Loop through the fields of the destination struct
for i := 0; i < destVal.NumField(); i++ {
destField := destVal.Field(i)
destFieldType := destVal.Type().Field(i)
if destFieldType.Anonymous {
// Recursively handle embedded structs
if err := mapStructInternal(sourceVal, destField); err != nil {
return err
}
continue
}
sourceField := sourceVal.FieldByName(destFieldType.Name)
// If the source field is valid and can be assigned to the destination field
if sourceField.IsValid() && destField.CanSet() {
// Handle direct assignment for simple types
if sourceField.Type() == destField.Type() {
destField.Set(sourceField)
} else if sourceField.Kind() == reflect.Slice && destField.Kind() == reflect.Slice {
// Handle slices
if sourceField.Type().Elem() == destField.Type().Elem() {
newSlice := reflect.MakeSlice(destField.Type(), sourceField.Len(), sourceField.Cap())
for j := 0; j < sourceField.Len(); j++ {
newSlice.Index(j).Set(sourceField.Index(j))
}
destField.Set(newSlice)
}
} else if sourceField.Kind() == reflect.Struct && destField.Kind() == reflect.Struct {
// Recursively map nested structs
if err := mapStructInternal(sourceField, destField); err != nil {
return err
}
}
}
}
return nil
}

View File

@@ -0,0 +1,37 @@
package dto
type PublicOidcClientDto struct {
ID string `json:"id"`
Name string `json:"name"`
HasLogo bool `json:"hasLogo"`
}
type OidcClientDto struct {
PublicOidcClientDto
CallbackURLs []string `json:"callbackURLs"`
CreatedBy UserDto `json:"createdBy"`
}
type OidcClientCreateDto struct {
Name string `json:"name" binding:"required,max=50"`
CallbackURLs []string `json:"callbackURLs" binding:"required,urlList"`
}
type AuthorizeOidcClientRequestDto struct {
ClientID string `json:"clientID" binding:"required"`
Scope string `json:"scope" binding:"required"`
CallbackURL string `json:"callbackURL"`
Nonce string `json:"nonce"`
}
type AuthorizeOidcClientResponseDto struct {
Code string `json:"code"`
CallbackURL string `json:"callbackURL"`
}
type OidcIdTokenDto struct {
GrantType string `form:"grant_type" binding:"required"`
Code string `form:"code" binding:"required"`
ClientID string `form:"client_id"`
ClientSecret string `form:"client_secret"`
}

View File

@@ -0,0 +1,25 @@
package dto
import "time"
type UserDto struct {
ID string `json:"id"`
Username string `json:"username"`
Email string `json:"email" `
FirstName string `json:"firstName"`
LastName string `json:"lastName"`
IsAdmin bool `json:"isAdmin"`
}
type UserCreateDto struct {
Username string `json:"username" binding:"required,username,min=3,max=20"`
Email string `json:"email" binding:"required,email"`
FirstName string `json:"firstName" binding:"required,min=3,max=30"`
LastName string `json:"lastName" binding:"required,min=3,max=30"`
IsAdmin bool `json:"isAdmin"`
}
type OneTimeAccessTokenCreateDto struct {
UserID string `json:"userId" binding:"required"`
ExpiresAt time.Time `json:"expiresAt" binding:"required"`
}

View File

@@ -0,0 +1,42 @@
package dto
import (
"github.com/gin-gonic/gin/binding"
"github.com/go-playground/validator/v10"
"log"
"net/url"
"regexp"
)
var validateUrlList validator.Func = func(fl validator.FieldLevel) bool {
urls := fl.Field().Interface().([]string)
for _, u := range urls {
_, err := url.ParseRequestURI(u)
if err != nil {
return false
}
}
return true
}
var validateUsername validator.Func = func(fl validator.FieldLevel) bool {
// [a-zA-Z0-9] : The username must start with an alphanumeric character
// [a-zA-Z0-9_.@-]* : The rest of the username can contain alphanumeric characters, dots, underscores, hyphens, and "@" symbols
// [a-zA-Z0-9]$ : The username must end with an alphanumeric character
regex := "^[a-zA-Z0-9][a-zA-Z0-9_.@-]*[a-zA-Z0-9]$"
matched, _ := regexp.MatchString(regex, fl.Field().String())
return matched
}
func init() {
if v, ok := binding.Validator.Engine().(*validator.Validate); ok {
if err := v.RegisterValidation("urlList", validateUrlList); err != nil {
log.Fatalf("Failed to register custom validation: %v", err)
}
}
if v, ok := binding.Validator.Engine().(*validator.Validate); ok {
if err := v.RegisterValidation("username", validateUsername); err != nil {
log.Fatalf("Failed to register custom validation: %v", err)
}
}
}

View File

@@ -0,0 +1,23 @@
package dto
import (
"github.com/go-webauthn/webauthn/protocol"
"time"
)
type WebauthnCredentialDto struct {
ID string `json:"id"`
Name string `json:"name"`
CredentialID string `json:"credentialID"`
AttestationType string `json:"attestationType"`
Transport []protocol.AuthenticatorTransport `json:"transport"`
BackupEligible bool `json:"backupEligible"`
BackupState bool `json:"backupState"`
CreatedAt time.Time `json:"createdAt"`
}
type WebauthnCredentialUpdateDto struct {
Name string `json:"name" binding:"required,min=1,max=30"`
}

View File

@@ -17,7 +17,7 @@ func (m *FileSizeLimitMiddleware) Add(maxSize int64) gin.HandlerFunc {
return func(c *gin.Context) {
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, maxSize)
if err := c.Request.ParseMultipartForm(maxSize); err != nil {
utils.HandlerError(c, http.StatusRequestEntityTooLarge, fmt.Sprintf("The file can't be larger than %s bytes", formatFileSize(maxSize)))
utils.CustomControllerError(c, http.StatusRequestEntityTooLarge, fmt.Sprintf("The file can't be larger than %s bytes", formatFileSize(maxSize)))
c.Abort()
return
}

View File

@@ -9,11 +9,12 @@ import (
)
type JwtAuthMiddleware struct {
jwtService *service.JwtService
jwtService *service.JwtService
ignoreUnauthenticated bool
}
func NewJwtAuthMiddleware(jwtService *service.JwtService) *JwtAuthMiddleware {
return &JwtAuthMiddleware{jwtService: jwtService}
func NewJwtAuthMiddleware(jwtService *service.JwtService, ignoreUnauthenticated bool) *JwtAuthMiddleware {
return &JwtAuthMiddleware{jwtService: jwtService, ignoreUnauthenticated: ignoreUnauthenticated}
}
func (m *JwtAuthMiddleware) Add(adminOnly bool) gin.HandlerFunc {
@@ -24,23 +25,29 @@ func (m *JwtAuthMiddleware) Add(adminOnly bool) gin.HandlerFunc {
authorizationHeaderSplitted := strings.Split(c.GetHeader("Authorization"), " ")
if len(authorizationHeaderSplitted) == 2 {
token = authorizationHeaderSplitted[1]
} else if m.ignoreUnauthenticated {
c.Next()
return
} else {
utils.HandlerError(c, http.StatusUnauthorized, "You're not signed in")
utils.CustomControllerError(c, http.StatusUnauthorized, "You're not signed in")
c.Abort()
return
}
}
claims, err := m.jwtService.VerifyAccessToken(token)
if err != nil {
utils.HandlerError(c, http.StatusUnauthorized, "You're not signed in")
if err != nil && m.ignoreUnauthenticated {
c.Next()
return
} else if err != nil {
utils.CustomControllerError(c, http.StatusUnauthorized, "You're not signed in")
c.Abort()
return
}
// Check if the user is an admin
if adminOnly && !claims.IsAdmin {
utils.HandlerError(c, http.StatusForbidden, "You don't have permission to access this resource")
utils.CustomControllerError(c, http.StatusForbidden, "You don't have permission to access this resource")
c.Abort()
return
}

View File

@@ -33,7 +33,7 @@ func (m *RateLimitMiddleware) Add(limit rate.Limit, burst int) gin.HandlerFunc {
limiter := getLimiter(ip, limit, burst)
if !limiter.Allow() {
utils.HandlerError(c, http.StatusTooManyRequests, "Too many requests. Please wait a while before trying again.")
utils.CustomControllerError(c, http.StatusTooManyRequests, "Too many requests. Please wait a while before trying again.")
c.Abort()
return
}

View File

@@ -1,11 +1,11 @@
package model
type AppConfigVariable struct {
Key string `gorm:"primaryKey;not null" json:"key"`
Type string `json:"type"`
IsPublic bool `json:"-"`
IsInternal bool `json:"-"`
Value string `json:"value"`
Key string `gorm:"primaryKey;not null"`
Type string
IsPublic bool
IsInternal bool
Value string
}
type AppConfig struct {
@@ -14,7 +14,3 @@ type AppConfig struct {
LogoImageType AppConfigVariable
SessionDuration AppConfigVariable
}
type AppConfigUpdateDto struct {
AppName string `json:"appName" binding:"required"`
}

View File

@@ -8,8 +8,8 @@ import (
// Base contains common columns for all tables.
type Base struct {
ID string `gorm:"primaryKey;not null" json:"id"`
CreatedAt time.Time `json:"createdAt"`
ID string `gorm:"primaryKey;not null"`
CreatedAt time.Time
}
func (b *Base) BeforeCreate(_ *gorm.DB) (err error) {

View File

@@ -1,38 +1,22 @@
package model
import (
"database/sql/driver"
"encoding/json"
"errors"
"gorm.io/gorm"
"time"
)
type UserAuthorizedOidcClient struct {
Scope string
UserID string `json:"userId" gorm:"primary_key;"`
UserID string `gorm:"primary_key;"`
User User
ClientID string `json:"clientId" gorm:"primary_key;"`
ClientID string `gorm:"primary_key;"`
Client OidcClient
}
type OidcClient struct {
Base
Name string `json:"name"`
Secret string `json:"-"`
CallbackURL string `json:"callbackURL"`
ImageType *string `json:"-"`
HasLogo bool `gorm:"-" json:"hasLogo"`
CreatedByID string
CreatedBy User
}
func (c *OidcClient) AfterFind(_ *gorm.DB) (err error) {
// Compute HasLogo field
c.HasLogo = c.ImageType != nil && *c.ImageType != ""
return nil
}
type OidcAuthorizationCode struct {
Base
@@ -47,26 +31,35 @@ type OidcAuthorizationCode struct {
ClientID string
}
type OidcClientCreateDto struct {
Name string `json:"name" binding:"required"`
CallbackURL string `json:"callbackURL" binding:"required"`
type OidcClient struct {
Base
Name string
Secret string
CallbackURLs CallbackURLs
ImageType *string
HasLogo bool `gorm:"-"`
CreatedByID string
CreatedBy User
}
type AuthorizeNewClientDto struct {
ClientID string `json:"clientID" binding:"required"`
Scope string `json:"scope" binding:"required"`
Nonce string `json:"nonce"`
func (c *OidcClient) AfterFind(_ *gorm.DB) (err error) {
// Compute HasLogo field
c.HasLogo = c.ImageType != nil && *c.ImageType != ""
return nil
}
type OidcIdTokenDto struct {
GrantType string `form:"grant_type" binding:"required"`
Code string `form:"code" binding:"required"`
ClientID string `form:"client_id"`
ClientSecret string `form:"client_secret"`
type CallbackURLs []string
func (cu *CallbackURLs) Scan(value interface{}) error {
if v, ok := value.([]byte); ok {
return json.Unmarshal(v, cu)
} else {
return errors.New("type assertion to []byte failed")
}
}
type AuthorizeRequest struct {
ClientID string `json:"clientID" binding:"required"`
Scope string `json:"scope" binding:"required"`
Nonce string `json:"nonce"`
func (cu CallbackURLs) Value() (driver.Value, error) {
return json.Marshal(cu)
}

View File

@@ -9,13 +9,13 @@ import (
type User struct {
Base
Username string `json:"username"`
Email string `json:"email" `
FirstName string `json:"firstName"`
LastName string `json:"lastName"`
IsAdmin bool `json:"isAdmin"`
Username string
Email string
FirstName string
LastName string
IsAdmin bool
Credentials []WebauthnCredential `json:"-"`
Credentials []WebauthnCredential
}
func (u User) WebAuthnID() []byte { return []byte(u.ID) }
@@ -59,19 +59,9 @@ func (u User) WebAuthnCredentialDescriptors() (descriptors []protocol.Credential
type OneTimeAccessToken struct {
Base
Token string `json:"token"`
ExpiresAt time.Time `json:"expiresAt"`
Token string
ExpiresAt time.Time
UserID string `json:"userId"`
UserID string
User User
}
type OneTimeAccessTokenCreateDto struct {
UserID string `json:"userId" binding:"required"`
ExpiresAt time.Time `json:"expiresAt" binding:"required"`
}
type LoginUserDto struct {
Username string `json:"username" binding:"required"`
Password string `json:"password" binding:"required"`
}

View File

@@ -19,11 +19,11 @@ type WebauthnSession struct {
type WebauthnCredential struct {
Base
Name string `json:"name"`
CredentialID string `json:"credentialID"`
PublicKey []byte `json:"-"`
AttestationType string `json:"attestationType"`
Transport AuthenticatorTransportList `json:"-"`
Name string
CredentialID string
PublicKey []byte
AttestationType string
Transport AuthenticatorTransportList
BackupEligible bool `json:"backupEligible"`
BackupState bool `json:"backupState"`
@@ -32,15 +32,15 @@ type WebauthnCredential struct {
}
type PublicKeyCredentialCreationOptions struct {
Response protocol.PublicKeyCredentialCreationOptions `json:"response"`
SessionID string `json:"session_id"`
Timeout time.Duration `json:"timeout"`
Response protocol.PublicKeyCredentialCreationOptions
SessionID string
Timeout time.Duration
}
type PublicKeyCredentialRequestOptions struct {
Response protocol.PublicKeyCredentialRequestOptions `json:"response"`
SessionID string `json:"session_id"`
Timeout time.Duration `json:"timeout"`
Response protocol.PublicKeyCredentialRequestOptions
SessionID string
Timeout time.Duration
}
type AuthenticatorTransportList []protocol.AuthenticatorTransport
@@ -58,7 +58,3 @@ func (atl *AuthenticatorTransportList) Scan(value interface{}) error {
func (atl AuthenticatorTransportList) Value() (driver.Value, error) {
return json.Marshal(atl)
}
type WebauthnCredentialUpdateDto struct {
Name string `json:"name"`
}

View File

@@ -3,6 +3,7 @@ package service
import (
"fmt"
"github.com/stonith404/pocket-id/backend/internal/common"
"github.com/stonith404/pocket-id/backend/internal/dto"
"github.com/stonith404/pocket-id/backend/internal/model"
"github.com/stonith404/pocket-id/backend/internal/utils"
"gorm.io/gorm"
@@ -54,8 +55,8 @@ var defaultDbConfig = model.AppConfig{
},
}
func (s *AppConfigService) UpdateApplicationConfiguration(input model.AppConfigUpdateDto) ([]model.AppConfigVariable, error) {
savedConfigVariables := make([]model.AppConfigVariable, 10)
func (s *AppConfigService) UpdateApplicationConfiguration(input dto.AppConfigUpdateDto) ([]model.AppConfigVariable, error) {
var savedConfigVariables []model.AppConfigVariable
tx := s.db.Begin()
rt := reflect.ValueOf(input).Type()
@@ -78,7 +79,7 @@ func (s *AppConfigService) UpdateApplicationConfiguration(input model.AppConfigU
return nil, err
}
savedConfigVariables[i] = applicationConfigurationVariable
savedConfigVariables = append(savedConfigVariables, applicationConfigurationVariable)
}
tx.Commit()

View File

@@ -4,12 +4,14 @@ import (
"errors"
"fmt"
"github.com/stonith404/pocket-id/backend/internal/common"
"github.com/stonith404/pocket-id/backend/internal/dto"
"github.com/stonith404/pocket-id/backend/internal/model"
"github.com/stonith404/pocket-id/backend/internal/utils"
"golang.org/x/crypto/bcrypt"
"gorm.io/gorm"
"mime/multipart"
"os"
"slices"
"strings"
"time"
)
@@ -26,33 +28,50 @@ func NewOidcService(db *gorm.DB, jwtService *JwtService) *OidcService {
}
}
func (s *OidcService) Authorize(req model.AuthorizeRequest, userID string) (string, error) {
func (s *OidcService) Authorize(input dto.AuthorizeOidcClientRequestDto, userID string) (string, string, error) {
var userAuthorizedOIDCClient model.UserAuthorizedOidcClient
s.db.First(&userAuthorizedOIDCClient, "client_id = ? AND user_id = ?", req.ClientID, userID)
s.db.Preload("Client").First(&userAuthorizedOIDCClient, "client_id = ? AND user_id = ?", input.ClientID, userID)
if userAuthorizedOIDCClient.Scope != req.Scope {
return "", common.ErrOidcMissingAuthorization
if userAuthorizedOIDCClient.Scope != input.Scope {
return "", "", common.ErrOidcMissingAuthorization
}
return s.createAuthorizationCode(req.ClientID, userID, req.Scope, req.Nonce)
callbackURL, err := getCallbackURL(userAuthorizedOIDCClient.Client, input.CallbackURL)
if err != nil {
return "", "", err
}
code, err := s.createAuthorizationCode(input.ClientID, userID, input.Scope, input.Nonce)
return code, callbackURL, err
}
func (s *OidcService) AuthorizeNewClient(req model.AuthorizeNewClientDto, userID string) (string, error) {
func (s *OidcService) AuthorizeNewClient(input dto.AuthorizeOidcClientRequestDto, userID string) (string, string, error) {
var client model.OidcClient
if err := s.db.First(&client, "id = ?", input.ClientID).Error; err != nil {
return "", "", err
}
callbackURL, err := getCallbackURL(client, input.CallbackURL)
if err != nil {
return "", "", err
}
userAuthorizedClient := model.UserAuthorizedOidcClient{
UserID: userID,
ClientID: req.ClientID,
Scope: req.Scope,
ClientID: input.ClientID,
Scope: input.Scope,
}
if err := s.db.Create(&userAuthorizedClient).Error; err != nil {
if errors.Is(err, gorm.ErrDuplicatedKey) {
err = s.db.Model(&userAuthorizedClient).Update("scope", req.Scope).Error
err = s.db.Model(&userAuthorizedClient).Update("scope", input.Scope).Error
} else {
return "", err
return "", "", err
}
}
return s.createAuthorizationCode(req.ClientID, userID, req.Scope, req.Nonce)
code, err := s.createAuthorizationCode(input.ClientID, userID, input.Scope, input.Nonce)
return code, callbackURL, err
}
func (s *OidcService) CreateTokens(code, grantType, clientID, clientSecret string) (string, string, error) {
@@ -101,18 +120,18 @@ func (s *OidcService) CreateTokens(code, grantType, clientID, clientSecret strin
return idToken, accessToken, nil
}
func (s *OidcService) GetClient(clientID string) (*model.OidcClient, error) {
func (s *OidcService) GetClient(clientID string) (model.OidcClient, error) {
var client model.OidcClient
if err := s.db.First(&client, "id = ?", clientID).Error; err != nil {
return nil, err
if err := s.db.Preload("CreatedBy").First(&client, "id = ?", clientID).Error; err != nil {
return model.OidcClient{}, err
}
return &client, nil
return client, nil
}
func (s *OidcService) ListClients(searchTerm string, page int, pageSize int) ([]model.OidcClient, utils.PaginationResponse, error) {
var clients []model.OidcClient
query := s.db.Model(&model.OidcClient{})
query := s.db.Preload("CreatedBy").Model(&model.OidcClient{})
if searchTerm != "" {
searchPattern := "%" + searchTerm + "%"
query = query.Where("name LIKE ?", searchPattern)
@@ -126,34 +145,34 @@ func (s *OidcService) ListClients(searchTerm string, page int, pageSize int) ([]
return clients, pagination, nil
}
func (s *OidcService) CreateClient(input model.OidcClientCreateDto, userID string) (*model.OidcClient, error) {
func (s *OidcService) CreateClient(input dto.OidcClientCreateDto, userID string) (model.OidcClient, error) {
client := model.OidcClient{
Name: input.Name,
CallbackURL: input.CallbackURL,
CreatedByID: userID,
Name: input.Name,
CallbackURLs: input.CallbackURLs,
CreatedByID: userID,
}
if err := s.db.Create(&client).Error; err != nil {
return nil, err
return model.OidcClient{}, err
}
return &client, nil
return client, nil
}
func (s *OidcService) UpdateClient(clientID string, input model.OidcClientCreateDto) (*model.OidcClient, error) {
func (s *OidcService) UpdateClient(clientID string, input dto.OidcClientCreateDto) (model.OidcClient, error) {
var client model.OidcClient
if err := s.db.First(&client, "id = ?", clientID).Error; err != nil {
return nil, err
if err := s.db.Preload("CreatedBy").First(&client, "id = ?", clientID).Error; err != nil {
return model.OidcClient{}, err
}
client.Name = input.Name
client.CallbackURL = input.CallbackURL
client.CallbackURLs = input.CallbackURLs
if err := s.db.Save(&client).Error; err != nil {
return nil, err
return model.OidcClient{}, err
}
return &client, nil
return client, nil
}
func (s *OidcService) DeleteClient(clientID string) error {
@@ -284,6 +303,7 @@ func (s *OidcService) GetUserClaimsForClient(userID string, clientID string) (ma
profileClaims := map[string]interface{}{
"given_name": user.FirstName,
"family_name": user.LastName,
"name": user.FirstName + " " + user.LastName,
"preferred_username": user.Username,
}
@@ -320,3 +340,14 @@ func (s *OidcService) createAuthorizationCode(clientID string, userID string, sc
return randomString, nil
}
func getCallbackURL(client model.OidcClient, inputCallbackURL string) (callbackURL string, err error) {
if inputCallbackURL == "" {
return client.CallbackURLs[0], nil
}
if slices.Contains(client.CallbackURLs, inputCallbackURL) {
return inputCallbackURL, nil
}
return "", common.ErrOidcInvalidCallbackURL
}

View File

@@ -61,20 +61,20 @@ func (s *TestService) SeedDatabase() error {
Base: model.Base{
ID: "3654a746-35d4-4321-ac61-0bdcff2b4055",
},
Name: "Nextcloud",
Secret: "$2a$10$9dypwot8nGuCjT6wQWWpJOckZfRprhe2EkwpKizxS/fpVHrOLEJHC", // w2mUeZISmEvIDMEDvpY0PnxQIpj1m3zY
CallbackURL: "http://nextcloud/auth/callback",
ImageType: utils.StringPointer("png"),
CreatedByID: users[0].ID,
Name: "Nextcloud",
Secret: "$2a$10$9dypwot8nGuCjT6wQWWpJOckZfRprhe2EkwpKizxS/fpVHrOLEJHC", // w2mUeZISmEvIDMEDvpY0PnxQIpj1m3zY
CallbackURLs: model.CallbackURLs{"http://nextcloud/auth/callback"},
ImageType: utils.StringPointer("png"),
CreatedByID: users[0].ID,
},
{
Base: model.Base{
ID: "606c7782-f2b1-49e5-8ea9-26eb1b06d018",
},
Name: "Immich",
Secret: "$2a$10$Ak.FP8riD1ssy2AGGbG.gOpnp/rBpymd74j0nxNMtW0GG1Lb4gzxe", // PYjrE9u4v9GVqXKi52eur0eb2Ci4kc0x
CallbackURL: "http://immich/auth/callback",
CreatedByID: users[0].ID,
Name: "Immich",
Secret: "$2a$10$Ak.FP8riD1ssy2AGGbG.gOpnp/rBpymd74j0nxNMtW0GG1Lb4gzxe", // PYjrE9u4v9GVqXKi52eur0eb2Ci4kc0x
CallbackURLs: model.CallbackURLs{"http://immich/auth/callback"},
CreatedByID: users[0].ID,
},
}
for _, client := range oidcClients {

View File

@@ -3,6 +3,7 @@ package service
import (
"errors"
"github.com/stonith404/pocket-id/backend/internal/common"
"github.com/stonith404/pocket-id/backend/internal/dto"
"github.com/stonith404/pocket-id/backend/internal/model"
"github.com/stonith404/pocket-id/backend/internal/utils"
"gorm.io/gorm"
@@ -46,17 +47,24 @@ func (s *UserService) DeleteUser(userID string) error {
return s.db.Delete(&user).Error
}
func (s *UserService) CreateUser(user *model.User) error {
if err := s.db.Create(user).Error; err != nil {
if errors.Is(err, gorm.ErrDuplicatedKey) {
return s.checkDuplicatedFields(*user)
}
return err
func (s *UserService) CreateUser(input dto.UserCreateDto) (model.User, error) {
user := model.User{
FirstName: input.FirstName,
LastName: input.LastName,
Email: input.Email,
Username: input.Username,
IsAdmin: input.IsAdmin,
}
return nil
if err := s.db.Create(&user).Error; err != nil {
if errors.Is(err, gorm.ErrDuplicatedKey) {
return model.User{}, s.checkDuplicatedFields(user)
}
return model.User{}, err
}
return user, nil
}
func (s *UserService) UpdateUser(userID string, updatedUser model.User, updateOwnUser bool) (model.User, error) {
func (s *UserService) UpdateUser(userID string, updatedUser dto.UserCreateDto, updateOwnUser bool) (model.User, error) {
var user model.User
if err := s.db.Where("id = ?", userID).First(&user).Error; err != nil {
return model.User{}, err

View File

@@ -67,10 +67,10 @@ func (s *WebAuthnService) BeginRegistration(userID string) (*model.PublicKeyCred
}, nil
}
func (s *WebAuthnService) VerifyRegistration(sessionID, userID string, r *http.Request) (*model.WebauthnCredential, error) {
func (s *WebAuthnService) VerifyRegistration(sessionID, userID string, r *http.Request) (model.WebauthnCredential, error) {
var storedSession model.WebauthnSession
if err := s.db.First(&storedSession, "id = ?", sessionID).Error; err != nil {
return nil, err
return model.WebauthnCredential{}, err
}
session := webauthn.SessionData{
@@ -81,12 +81,12 @@ func (s *WebAuthnService) VerifyRegistration(sessionID, userID string, r *http.R
var user model.User
if err := s.db.Find(&user, "id = ?", userID).Error; err != nil {
return nil, err
return model.WebauthnCredential{}, err
}
credential, err := s.webAuthn.FinishRegistration(&user, session, r)
if err != nil {
return nil, err
return model.WebauthnCredential{}, err
}
credentialToStore := model.WebauthnCredential{
@@ -100,10 +100,10 @@ func (s *WebAuthnService) VerifyRegistration(sessionID, userID string, r *http.R
BackupState: credential.Flags.BackupState,
}
if err := s.db.Create(&credentialToStore).Error; err != nil {
return nil, err
return model.WebauthnCredential{}, err
}
return &credentialToStore, nil
return credentialToStore, nil
}
func (s *WebAuthnService) BeginLogin() (*model.PublicKeyCredentialRequestOptions, error) {
@@ -129,10 +129,10 @@ func (s *WebAuthnService) BeginLogin() (*model.PublicKeyCredentialRequestOptions
}, nil
}
func (s *WebAuthnService) VerifyLogin(sessionID, userID string, credentialAssertionData *protocol.ParsedCredentialAssertionData) (*model.User, error) {
func (s *WebAuthnService) VerifyLogin(sessionID, userID string, credentialAssertionData *protocol.ParsedCredentialAssertionData) (model.User, error) {
var storedSession model.WebauthnSession
if err := s.db.First(&storedSession, "id = ?", sessionID).Error; err != nil {
return nil, err
return model.User{}, err
}
session := webauthn.SessionData{
@@ -149,14 +149,14 @@ func (s *WebAuthnService) VerifyLogin(sessionID, userID string, credentialAssert
}, session, credentialAssertionData)
if err != nil {
return nil, common.ErrInvalidCredentials
return model.User{}, err
}
if err := s.db.Find(&user, "id = ?", userID).Error; err != nil {
return nil, err
return model.User{}, err
}
return user, nil
return *user, nil
}
func (s *WebAuthnService) ListCredentials(userID string) ([]model.WebauthnCredential, error) {
@@ -180,17 +180,17 @@ func (s *WebAuthnService) DeleteCredential(userID, credentialID string) error {
return nil
}
func (s *WebAuthnService) UpdateCredential(userID, credentialID, name string) error {
func (s *WebAuthnService) UpdateCredential(userID, credentialID, name string) (model.WebauthnCredential, error) {
var credential model.WebauthnCredential
if err := s.db.Where("id = ? AND user_id = ?", credentialID, userID).First(&credential).Error; err != nil {
return err
return credential, err
}
credential.Name = name
if err := s.db.Save(&credential).Error; err != nil {
return err
return credential, err
}
return nil
return credential, nil
}

View File

@@ -0,0 +1,75 @@
package utils
import (
"errors"
"github.com/gin-gonic/gin"
"github.com/go-playground/validator/v10"
"gorm.io/gorm"
"log"
"net/http"
"strings"
)
import (
"fmt"
)
func ControllerError(c *gin.Context, err error) {
// Check for record not found errors
if errors.Is(err, gorm.ErrRecordNotFound) {
CustomControllerError(c, http.StatusNotFound, "Record not found")
return
}
// Check for validation errors
var validationErrors validator.ValidationErrors
if errors.As(err, &validationErrors) {
message := handleValidationError(validationErrors)
CustomControllerError(c, http.StatusBadRequest, message)
return
}
log.Println(err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Something went wrong"})
}
func handleValidationError(validationErrors validator.ValidationErrors) string {
var errorMessages []string
for _, ve := range validationErrors {
fieldName := ve.Field()
var errorMessage string
switch ve.Tag() {
case "required":
errorMessage = fmt.Sprintf("%s is required", fieldName)
case "email":
errorMessage = fmt.Sprintf("%s must be a valid email address", fieldName)
case "username":
errorMessage = fmt.Sprintf("%s must only contain lowercase letters, numbers, underscores, dots, hyphens, and '@' symbols and not start or end with a special character", fieldName)
case "url":
errorMessage = fmt.Sprintf("%s must be a valid URL", fieldName)
case "min":
errorMessage = fmt.Sprintf("%s must be at least %s characters long", fieldName, ve.Param())
case "max":
errorMessage = fmt.Sprintf("%s must be at most %s characters long", fieldName, ve.Param())
case "urlList":
errorMessage = fmt.Sprintf("%s must be a list of valid URLs", fieldName)
default:
errorMessage = fmt.Sprintf("%s is invalid", fieldName)
}
errorMessages = append(errorMessages, errorMessage)
}
// Join all the error messages into a single string
combinedErrors := strings.Join(errorMessages, ", ")
return combinedErrors
}
func CustomControllerError(c *gin.Context, statusCode int, message string) {
// Capitalize the first letter of the message
message = strings.ToUpper(message[:1]) + message[1:]
c.JSON(statusCode, gin.H{"error": message})
}

View File

@@ -1,27 +0,0 @@
package utils
import (
"errors"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
"log"
"net/http"
"strings"
)
func UnknownHandlerError(c *gin.Context, err error) {
if errors.Is(err, gorm.ErrRecordNotFound) {
HandlerError(c, http.StatusNotFound, "Record not found")
return
} else {
log.Println(err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Something went wrong"})
}
}
func HandlerError(c *gin.Context, statusCode int, message string) {
// Capitalize the first letter of the message
message = strings.ToUpper(message[:1]) + message[1:]
c.JSON(statusCode, gin.H{"error": message})
}

View File

@@ -57,7 +57,7 @@ CREATE TABLE webauthn_credentials
credential_id TEXT NOT NULL UNIQUE,
public_key BLOB NOT NULL,
attestation_type TEXT NOT NULL,
transport TEXT NOT NULL,
transport BLOB NOT NULL,
user_id TEXT REFERENCES users
);

View File

@@ -0,0 +1,23 @@
create table oidc_clients
(
id TEXT not null primary key,
created_at DATETIME,
name TEXT,
secret TEXT,
callback_url TEXT,
image_type TEXT,
created_by_id TEXT
references users
);
insert into oidc_clients(id, created_at, name, secret, callback_url, image_type, created_by_id)
select id,
created_at,
name,
secret,
json_extract(callback_urls, '$[0]'),
image_type,
created_by_id
from oidc_clients_dg_tmp;
drop table oidc_clients_dg_tmp;

View File

@@ -0,0 +1,26 @@
create table oidc_clients_dg_tmp
(
id TEXT not null primary key,
created_at DATETIME,
name TEXT,
secret TEXT,
callback_urls BLOB,
image_type TEXT,
created_by_id TEXT
references users
);
insert into oidc_clients_dg_tmp(id, created_at, name, secret, callback_urls, image_type, created_by_id)
select id,
created_at,
name,
secret,
CAST('["' || callback_url || '"]' AS BLOB),
image_type,
created_by_id
from oidc_clients;
drop table oidc_clients;
alter table oidc_clients_dg_tmp
rename to oidc_clients;

81
docs/proxy-services.md Normal file
View File

@@ -0,0 +1,81 @@
# Proxy Services through Pocket ID
The goal of Pocket ID is to stay simple. Because of that we don't have a built-in proxy provider. However, you can use [OAuth2 Proxy](https://oauth2-proxy.github.io/) to add authentication to your services that don't support OIDC. This guide will show you how to set up OAuth2 Proxy with Pocket ID.
## Docker Setup
#### 1. Add OAuth2 proxy to the service that should be proxied.
To configure OAuth2 Proxy with Pocket ID, you have to add the following service to the service that should be proxied. E.g., [Uptime Kuma](https://github.com/louislam/uptime-kuma) should be proxied, you can add the following service to the `docker-compose.yml` of Uptime Kuma:
```yaml
# Example with Uptime Kuma
# uptime-kuma:
# image: louislam/uptime-kuma
oauth2-proxy:
image: quay.io/oauth2-proxy/oauth2-proxy:v7.6.0
command: --config /oauth2-proxy.cfg
volumes:
- "./oauth2-proxy.cfg:/oauth2-proxy.cfg"
ports:
- 4180:4180
```
#### 2. Create a new OIDC client in Pocket ID.
Create a new OIDC client in Pocket ID by navigating to `https://<your-domain>/settings/admin/oidc-clients`. After adding the client, you will obtain the client ID and client secret.
#### 3. Create a configuration file for OAuth2 Proxy.
Create a configuration file named `oauth2-proxy.cfg` in the same directory as your `docker-compose.yml` file of the service that should be proxied (e.g. Uptime Kuma). This file will contain the necessary configurations for OAuth2 Proxy to work with Pocket ID.
Here is the recommend `oauth2-proxy.cfg` configuration:
```cfg
# Replace with your own credentials
client_id="client-id-from-pocket-id"
client_secret="client-secret-from-pocket-id"
oidc_issuer_url="https://<your-pocket-id-domain>"
# Replace with a secure random string
cookie_secret="random-string"
# Upstream servers (e.g http://uptime-kuma:3001)
upstreams="http://<service-to-be-proxied>:<port>"
# Additional Configuration
provider="oidc"
scope = "openid email profile"
# If you are using a reverse proxy in front of OAuth2 Proxy
reverse_proxy = true
# Email domains allowed for authentication
email_domains = ["*"]
# If you are using HTTPS
cookie_secure="true"
# Listen on all interfaces
http_address="0.0.0.0:4180"
```
For additional configuration options, refer to the official [OAuth2 Proxy documentation](https://oauth2-proxy.github.io/oauth2-proxy/configuration/overview).
#### 4. Start the services.
After creating the configuration file, you can start the services using Docker Compose:
```bash
docker compose up -d
```
#### 5. Access the service.
You can now access the service through OAuth2 Proxy by visiting `http://localhost:4180`.
## Standalone Installation
Setting up OAuth2 Proxy with Pocket ID without Docker is similar to the Docker setup. As the setup depends on your environment, you have to adjust the steps accordingly but is should be similar to the Docker setup.
You can visit the official [OAuth2 Proxy documentation](https://oauth2-proxy.github.io/oauth2-proxy/installation) for more information.

File diff suppressed because it is too large Load Diff

View File

@@ -12,46 +12,46 @@
"format": "prettier --write ."
},
"devDependencies": {
"@playwright/test": "^1.46.0",
"@sveltejs/adapter-auto": "^3.0.0",
"@sveltejs/adapter-node": "^5.2.0",
"@sveltejs/kit": "^2.0.0",
"@sveltejs/vite-plugin-svelte": "^3.0.0",
"@types/eslint": "^8.56.7",
"@playwright/test": "^1.46.1",
"@sveltejs/adapter-auto": "^3.2.4",
"@sveltejs/adapter-node": "^5.2.2",
"@sveltejs/kit": "^2.5.24",
"@sveltejs/vite-plugin-svelte": "^3.1.2",
"@types/eslint": "^9.6.0",
"@types/jsonwebtoken": "^9.0.6",
"@types/node": "^22.1.0",
"autoprefixer": "^10.4.19",
"@types/node": "^22.5.0",
"autoprefixer": "^10.4.20",
"cbor-js": "^0.1.0",
"eslint": "^9.0.0",
"eslint": "^9.9.1",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-svelte": "^2.36.0",
"globals": "^15.0.0",
"postcss": "^8.4.38",
"prettier": "^3.1.1",
"prettier-plugin-svelte": "^3.1.2",
"prettier-plugin-tailwindcss": "^0.6.4",
"eslint-plugin-svelte": "^2.40.0",
"globals": "^15.9.0",
"postcss": "^8.4.41",
"prettier": "^3.3.3",
"prettier-plugin-svelte": "^3.2.6",
"prettier-plugin-tailwindcss": "^0.6.6",
"svelte": "^5.0.0-next.1",
"svelte-check": "^3.6.0",
"tailwindcss": "^3.4.4",
"tslib": "^2.4.1",
"typescript": "^5.0.0",
"typescript-eslint": "^8.0.0-alpha.20",
"vite": "^5.0.3"
"svelte-check": "^3.8.6",
"tailwindcss": "^3.4.10",
"tslib": "^2.7.0",
"typescript": "^5.5.4",
"typescript-eslint": "^8.2.0",
"vite": "^5.4.2"
},
"type": "module",
"dependencies": {
"@simplewebauthn/browser": "^10.0.0",
"axios": "^1.7.2",
"bits-ui": "^0.21.12",
"axios": "^1.7.5",
"bits-ui": "^0.21.13",
"clsx": "^2.1.1",
"crypto": "^1.0.1",
"formsnap": "^1.0.1",
"jsonwebtoken": "^9.0.2",
"lucide-svelte": "^0.399.0",
"lucide-svelte": "^0.435.0",
"mode-watcher": "^0.4.1",
"svelte-sonner": "^0.3.27",
"sveltekit-superforms": "^2.16.1",
"tailwind-merge": "^2.3.0",
"sveltekit-superforms": "^2.17.0",
"tailwind-merge": "^2.5.2",
"tailwind-variants": "^0.2.1",
"zod": "^3.23.8"
}

View File

@@ -2,15 +2,17 @@
import { Label } from '$lib/components/ui/label';
import type { FormInput } from '$lib/utils/form-util';
import type { Snippet } from 'svelte';
import type { HTMLAttributes } from 'svelte/elements';
import { Input } from './ui/input';
let {
input = $bindable(),
label,
description,
children
}: {
input: FormInput<string | boolean | number>;
children,
...restProps
}: HTMLAttributes<HTMLDivElement> & {
input?: FormInput<string | boolean | number>;
label: string;
description?: string;
children?: Snippet;
@@ -19,19 +21,19 @@
const id = label.toLowerCase().replace(/ /g, '-');
</script>
<div>
<div {...restProps}>
<Label class="mb-0" for={id}>{label}</Label>
{#if description}
<p class="text-muted-foreground text-xs mt-1">{description}</p>
<p class="text-muted-foreground mt-1 text-xs">{description}</p>
{/if}
<div class="mt-2">
{#if children}
{@render children()}
{:else}
{:else if input}
<Input {id} bind:value={input.value} />
{/if}
{#if input.error}
<p class="text-sm text-red-500">{input.error}</p>
{#if input?.error}
<p class="mt-1 text-sm text-red-500">{input.error}</p>
{/if}
</div>
</div>

View File

@@ -23,7 +23,7 @@
<Avatar.Fallback>{initials}</Avatar.Fallback>
</Avatar.Root></DropdownMenu.Trigger
>
<DropdownMenu.Content class="w-40" align="start">
<DropdownMenu.Content class="min-w-40" align="start">
<DropdownMenu.Label class="font-normal">
<div class="flex flex-col space-y-1">
<p class="text-sm font-medium leading-none">

View File

@@ -6,12 +6,13 @@
import HeaderAvatar from './header-avatar.svelte';
let isAuthPage = $derived(
!$page.error && ($page.url.pathname.startsWith('/authorize') || $page.url.pathname.startsWith('/login'))
!$page.error &&
($page.url.pathname.startsWith('/authorize') || $page.url.pathname.startsWith('/login'))
);
</script>
<div class=" w-full {isAuthPage ? 'absolute top-0 z-10 mt-4' : 'border-b'}">
<div class="mx-auto flex w-full max-w-[1520px] items-center justify-between px-4 md:px-10">
<div class="mx-auto flex w-full max-w-[1640px] items-center justify-between px-4 md:px-10">
<div class="flex h-16 items-center">
{#if !isAuthPage}
<Logo class="mr-3 h-10 w-10" />

View File

@@ -1,26 +1,28 @@
import type { OidcClient, OidcClientCreate } from '$lib/types/oidc.type';
import type { AuthorizeResponse, OidcClient, OidcClientCreate } from '$lib/types/oidc.type';
import type { Paginated, PaginationRequest } from '$lib/types/pagination.type';
import APIService from './api-service';
class OidcService extends APIService {
async authorize(clientId: string, scope: string, nonce?: string) {
async authorize(clientId: string, scope: string, callbackURL : string, nonce?: string) {
const res = await this.api.post('/oidc/authorize', {
scope,
nonce,
callbackURL,
clientId
});
return res.data.code as string;
return res.data as AuthorizeResponse;
}
async authorizeNewClient(clientId: string, scope: string, nonce?: string) {
async authorizeNewClient(clientId: string, scope: string, callbackURL: string, nonce?: string) {
const res = await this.api.post('/oidc/authorize/new-client', {
scope,
nonce,
callbackURL,
clientId
});
return res.data.code as string;
return res.data as AuthorizeResponse;
}
async listClients(search?: string, pagination?: PaginationRequest) {

View File

@@ -2,7 +2,7 @@ export type OidcClient = {
id: string;
name: string;
logoURL: string;
callbackURL: string;
callbackURLs: [string, ...string[]];
hasLogo: boolean;
};
@@ -11,3 +11,8 @@ export type OidcClientCreate = Omit<OidcClient, 'id' | 'logoURL' | 'hasLogo'>;
export type OidcClientCreateWithLogo = OidcClientCreate & {
logo: File | null;
};
export type AuthorizeResponse = {
code: string;
callbackURL: string;
};

View File

@@ -11,6 +11,7 @@ export const load: PageServerLoad = async ({ url, cookies }) => {
scope: url.searchParams.get('scope')!,
nonce: url.searchParams.get('nonce') || undefined,
state: url.searchParams.get('state')!,
callbackURL: url.searchParams.get('redirect_uri')!,
client
};
};

View File

@@ -24,7 +24,7 @@
let authorizationRequired = false;
export let data: PageData;
let { scope, nonce, client, state } = data;
let { scope, nonce, client, state, callbackURL } = data;
async function authorize() {
isLoading = true;
@@ -36,9 +36,11 @@
await webauthnService.finishLogin(authResponse);
}
await oidService.authorize(client!.id, scope, nonce).then(async (code) => {
onSuccess(code);
});
await oidService
.authorize(client!.id, scope, callbackURL, nonce)
.then(async ({ code, callbackURL }) => {
onSuccess(code, callbackURL);
});
} catch (e) {
if (e instanceof AxiosError && e.response?.status === 403) {
authorizationRequired = true;
@@ -52,19 +54,21 @@
async function authorizeNewClient() {
isLoading = true;
try {
await oidService.authorizeNewClient(client!.id, scope, nonce).then(async (code) => {
onSuccess(code);
});
await oidService
.authorizeNewClient(client!.id, scope, callbackURL, nonce)
.then(async ({ code, callbackURL }) => {
onSuccess(code, callbackURL);
});
} catch (e) {
errorMessage = getWebauthnErrorMessage(e);
isLoading = false;
}
}
function onSuccess(code: string) {
function onSuccess(code: string, callbackURL: string) {
success = true;
setTimeout(() => {
window.location.href = `${client!.callbackURL}?code=${code}&state=${state}`;
window.location.href = `${callbackURL}?code=${code}&state=${state}`;
}, 1000);
}
</script>
@@ -79,16 +83,17 @@
<SignInWrapper>
<ClientProviderImages {client} {success} error={!!errorMessage} />
<h1 class="font-playfair mt-5 text-3xl font-bold sm:text-4xl">Sign in to {client.name}</h1>
{#if !authorizationRequired}
{#if errorMessage}
<p class="text-muted-foreground mb-10 mt-2">
{#if errorMessage}
{errorMessage}. Please try again.
{:else}
Do you want to sign in to <b>{client.name}</b> with your
<b>{$applicationConfigurationStore.appName}</b> account?
{/if}
{errorMessage}. Please try again.
</p>
{:else}
{/if}
{#if !authorizationRequired && !errorMessage}
<p class="text-muted-foreground mb-10 mt-2">
Do you want to sign in to <b>{client.name}</b> with your
<b>{$applicationConfigurationStore.appName}</b> account?
</p>
{:else if authorizationRequired}
<div transition:slide={{ duration: 300 }}>
<Card.Root class="mb-10 mt-6">
<Card.Header class="pb-5">

View File

@@ -22,24 +22,28 @@
</script>
<section>
<div class="h-screen w-full">
<main class="flex min-h-screen flex-1 flex-col gap-4 bg-muted/40 p-4 md:gap-8 md:p-10">
<div class="mx-auto grid w-full max-w-[1440px] gap-2">
<h1 class="text-3xl font-semibold">Settings</h1>
</div>
<div
class="mx-auto grid w-full max-w-[1440px] items-start gap-6 md:grid-cols-[180px_1fr] lg:grid-cols-[250px_1fr]"
>
<nav class="grid gap-4 text-sm text-muted-foreground">
{#each links as { href, label }}
<a {href} class={$page.url.pathname.startsWith(href) ? 'font-bold text-primary' : ''}>
{label}
</a>
{/each}
</nav>
<div class="flex flex-col gap-5">
{@render children()}
<div class="bg-muted/40 h-screen w-full">
<main
class="mx-auto flex min-h-screen max-w-[1640px] flex-col gap-x-4 gap-y-10 p-4 md:p-10 lg:flex-row"
>
<div>
<div class="mx-auto grid w-full gap-2">
<h1 class="mb-5 text-3xl font-semibold">Settings</h1>
</div>
<div
class="mx-auto grid items-start gap-6 md:grid-cols-[180px_1fr] lg:grid-cols-[250px_1fr]"
>
<nav class="text-muted-foreground grid gap-4 text-sm">
{#each links as { href, label }}
<a {href} class={$page.url.pathname.startsWith(href) ? 'text-primary font-bold' : ''}>
{label}
</a>
{/each}
</nav>
</div>
</div>
<div class="flex w-full flex-col gap-5">
{@render children()}
</div>
</main>
</div>

View File

@@ -47,7 +47,6 @@
<form onsubmit={onSubmit}>
<div class="flex flex-col gap-5">
<FormInput label="Application Name" bind:input={$inputs.appName} />
<FormInput
label="Session Duration"
description="The duration of a session in minutes before the user has to sign in again."

View File

@@ -1,5 +1,6 @@
<script lang="ts">
import { beforeNavigate } from '$app/navigation';
import { page } from '$app/stores';
import { openConfirmDialog } from '$lib/components/confirm-dialog';
import { Button } from '$lib/components/ui/button';
import * as Card from '$lib/components/ui/card';
@@ -10,13 +11,24 @@
import { axiosErrorToast } from '$lib/utils/error-util';
import { LucideChevronLeft, LucideRefreshCcw } from 'lucide-svelte';
import { toast } from 'svelte-sonner';
import { slide } from 'svelte/transition';
import OidcForm from '../oidc-client-form.svelte';
let { data } = $props();
let client = $state(data);
let showAllDetails = $state(false);
const oidcService = new OidcService();
const setupDetails = {
'Authorization URL': `https://${$page.url.hostname}/authorize`,
'OIDC Discovery URL': `https://${$page.url.hostname}/.well-known/openid-configuration`,
'Token URL': `https://${$page.url.hostname}/api/oidc/token`,
'Userinfo URL': `https://${$page.url.hostname}/api/oidc/userinfo`,
'Certificate URL': `https://${$page.url.hostname}/.well-known/jwks.json`,
PKCE: 'Disabled'
};
async function updateClient(updatedClient: OidcClientCreateWithLogo) {
let success = true;
const dataPromise = oidcService.updateClient(client.id, updatedClient);
@@ -74,23 +86,43 @@
<Card.Title>{client.name}</Card.Title>
</Card.Header>
<Card.Content>
<div class="flex">
<Label class="mb-0 w-44">Client ID</Label>
<span class="text-muted-foreground text-sm" data-testid="client-id"> {client.id}</span>
</div>
<div class="mt-3 flex items-center">
<Label class="mb-0 w-44">Client secret</Label>
<span class="text-muted-foreground text-sm" data-testid="client-secret"
>{$clientSecretStore ?? '••••••••••••••••••••••••••••••••'}</span
>
{#if !$clientSecretStore}
<Button
class="ml-2"
onclick={createClientSecret}
size="sm"
variant="ghost"
aria-label="Create new client secret"><LucideRefreshCcw class="h-3 w-3" /></Button
<div class="flex flex-col">
<div class="mb-2 flex">
<Label class="mb-0 w-44">Client ID</Label>
<span class="text-muted-foreground text-sm" data-testid="client-id"> {client.id}</span>
</div>
<div class="mb-2 mt-1 flex items-center">
<Label class="w-44">Client secret</Label>
<span class="text-muted-foreground text-sm" data-testid="client-secret"
>{$clientSecretStore ?? '••••••••••••••••••••••••••••••••'}</span
>
{#if !$clientSecretStore}
<Button
class="ml-2"
onclick={createClientSecret}
size="sm"
variant="ghost"
aria-label="Create new client secret"><LucideRefreshCcw class="h-3 w-3" /></Button
>
{/if}
</div>
{#if showAllDetails}
<div transition:slide>
{#each Object.entries(setupDetails) as [key, value]}
<div class="mb-5 flex">
<Label class="mb-0 w-44">{key}</Label>
<span class="text-muted-foreground text-sm">{value}</span>
</div>
{/each}
</div>
{/if}
{#if !showAllDetails}
<div class="mt-4 flex justify-center">
<Button on:click={() => (showAllDetails = true)} size="sm" variant="ghost"
>Show more details</Button
>
</div>
{/if}
</div>
</Card.Content>

View File

@@ -0,0 +1,55 @@
<script lang="ts">
import FormInput from '$lib/components/form-input.svelte';
import { Button } from '$lib/components/ui/button';
import { Input } from '$lib/components/ui/input';
import { LucideMinus, LucidePlus } from 'lucide-svelte';
import type { Snippet } from 'svelte';
import type { HTMLAttributes } from 'svelte/elements';
let {
callbackURLs = $bindable(),
error = $bindable(null),
...restProps
}: HTMLAttributes<HTMLDivElement> & {
callbackURLs: string[];
error?: string | null;
children?: Snippet;
} = $props();
const limit = 5;
</script>
<div {...restProps}>
<FormInput label="Callback URLs">
<div class="flex flex-col gap-y-2">
{#each callbackURLs as _, i}
<div class="flex gap-x-2">
<Input data-testid={`callback-url-${i + 1}`} bind:value={callbackURLs[i]} />
{#if callbackURLs.length > 1}
<Button
variant="outline"
size="sm"
on:click={() => callbackURLs = callbackURLs.filter((_, index) => index !== i)}
>
<LucideMinus class="h-4 w-4" />
</Button>
{/if}
</div>
{/each}
</div>
</FormInput>
{#if error}
<p class="mt-1 text-sm text-red-500">{error}</p>
{/if}
{#if callbackURLs.length < limit}
<Button
class="mt-2"
variant="secondary"
size="sm"
on:click={() => callbackURLs = [...callbackURLs, '']}
>
<LucidePlus class="mr-1 h-4 w-4" />
Add another
</Button>
{/if}
</div>

View File

@@ -10,6 +10,7 @@
} from '$lib/types/oidc.type';
import { createForm } from '$lib/utils/form-util';
import { z } from 'zod';
import OidcCallbackUrlInput from './oidc-callback-url-input.svelte';
let {
callback,
@@ -27,12 +28,12 @@
const client: OidcClientCreate = {
name: existingClient?.name || '',
callbackURL: existingClient?.callbackURL || ''
callbackURLs: existingClient?.callbackURLs || [""]
};
const formSchema = z.object({
name: z.string().min(2).max(50),
callbackURL: z.string().url()
callbackURLs: z.array(z.string().url()).nonempty()
});
type FormSchema = typeof formSchema;
@@ -70,32 +71,40 @@
</script>
<form onsubmit={onSubmit}>
<div class="mt-3 grid grid-cols-2 gap-3">
<FormInput label="Name" bind:input={$inputs.name} />
<FormInput label="Callback URL" bind:input={$inputs.callbackURL} />
<div class="mt-3">
<Label for="logo">Logo</Label>
<div class="mt-2 flex items-end gap-3">
{#if logoDataURL}
<div class="h-32 w-32 rounded-2xl bg-muted p-3">
<img class="m-auto max-h-full max-w-full object-contain" src={logoDataURL} alt={`${$inputs.name.value} logo`} />
</div>
{/if}
<div class="flex flex-col gap-2">
<FileInput
id="logo"
variant="secondary"
accept="image/png, image/jpeg, image/svg+xml"
onchange={onLogoChange}
>
<Button variant="secondary">
{existingClient?.hasLogo ? 'Change Logo' : 'Upload Logo'}
</Button>
</FileInput>
{#if logoDataURL}
<Button variant="outline" on:click={resetLogo}>Remove Logo</Button>
{/if}
<div class="flex flex-col gap-3 sm:flex-row">
<FormInput label="Name" class="w-full" bind:input={$inputs.name} />
<OidcCallbackUrlInput
class="w-full"
bind:callbackURLs={$inputs.callbackURLs.value}
bind:error={$inputs.callbackURLs.error}
/>
</div>
<div class="mt-3">
<Label for="logo">Logo</Label>
<div class="mt-2 flex items-end gap-3">
{#if logoDataURL}
<div class="bg-muted h-32 w-32 rounded-2xl p-3">
<img
class="m-auto max-h-full max-w-full object-contain"
src={logoDataURL}
alt={`${$inputs.name.value} logo`}
/>
</div>
{/if}
<div class="flex flex-col gap-2">
<FileInput
id="logo"
variant="secondary"
accept="image/png, image/jpeg, image/svg+xml"
onchange={onLogoChange}
>
<Button variant="secondary">
{existingClient?.hasLogo ? 'Change Logo' : 'Upload Logo'}
</Button>
</FileInput>
{#if logoDataURL}
<Button variant="outline" on:click={resetLogo}>Remove Logo</Button>
{/if}
</div>
</div>
</div>

View File

@@ -26,9 +26,16 @@
};
const formSchema = z.object({
firstName: z.string().min(2).max(50),
lastName: z.string().min(2).max(50),
username: z.string().min(2).max(50),
firstName: z.string().min(2).max(30),
lastName: z.string().min(2).max(30),
username: z
.string()
.min(2)
.max(30)
.regex(
/^[a-z0-9_@.-]+$/,
"Username can only contain lowercase letters, numbers, underscores, dots, hyphens, and '@' symbols"
),
email: z.string().email(),
isAdmin: z.boolean()
});
@@ -66,10 +73,10 @@
<div class="items-top mt-5 flex space-x-2">
<Checkbox id="admin-privileges" bind:checked={$inputs.isAdmin.value} />
<div class="grid gap-1.5 leading-none">
<Label for="admin-privileges" class="text-sm font-medium leading-none mb-0">
<Label for="admin-privileges" class="mb-0 text-sm font-medium leading-none">
Admin Privileges
</Label>
<p class="text-[0.8rem] text-muted-foreground">Admins have full access to the admin panel.</p>
<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">

View File

@@ -10,10 +10,15 @@ test('Update general configuration', async ({ page }) => {
await page.getByLabel('Session Duration').fill('30');
await page.getByRole('button', { name: 'Save' }).first().click();
await expect(page.getByTestId('application-name')).toHaveText('Updated Name');
await expect(page.getByRole('status')).toHaveText(
'Application configuration updated successfully'
);
await expect(page.getByTestId('application-name')).toHaveText('Updated Name');
await page.reload();
await expect(page.getByLabel('Name')).toHaveValue('Updated Name');
await expect(page.getByLabel('Session Duration')).toHaveValue('30');
});
test('Update application images', async ({ page }) => {

Binary file not shown.

Before

Width:  |  Height:  |  Size: 578 KiB

After

Width:  |  Height:  |  Size: 528 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 164 KiB

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 86 KiB

After

Width:  |  Height:  |  Size: 58 KiB

View File

@@ -23,17 +23,18 @@ export const users = {
export const oidcClients = {
nextcloud: {
id: "3654a746-35d4-4321-ac61-0bdcff2b4055",
id: '3654a746-35d4-4321-ac61-0bdcff2b4055',
name: 'Nextcloud',
callbackUrl: 'http://nextcloud/auth/callback'
},
immich: {
id: "606c7782-f2b1-49e5-8ea9-26eb1b06d018",
name: 'Immich',
callbackUrl: 'http://immich/auth/callback'
},
immich: {
id: '606c7782-f2b1-49e5-8ea9-26eb1b06d018',
name: 'Immich',
callbackUrl: 'http://immich/auth/callback'
},
pingvinShare: {
name: 'Pingvin Share',
callbackUrl: 'http://pingvin.share/auth/callback'
callbackUrl: 'http://pingvin.share/auth/callback',
secondCallbackUrl: 'http://pingvin.share/auth/callback2'
}
};

View File

@@ -10,7 +10,11 @@ test('Create OIDC client', async ({ page }) => {
await page.getByRole('button', { name: 'Add OIDC Client' }).click();
await page.getByLabel('Name').fill(oidcClient.name);
await page.getByLabel('Callback URL').fill(oidcClient.callbackUrl);
await page.getByTestId('callback-url-1').fill(oidcClient.callbackUrl);
await page.getByRole('button', { name: 'Add another' }).click();
await page.getByTestId('callback-url-2').fill(oidcClient.secondCallbackUrl!);
await page.getByLabel('logo').setInputFiles('tests/assets/pingvin-share-logo.png');
await page.getByRole('button', { name: 'Save' }).click();
@@ -20,7 +24,8 @@ test('Create OIDC client', async ({ page }) => {
expect(clientId?.length).toBe(36);
expect((await page.getByTestId('client-secret').textContent())?.length).toBe(32);
await expect(page.getByLabel('Name')).toHaveValue(oidcClient.name);
await expect(page.getByLabel('Callback URL')).toHaveValue(oidcClient.callbackUrl);
await expect(page.getByTestId('callback-url-1')).toHaveValue(oidcClient.callbackUrl);
await expect(page.getByTestId('callback-url-2')).toHaveValue(oidcClient.secondCallbackUrl!);
await expect(page.getByRole('img', { name: `${oidcClient.name} logo` })).toBeVisible();
await page.request
.get(`/api/oidc/clients/${clientId}/logo`)
@@ -32,7 +37,7 @@ test('Edit OIDC client', async ({ page }) => {
await page.goto(`/settings/admin/oidc-clients/${oidcClient.id}`);
await page.getByLabel('Name').fill('Nextcloud updated');
await page.getByLabel('Callback URL').fill('http://nextcloud-updated/auth/callback');
await page.getByTestId('callback-url-1').fill('http://nextcloud-updated/auth/callback');
await page.getByLabel('logo').setInputFiles('tests/assets/nextcloud-logo.png');
await page.getByRole('button', { name: 'Save' }).click();

View File

@@ -1,5 +1,5 @@
import axios from 'axios';
export async function cleanupBackend() {
await axios.post('/api/test/reset');
await axios.post('http://localhost/api/test/reset');
}