Compare commits

...

33 Commits

Author SHA1 Message Date
Elias Schneider
9ce82fb205 release: 1.3.1 2025-06-09 22:59:13 +02:00
Elias Schneider
2935236ace fix: change timestamp of client_credentials.sql migration 2025-06-09 22:58:56 +02:00
Elias Schneider
c821b675b8 release: 1.3.0 2025-06-09 21:37:27 +02:00
Elias Schneider
a09d529027 chore: add branch check to release script 2025-06-09 21:37:00 +02:00
Alessandro (Ale) Segala
b62b61fb01 feat: allow introspection and device code endpoints to use Federated Client Credentials (#640) 2025-06-09 21:17:55 +02:00
Alessandro (Ale) Segala
df5c1ed1f8 chore: add docs link and rename to Federated Client Credentials (#636)
Co-authored-by: Elias Schneider <login@eliasschneider.com>
2025-06-09 21:15:37 +02:00
Elias Schneider
f4af35f86b chore(translations): update translations via Crowdin (#642) 2025-06-09 18:36:37 +02:00
Elias Schneider
657a51f7ed fix: misleading text for disable animations option 2025-06-09 18:22:55 +02:00
Elias Schneider
575b2f71e9 fix: use full width for audit log filters 2025-06-09 18:16:53 +02:00
Elias Schneider
97f7326da4 feat: new color theme for the UI 2025-06-09 18:09:13 +02:00
Elias Schneider
242d87a54b chore: upgrade to Shadcn v1.0.0 2025-06-09 18:08:39 +02:00
Kyle Mendell
c111b79147 feat: oidc client data preview (#624)
Co-authored-by: Elias Schneider <login@eliasschneider.com>
2025-06-09 15:46:03 +00:00
Elias Schneider
61bf14225b chore(translations): update translations via Crowdin (#637) 2025-06-09 11:58:14 +02:00
github-actions[bot]
c1e98411b6 chore: update AAGUIDs (#639)
Co-authored-by: stonith404 <58886915+stonith404@users.noreply.github.com>
2025-06-09 11:48:21 +02:00
Elias Schneider
b25e95fc4a ci/cd: add missing attestions permission 2025-06-08 16:15:23 +02:00
Elias Schneider
3cc82d8522 docs: remove difficult to maintain OpenAPI properties 2025-06-08 16:10:42 +02:00
Elias Schneider
ea4e48680c docs: fix pagination API docs 2025-06-08 16:04:58 +02:00
Elias Schneider
f403eed12c ci/cd: add missing permission 2025-06-08 16:03:40 +02:00
Elias Schneider
388a874922 refactor: upgrade to Zod v4 (#623) 2025-06-08 15:44:59 +02:00
Elias Schneider
9a4aab465a chore(translations): update translations via Crowdin (#632) 2025-06-08 15:44:22 +02:00
Kyle Mendell
a052cd6619 ci/cd: add workflow for building 'next' docker image (#633)
Co-authored-by: Elias Schneider <login@eliasschneider.com>
2025-06-08 15:42:41 +02:00
Elias Schneider
31a803b243 chore(translations): add Traditional Chinese files 2025-06-07 21:04:48 +02:00
Elias Schneider
1d2e41c04e chore(translations): update translations via Crowdin (#629) 2025-06-07 21:01:49 +02:00
Elias Schneider
b650d6d423 chore(translations): add Danish language files 2025-06-06 16:27:33 +02:00
Elias Schneider
156aad3057 chore(translations): update translations via Crowdin (#620) 2025-06-06 16:25:37 +02:00
Alessandro (Ale) Segala
05bfe00924 feat: JWT bearer assertions for client authentication (#566)
Co-authored-by: Kyle Mendell <ksm@ofkm.us>
Co-authored-by: Kyle Mendell <kmendell@ofkm.us>
Co-authored-by: Elias Schneider <login@eliasschneider.com>
2025-06-06 12:23:51 +02:00
Mr Snake
035b2c022b feat: add unix socket support (#615) 2025-06-06 07:01:19 +00:00
Elias Schneider
61b62d4612 fix: OIDC client image can't be deleted 2025-06-06 08:50:33 +02:00
Elias Schneider
dc5d7bb2f3 refactor: run fomratter 2025-06-05 22:43:24 +02:00
Elias Schneider
5e9096e328 fix: UI config overridden by env variables don't apply on first start 2025-06-05 22:36:55 +02:00
Elias Schneider
34b4ba514f chore(translations): update translations via Crowdin (#614) 2025-06-05 15:42:28 +02:00
Elias Schneider
d217083059 feat: add API endpoint for user authorized clients 2025-06-04 09:23:44 +02:00
Elias Schneider
bdcef60cab fix: don't load app config and user on every route change 2025-06-04 08:52:34 +02:00
96 changed files with 4620 additions and 914 deletions

78
.github/workflows/build-next.yml vendored Normal file
View File

@@ -0,0 +1,78 @@
name: Build Next Image
on:
push:
branches:
- main
jobs:
build-next:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
id-token: write
attestations: write
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 22
cache: "npm"
cache-dependency-path: frontend/package-lock.json
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version-file: "backend/go.mod"
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Set DOCKER_IMAGE_NAME
run: |
# Lowercase REPO_OWNER which is required for containers
REPO_OWNER=${{ github.repository_owner }}
DOCKER_IMAGE_NAME="ghcr.io/${REPO_OWNER,,}/pocket-id"
echo "DOCKER_IMAGE_NAME=${DOCKER_IMAGE_NAME}" >>${GITHUB_ENV}
- name: Login to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Install frontend dependencies
working-directory: frontend
run: npm ci
- name: Build frontend
working-directory: frontend
run: npm run build
- name: Build binaries
run: sh scripts/development/build-binaries.sh
- name: Build and push container image
id: build-push-image
uses: docker/build-push-action@v6
with:
context: .
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ env.DOCKER_IMAGE_NAME }}:next
file: Dockerfile-prebuilt
- name: Container image attestation
uses: actions/attest-build-provenance@v2
with:
subject-name: "${{ env.DOCKER_IMAGE_NAME }}"
subject-digest: ${{ steps.build-push-image.outputs.digest }}
push-to-registry: true

1
.gitignore vendored
View File

@@ -10,6 +10,7 @@ node_modules
/frontend/build
/backend/bin
pocket-id
/tests/test-results/*.json
# OS
.DS_Store

View File

@@ -1 +1 @@
1.2.0
1.3.1

View File

@@ -1,3 +1,31 @@
## [](https://github.com/pocket-id/pocket-id/compare/v1.3.0...v) (2025-06-09)
### Bug Fixes
* change timestamp of `client_credentials.sql` migration ([2935236](https://github.com/pocket-id/pocket-id/commit/2935236acee9c78c2fe6787ec8b5f53ae0eca047))
## [](https://github.com/pocket-id/pocket-id/compare/v1.2.0...v) (2025-06-09)
### Features
* add API endpoint for user authorized clients ([d217083](https://github.com/pocket-id/pocket-id/commit/d217083059120171d5c555b09eefe6ba3c8a8d42))
* add unix socket support ([#615](https://github.com/pocket-id/pocket-id/issues/615)) ([035b2c0](https://github.com/pocket-id/pocket-id/commit/035b2c022bfd2b98f13355ec7a126e0f1ab3ebd8))
* allow introspection and device code endpoints to use Federated Client Credentials ([#640](https://github.com/pocket-id/pocket-id/issues/640)) ([b62b61f](https://github.com/pocket-id/pocket-id/commit/b62b61fb017dba31a6fc612c138bebf370d3956c))
* JWT bearer assertions for client authentication ([#566](https://github.com/pocket-id/pocket-id/issues/566)) ([05bfe00](https://github.com/pocket-id/pocket-id/commit/05bfe0092450c9bc26d03c6a54c21050eef8f63a))
* new color theme for the UI ([97f7326](https://github.com/pocket-id/pocket-id/commit/97f7326da40265a954340d519661969530f097a0))
* oidc client data preview ([#624](https://github.com/pocket-id/pocket-id/issues/624)) ([c111b79](https://github.com/pocket-id/pocket-id/commit/c111b7914731a3cafeaa55102b515f84a1ad74dc))
### Bug Fixes
* don't load app config and user on every route change ([bdcef60](https://github.com/pocket-id/pocket-id/commit/bdcef60cab6a61e1717661e918c42e3650d23fee))
* misleading text for disable animations option ([657a51f](https://github.com/pocket-id/pocket-id/commit/657a51f7ed8a77e8a937971032091058aacfded6))
* OIDC client image can't be deleted ([61b62d4](https://github.com/pocket-id/pocket-id/commit/61b62d461200c1359a16c92c9c62530362a4785c))
* UI config overridden by env variables don't apply on first start ([5e9096e](https://github.com/pocket-id/pocket-id/commit/5e9096e328741ba2a0e03835927fe62e6aea2a89))
* use full width for audit log filters ([575b2f7](https://github.com/pocket-id/pocket-id/commit/575b2f71e9f1ff9c4f6fd411b136676c213b7201))
## [](https://github.com/pocket-id/pocket-id/compare/v1.1.0...v) (2025-06-03)

View File

@@ -20,7 +20,8 @@ require (
github.com/google/uuid v1.6.0
github.com/hashicorp/go-uuid v1.0.3
github.com/joho/godotenv v1.5.1
github.com/lestrrat-go/jwx/v3 v3.0.0-beta1
github.com/lestrrat-go/httprc/v3 v3.0.0-beta2
github.com/lestrrat-go/jwx/v3 v3.0.1
github.com/mileusna/useragent v1.3.5
github.com/oschwald/maxminddb-golang/v2 v2.0.0-beta.2
github.com/stretchr/testify v1.10.0
@@ -32,7 +33,7 @@ require (
go.opentelemetry.io/otel/sdk v1.35.0
go.opentelemetry.io/otel/sdk/metric v1.35.0
go.opentelemetry.io/otel/trace v1.35.0
golang.org/x/crypto v0.36.0
golang.org/x/crypto v0.37.0
golang.org/x/image v0.24.0
golang.org/x/time v0.9.0
gorm.io/driver/postgres v1.5.11
@@ -77,9 +78,8 @@ require (
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.2.10 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/lestrrat-go/blackmagic v1.0.2 // indirect
github.com/lestrrat-go/blackmagic v1.0.3 // indirect
github.com/lestrrat-go/httpcc v1.0.1 // indirect
github.com/lestrrat-go/httprc/v3 v3.0.0-beta1 // indirect
github.com/lestrrat-go/option v1.0.1 // indirect
github.com/lib/pq v1.10.9 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
@@ -123,7 +123,7 @@ require (
golang.org/x/net v0.38.0 // indirect
golang.org/x/sync v0.14.0 // indirect
golang.org/x/sys v0.33.0 // indirect
golang.org/x/text v0.23.0 // indirect
golang.org/x/text v0.24.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20250218202821-56aae31c358a // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20250218202821-56aae31c358a // indirect
google.golang.org/grpc v1.71.0 // indirect

View File

@@ -164,14 +164,14 @@ github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/lestrrat-go/blackmagic v1.0.2 h1:Cg2gVSc9h7sz9NOByczrbUvLopQmXrfFx//N+AkAr5k=
github.com/lestrrat-go/blackmagic v1.0.2/go.mod h1:UrEqBzIR2U6CnzVyUtfM6oZNMt/7O7Vohk2J0OGSAtU=
github.com/lestrrat-go/blackmagic v1.0.3 h1:94HXkVLxkZO9vJI/w2u1T0DAoprShFd13xtnSINtDWs=
github.com/lestrrat-go/blackmagic v1.0.3/go.mod h1:6AWFyKNNj0zEXQYfTMPfZrAXUWUfTIZ5ECEUEJaijtw=
github.com/lestrrat-go/httpcc v1.0.1 h1:ydWCStUeJLkpYyjLDHihupbn2tYmZ7m22BGkcvZZrIE=
github.com/lestrrat-go/httpcc v1.0.1/go.mod h1:qiltp3Mt56+55GPVCbTdM9MlqhvzyuL6W/NMDA8vA5E=
github.com/lestrrat-go/httprc/v3 v3.0.0-beta1 h1:pzDjP9dSONCFQC/AE3mWUnHILGiYPiMKzQIS+weKJXA=
github.com/lestrrat-go/httprc/v3 v3.0.0-beta1/go.mod h1:wdsgouffPvWPEYh8t7PRH/PidR5sfVqt0na4Nhj60Ms=
github.com/lestrrat-go/jwx/v3 v3.0.0-beta1 h1:Iqjb8JvWjh34Jv8DeM2wQ1aG5fzFBzwQu7rlqwuJB0I=
github.com/lestrrat-go/jwx/v3 v3.0.0-beta1/go.mod h1:ak32WoNtHE0aLowVWBcCvXngcAnW4tuC0YhFwOr/kwc=
github.com/lestrrat-go/httprc/v3 v3.0.0-beta2 h1:SDxjGoH7qj0nBXVrcrxX8eD94wEnjR+EEuqqmeqQYlY=
github.com/lestrrat-go/httprc/v3 v3.0.0-beta2/go.mod h1:Nwo81sMxE0DcvTB+rJyynNhv/DUu2yZErV7sscw9pHE=
github.com/lestrrat-go/jwx/v3 v3.0.1 h1:fH3T748FCMbXoF9UXXNS9i0q6PpYyJZK/rKSbkt2guY=
github.com/lestrrat-go/jwx/v3 v3.0.1/go.mod h1:XP2WqxMOSzHSyf3pfibCcfsLqbomxakAnNqiuaH8nwo=
github.com/lestrrat-go/option v1.0.1 h1:oAzP2fvZGQKWkvHa1/SAcFolBEca1oN+mQ7eooNBEYU=
github.com/lestrrat-go/option v1.0.1/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I=
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
@@ -309,8 +309,8 @@ golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliY
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34=
golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc=
golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE=
golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc=
golang.org/x/exp v0.0.0-20250506013437-ce4c2cf36ca6 h1:y5zboxd6LQAqYIhHnB48p0ByQ/GnQx2BE33L8BOHQkI=
golang.org/x/exp v0.0.0-20250506013437-ce4c2cf36ca6/go.mod h1:U6Lno4MTRCDY+Ba7aCcauB9T60gsv5s4ralQzP72ZoQ=
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
@@ -377,8 +377,8 @@ golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY=
golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4=
golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0=
golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU=
golang.org/x/time v0.9.0 h1:EsRrnYcQiGH+5FfbgvV4AP7qEZstoyrHB0DzarOQ4ZY=
golang.org/x/time v0.9.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=

View File

@@ -3,6 +3,8 @@
package bootstrap
import (
"log"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
@@ -14,7 +16,12 @@ import (
func init() {
registerTestControllers = []func(apiGroup *gin.RouterGroup, db *gorm.DB, svc *services){
func(apiGroup *gin.RouterGroup, db *gorm.DB, svc *services) {
testService := service.NewTestService(db, svc.appConfigService, svc.jwtService, svc.ldapService)
testService, err := service.NewTestService(db, svc.appConfigService, svc.jwtService, svc.ldapService)
if err != nil {
log.Fatalf("failed to initialize test service: %v", err)
return
}
controller.NewTestController(apiGroup, testService)
},
}

View File

@@ -101,21 +101,27 @@ func initRouterInternal(db *gorm.DB, svc *services) (utils.Service, error) {
// Set up the server
srv := &http.Server{
Addr: net.JoinHostPort(common.EnvConfig.Host, common.EnvConfig.Port),
MaxHeaderBytes: 1 << 20,
ReadHeaderTimeout: 10 * time.Second,
Handler: r,
}
// Set up the listener
listener, err := net.Listen("tcp", srv.Addr)
network := "tcp"
addr := net.JoinHostPort(common.EnvConfig.Host, common.EnvConfig.Port)
if common.EnvConfig.UnixSocket != "" {
network = "unix"
addr = common.EnvConfig.UnixSocket
}
listener, err := net.Listen(network, addr)
if err != nil {
return nil, fmt.Errorf("failed to create TCP listener: %w", err)
return nil, fmt.Errorf("failed to create %s listener: %w", network, err)
}
// Service runner function
runFn := func(ctx context.Context) error {
log.Printf("Server listening on %s", srv.Addr)
log.Printf("Server listening on %s", addr)
// Start the server in a background goroutine
go func() {

View File

@@ -26,15 +26,14 @@ type services struct {
}
// Initializes all services
// The context should be used by services only for initialization, and not for running
func initServices(initCtx context.Context, db *gorm.DB, httpClient *http.Client) (svc *services, err error) {
func initServices(ctx context.Context, db *gorm.DB, httpClient *http.Client) (svc *services, err error) {
svc = &services{}
svc.appConfigService = service.NewAppConfigService(initCtx, db)
svc.appConfigService = service.NewAppConfigService(ctx, db)
svc.emailService, err = service.NewEmailService(db, svc.appConfigService)
if err != nil {
return nil, fmt.Errorf("unable to create email service: %w", err)
return nil, fmt.Errorf("failed to create email service: %w", err)
}
svc.geoLiteService = service.NewGeoLiteService(httpClient)
@@ -42,7 +41,12 @@ func initServices(initCtx context.Context, db *gorm.DB, httpClient *http.Client)
svc.jwtService = service.NewJwtService(svc.appConfigService)
svc.userService = service.NewUserService(db, svc.jwtService, svc.auditLogService, svc.emailService, svc.appConfigService)
svc.customClaimService = service.NewCustomClaimService(db)
svc.oidcService = service.NewOidcService(db, svc.jwtService, svc.appConfigService, svc.auditLogService, svc.customClaimService)
svc.oidcService, err = service.NewOidcService(ctx, db, svc.jwtService, svc.appConfigService, svc.auditLogService, svc.customClaimService)
if err != nil {
return nil, fmt.Errorf("failed to create OIDC service: %w", err)
}
svc.userGroupService = service.NewUserGroupService(db, svc.appConfigService)
svc.ldapService = service.NewLdapService(db, httpClient, svc.appConfigService, svc.userService, svc.userGroupService)
svc.apiKeyService = service.NewApiKeyService(db, svc.emailService)

View File

@@ -32,6 +32,7 @@ type EnvConfigSchema struct {
KeysPath string `env:"KEYS_PATH"`
Port string `env:"PORT"`
Host string `env:"HOST"`
UnixSocket string `env:"UNIX_SOCKET"`
MaxMindLicenseKey string `env:"MAXMIND_LICENSE_KEY"`
GeoLiteDBPath string `env:"GEOLITE_DB_PATH"`
GeoLiteDBUrl string `env:"GEOLITE_DB_URL"`
@@ -51,6 +52,7 @@ var EnvConfig = &EnvConfigSchema{
AppURL: "http://localhost:1411",
Port: "1411",
Host: "0.0.0.0",
UnixSocket: "",
MaxMindLicenseKey: "",
GeoLiteDBPath: "data/GeoLite2-City.mmdb",
GeoLiteDBUrl: MaxMindGeoLiteCityUrl,

View File

@@ -65,6 +65,11 @@ type OidcClientSecretInvalidError struct{}
func (e *OidcClientSecretInvalidError) Error() string { return "invalid client secret" }
func (e *OidcClientSecretInvalidError) HttpStatusCode() int { return 400 }
type OidcClientAssertionInvalidError struct{}
func (e *OidcClientAssertionInvalidError) Error() string { return "invalid client assertion" }
func (e *OidcClientAssertionInvalidError) HttpStatusCode() int { return 400 }
type OidcInvalidAuthorizationCodeError struct{}
func (e *OidcInvalidAuthorizationCodeError) Error() string { return "invalid authorization code" }

View File

@@ -38,10 +38,10 @@ func NewApiKeyController(group *gin.RouterGroup, authMiddleware *middleware.Auth
// @Summary List API keys
// @Description Get a paginated list of API keys belonging to the current user
// @Tags API Keys
// @Param page query int false "Page number, starting from 1" default(1)
// @Param limit query int false "Number of items per page" default(10)
// @Param sort_column query string false "Column to sort by" default("created_at")
// @Param sort_direction query string false "Sort direction (asc or desc)" default("desc")
// @Param pagination[page] query int false "Page number for pagination" default(1)
// @Param pagination[limit] query int false "Number of items per page" default(20)
// @Param sort[column] query string false "Column to sort by"
// @Param sort[direction] query string false "Sort direction (asc or desc)" default("asc")
// @Success 200 {object} dto.Paginated[dto.ApiKeyDto]
// @Router /api/api-keys [get]
func (c *ApiKeyController) listApiKeysHandler(ctx *gin.Context) {

View File

@@ -57,7 +57,6 @@ type AppConfigController struct {
// @Accept json
// @Produce json
// @Success 200 {array} dto.PublicAppConfigVariableDto
// @Failure 500 {object} object "{"error": "error message"}"
// @Router /application-configuration [get]
func (acc *AppConfigController) listAppConfigHandler(c *gin.Context) {
configuration := acc.appConfigService.ListAppConfig(false)
@@ -85,7 +84,6 @@ func (acc *AppConfigController) listAppConfigHandler(c *gin.Context) {
// @Accept json
// @Produce json
// @Success 200 {array} dto.AppConfigVariableDto
// @Security BearerAuth
// @Router /application-configuration/all [get]
func (acc *AppConfigController) listAllAppConfigHandler(c *gin.Context) {
configuration := acc.appConfigService.ListAppConfig(true)
@@ -107,7 +105,6 @@ func (acc *AppConfigController) listAllAppConfigHandler(c *gin.Context) {
// @Produce json
// @Param body body dto.AppConfigUpdateDto true "Application Configuration"
// @Success 200 {array} dto.AppConfigVariableDto
// @Security BearerAuth
// @Router /api/application-configuration [put]
func (acc *AppConfigController) updateAppConfigHandler(c *gin.Context) {
var input dto.AppConfigUpdateDto
@@ -164,7 +161,6 @@ func (acc *AppConfigController) getLogoHandler(c *gin.Context) {
// @Tags Application Configuration
// @Produce image/x-icon
// @Success 200 {file} binary "Favicon image"
// @Failure 404 {object} object "{"error": "File not found"}"
// @Router /api/application-configuration/favicon [get]
func (acc *AppConfigController) getFaviconHandler(c *gin.Context) {
acc.getImage(c, "favicon", "ico")
@@ -177,7 +173,6 @@ func (acc *AppConfigController) getFaviconHandler(c *gin.Context) {
// @Produce image/png
// @Produce image/jpeg
// @Success 200 {file} binary "Background image"
// @Failure 404 {object} object "{"error": "File not found"}"
// @Router /api/application-configuration/background-image [get]
func (acc *AppConfigController) getBackgroundImageHandler(c *gin.Context) {
imageType := acc.appConfigService.GetDbConfig().BackgroundImageType.Value
@@ -192,7 +187,6 @@ func (acc *AppConfigController) getBackgroundImageHandler(c *gin.Context) {
// @Param light query boolean false "Light mode logo (true) or dark mode logo (false)"
// @Param file formData file true "Logo image file"
// @Success 204 "No Content"
// @Security BearerAuth
// @Router /api/application-configuration/logo [put]
func (acc *AppConfigController) updateLogoHandler(c *gin.Context) {
dbConfig := acc.appConfigService.GetDbConfig()
@@ -218,7 +212,6 @@ func (acc *AppConfigController) updateLogoHandler(c *gin.Context) {
// @Accept multipart/form-data
// @Param file formData file true "Favicon file (.ico)"
// @Success 204 "No Content"
// @Security BearerAuth
// @Router /api/application-configuration/favicon [put]
func (acc *AppConfigController) updateFaviconHandler(c *gin.Context) {
file, err := c.FormFile("file")
@@ -242,7 +235,6 @@ func (acc *AppConfigController) updateFaviconHandler(c *gin.Context) {
// @Accept multipart/form-data
// @Param file formData file true "Background image file"
// @Success 204 "No Content"
// @Security BearerAuth
// @Router /api/application-configuration/background-image [put]
func (acc *AppConfigController) updateBackgroundImageHandler(c *gin.Context) {
imageType := acc.appConfigService.GetDbConfig().BackgroundImageType.Value
@@ -280,7 +272,6 @@ func (acc *AppConfigController) updateImage(c *gin.Context, imageName string, ol
// @Description Manually trigger LDAP synchronization
// @Tags Application Configuration
// @Success 204 "No Content"
// @Security BearerAuth
// @Router /api/application-configuration/sync-ldap [post]
func (acc *AppConfigController) syncLdapHandler(c *gin.Context) {
err := acc.ldapService.SyncAll(c.Request.Context())
@@ -297,7 +288,6 @@ func (acc *AppConfigController) syncLdapHandler(c *gin.Context) {
// @Description Send a test email to verify email configuration
// @Tags Application Configuration
// @Success 204 "No Content"
// @Security BearerAuth
// @Router /api/application-configuration/test-email [post]
func (acc *AppConfigController) testEmailHandler(c *gin.Context) {
userID := c.GetString("userID")

View File

@@ -34,10 +34,10 @@ type AuditLogController struct {
// @Summary List audit logs
// @Description Get a paginated list of audit logs for the current user
// @Tags Audit Logs
// @Param page query int false "Page number, starting from 1" default(1)
// @Param limit query int false "Number of items per page" default(10)
// @Param sort_column query string false "Column to sort by" default("created_at")
// @Param sort_direction query string false "Sort direction (asc or desc)" default("desc")
// @Param pagination[page] query int false "Page number for pagination" default(1)
// @Param pagination[limit] query int false "Number of items per page" default(20)
// @Param sort[column] query string false "Column to sort by"
// @Param sort[direction] query string false "Sort direction (asc or desc)" default("asc")
// @Success 200 {object} dto.Paginated[dto.AuditLogDto]
// @Router /api/audit-logs [get]
func (alc *AuditLogController) listAuditLogsForUserHandler(c *gin.Context) {
@@ -82,13 +82,13 @@ func (alc *AuditLogController) listAuditLogsForUserHandler(c *gin.Context) {
// @Summary List all audit logs
// @Description Get a paginated list of all audit logs (admin only)
// @Tags Audit Logs
// @Param page query int false "Page number, starting from 1" default(1)
// @Param limit query int false "Number of items per page" default(10)
// @Param sort_column query string false "Column to sort by" default("created_at")
// @Param sort_direction query string false "Sort direction (asc or desc)" default("desc")
// @Param user_id query string false "Filter by user ID"
// @Param event query string false "Filter by event type"
// @Param client_name query string false "Filter by client name"
// @Param pagination[page] query int false "Page number for pagination" default(1)
// @Param pagination[limit] query int false "Number of items per page" default(20)
// @Param sort[column] query string false "Column to sort by"
// @Param sort[direction] query string false "Sort direction (asc or desc)" default("asc")
// @Param filters[userId] query string false "Filter by user ID"
// @Param filters[event] query string false "Filter by event type"
// @Param filters[clientName] query string false "Filter by client name"
// @Success 200 {object} dto.Paginated[dto.AuditLogDto]
// @Router /api/audit-logs/all [get]
func (alc *AuditLogController) listAllAuditLogsHandler(c *gin.Context) {

View File

@@ -35,10 +35,6 @@ type CustomClaimController struct {
// @Tags Custom Claims
// @Produce json
// @Success 200 {array} string "List of suggested custom claim names"
// @Failure 401 {object} object "Unauthorized"
// @Failure 403 {object} object "Forbidden"
// @Failure 500 {object} object "Internal server error"
// @Security BearerAuth
// @Router /api/custom-claims/suggestions [get]
func (ccc *CustomClaimController) getSuggestionsHandler(c *gin.Context) {
claims, err := ccc.customClaimService.GetSuggestions(c.Request.Context())
@@ -93,7 +89,6 @@ func (ccc *CustomClaimController) UpdateCustomClaimsForUserHandler(c *gin.Contex
// @Param userGroupId path string true "User Group ID"
// @Param claims body []dto.CustomClaimCreateDto true "List of custom claims to set for the user group"
// @Success 200 {array} dto.CustomClaimDto "Updated custom claims"
// @Security BearerAuth
// @Router /api/custom-claims/user-group/{userGroupId} [put]
func (ccc *CustomClaimController) UpdateCustomClaimsForUserGroupHandler(c *gin.Context) {
var input []dto.CustomClaimCreateDto

View File

@@ -14,6 +14,10 @@ func NewTestController(group *gin.RouterGroup, testService *service.TestService)
testController := &TestController{TestService: testService}
group.POST("/test/reset", testController.resetAndSeedHandler)
group.POST("/test/refreshtoken", testController.signRefreshToken)
group.GET("/externalidp/jwks.json", testController.externalIdPJWKS)
group.POST("/externalidp/sign", testController.externalIdPSignToken)
}
type TestController struct {
@@ -21,6 +25,15 @@ type TestController struct {
}
func (tc *TestController) resetAndSeedHandler(c *gin.Context) {
var baseURL string
if c.Request.TLS != nil {
baseURL = "https://" + c.Request.Host
} else {
baseURL = "http://" + c.Request.Host
}
skipLdap := c.Query("skip-ldap") == "true"
if err := tc.TestService.ResetDatabase(); err != nil {
_ = c.Error(err)
return
@@ -31,7 +44,7 @@ func (tc *TestController) resetAndSeedHandler(c *gin.Context) {
return
}
if err := tc.TestService.SeedDatabase(); err != nil {
if err := tc.TestService.SeedDatabase(baseURL); err != nil {
_ = c.Error(err)
return
}
@@ -41,17 +54,71 @@ func (tc *TestController) resetAndSeedHandler(c *gin.Context) {
return
}
if err := tc.TestService.SetLdapTestConfig(c.Request.Context()); err != nil {
_ = c.Error(err)
return
}
if !skipLdap {
if err := tc.TestService.SetLdapTestConfig(c.Request.Context()); err != nil {
_ = c.Error(err)
return
}
if err := tc.TestService.SyncLdap(c.Request.Context()); err != nil {
_ = c.Error(err)
return
if err := tc.TestService.SyncLdap(c.Request.Context()); err != nil {
_ = c.Error(err)
return
}
}
tc.TestService.SetJWTKeys()
c.Status(http.StatusNoContent)
}
func (tc *TestController) externalIdPJWKS(c *gin.Context) {
jwks, err := tc.TestService.GetExternalIdPJWKS()
if err != nil {
_ = c.Error(err)
return
}
c.JSON(http.StatusOK, jwks)
}
func (tc *TestController) externalIdPSignToken(c *gin.Context) {
var input struct {
Aud string `json:"aud"`
Iss string `json:"iss"`
Sub string `json:"sub"`
}
err := c.ShouldBindJSON(&input)
if err != nil {
_ = c.Error(err)
return
}
token, err := tc.TestService.SignExternalIdPToken(input.Iss, input.Sub, input.Aud)
if err != nil {
_ = c.Error(err)
return
}
c.Writer.WriteString(token)
}
func (tc *TestController) signRefreshToken(c *gin.Context) {
var input struct {
UserID string `json:"user"`
ClientID string `json:"client"`
RefreshToken string `json:"rt"`
}
err := c.ShouldBindJSON(&input)
if err != nil {
_ = c.Error(err)
return
}
token, err := tc.TestService.SignRefreshToken(input.UserID, input.ClientID, input.RefreshToken)
if err != nil {
_ = c.Error(err)
return
}
c.Writer.WriteString(token)
}

View File

@@ -7,14 +7,14 @@ import (
"net/url"
"strings"
"github.com/pocket-id/pocket-id/backend/internal/common"
"github.com/pocket-id/pocket-id/backend/internal/utils/cookie"
"github.com/gin-gonic/gin"
"github.com/pocket-id/pocket-id/backend/internal/common"
"github.com/pocket-id/pocket-id/backend/internal/dto"
"github.com/pocket-id/pocket-id/backend/internal/middleware"
"github.com/pocket-id/pocket-id/backend/internal/service"
"github.com/pocket-id/pocket-id/backend/internal/utils"
"github.com/pocket-id/pocket-id/backend/internal/utils/cookie"
)
// NewOidcController creates a new controller for OIDC related endpoints
@@ -48,9 +48,14 @@ func NewOidcController(group *gin.RouterGroup, authMiddleware *middleware.AuthMi
group.DELETE("/oidc/clients/:id/logo", oc.deleteClientLogoHandler)
group.POST("/oidc/clients/:id/logo", authMiddleware.Add(), fileSizeLimitMiddleware.Add(2<<20), oc.updateClientLogoHandler)
group.GET("/oidc/clients/:id/preview/:userId", authMiddleware.Add(), oc.getClientPreviewHandler)
group.POST("/oidc/device/authorize", oc.deviceAuthorizationHandler)
group.POST("/oidc/device/verify", authMiddleware.WithAdminNotRequired().Add(), oc.verifyDeviceCodeHandler)
group.GET("/oidc/device/info", authMiddleware.WithAdminNotRequired().Add(), oc.getDeviceCodeInfoHandler)
group.GET("/oidc/users/me/clients", authMiddleware.WithAdminNotRequired().Add(), oc.listOwnAuthorizedClientsHandler)
group.GET("/oidc/users/:id/clients", authMiddleware.Add(), oc.listAuthorizedClientsHandler)
}
type OidcController struct {
@@ -66,7 +71,6 @@ type OidcController struct {
// @Produce json
// @Param request body dto.AuthorizeOidcClientRequestDto true "Authorization request parameters"
// @Success 200 {object} dto.AuthorizeOidcClientResponseDto "Authorization code and callback URL"
// @Security BearerAuth
// @Router /api/oidc/authorize [post]
func (oc *OidcController) authorizeHandler(c *gin.Context) {
var input dto.AuthorizeOidcClientRequestDto
@@ -97,7 +101,6 @@ func (oc *OidcController) authorizeHandler(c *gin.Context) {
// @Produce json
// @Param request body dto.AuthorizationRequiredDto true "Authorization check parameters"
// @Success 200 {object} object "{ \"authorizationRequired\": true/false }"
// @Security BearerAuth
// @Router /api/oidc/authorization-required [post]
func (oc *OidcController) authorizationConfirmationRequiredHandler(c *gin.Context) {
var input dto.AuthorizationRequiredDto
@@ -121,11 +124,13 @@ func (oc *OidcController) authorizationConfirmationRequiredHandler(c *gin.Contex
// @Tags OIDC
// @Produce json
// @Param client_id formData string false "Client ID (if not using Basic Auth)"
// @Param client_secret formData string false "Client secret (if not using Basic Auth)"
// @Param client_secret formData string false "Client secret (if not using Basic Auth or client assertions)"
// @Param code formData string false "Authorization code (required for 'authorization_code' grant)"
// @Param grant_type formData string true "Grant type ('authorization_code' or 'refresh_token')"
// @Param code_verifier formData string false "PKCE code verifier (for authorization_code with PKCE)"
// @Param refresh_token formData string false "Refresh token (required for 'refresh_token' grant)"
// @Param client_assertion formData string false "Client assertion type (for 'authorization_code' grant when using client assertions)"
// @Param client_assertion_type formData string false "Client assertion type (for 'authorization_code' grant when using client assertions)"
// @Success 200 {object} dto.OidcTokenResponseDto "Token response with access_token and optional id_token and refresh_token"
// @Router /api/oidc/token [post]
func (oc *OidcController) createTokensHandler(c *gin.Context) {
@@ -195,7 +200,7 @@ func (oc *OidcController) userInfoHandler(c *gin.Context) {
return
}
token, err := oc.jwtService.VerifyOauthAccessToken(authToken)
token, err := oc.jwtService.VerifyOAuthAccessToken(authToken)
if err != nil {
_ = c.Error(err)
return
@@ -224,7 +229,6 @@ func (oc *OidcController) userInfoHandler(c *gin.Context) {
// @Description End user session and handle OIDC logout
// @Tags OIDC
// @Accept application/x-www-form-urlencoded
// @Produce html
// @Param id_token_hint query string false "ID token"
// @Param post_logout_redirect_uri query string false "URL to redirect to after logout"
// @Param state query string false "State parameter to include in the redirect"
@@ -304,9 +308,21 @@ func (oc *OidcController) introspectTokenHandler(c *gin.Context) {
// find valid tokens) while still allowing it to be used by an application that is
// supposed to interact with our IdP (since that needs to have a client_id
// and client_secret anyway).
clientID, clientSecret, _ := c.Request.BasicAuth()
var (
creds service.ClientAuthCredentials
ok bool
)
creds.ClientID, creds.ClientSecret, ok = c.Request.BasicAuth()
if !ok {
// If there's no basic auth, check if we have a bearer token
bearer, ok := utils.BearerAuth(c.Request)
if ok {
creds.ClientAssertionType = service.ClientAssertionTypeJWTBearer
creds.ClientAssertion = bearer
}
}
response, err := oc.oidcService.IntrospectToken(c.Request.Context(), clientID, clientSecret, input.Token)
response, err := oc.oidcService.IntrospectToken(c.Request.Context(), creds, input.Token)
if err != nil {
_ = c.Error(err)
return
@@ -348,7 +364,6 @@ func (oc *OidcController) getClientMetaDataHandler(c *gin.Context) {
// @Produce json
// @Param id path string true "Client ID"
// @Success 200 {object} dto.OidcClientWithAllowedUserGroupsDto "Client information"
// @Security BearerAuth
// @Router /api/oidc/clients/{id} [get]
func (oc *OidcController) getClientHandler(c *gin.Context) {
clientId := c.Param("id")
@@ -360,12 +375,12 @@ func (oc *OidcController) getClientHandler(c *gin.Context) {
clientDto := dto.OidcClientWithAllowedUserGroupsDto{}
err = dto.MapStruct(client, &clientDto)
if err == nil {
c.JSON(http.StatusOK, clientDto)
if err != nil {
_ = c.Error(err)
return
}
_ = c.Error(err)
c.JSON(http.StatusOK, clientDto)
}
// listClientsHandler godoc
@@ -373,12 +388,11 @@ func (oc *OidcController) getClientHandler(c *gin.Context) {
// @Description Get a paginated list of OIDC clients with optional search and sorting
// @Tags OIDC
// @Param search query string false "Search term to filter clients by name"
// @Param page query int false "Page number, starting from 1" default(1)
// @Param limit query int false "Number of items per page" default(10)
// @Param sort_column query string false "Column to sort by" default("name")
// @Param sort_direction query string false "Sort direction (asc or desc)" default("asc")
// @Param pagination[page] query int false "Page number for pagination" default(1)
// @Param pagination[limit] query int false "Number of items per page" default(20)
// @Param sort[column] query string false "Column to sort by"
// @Param sort[direction] query string false "Sort direction (asc or desc)" default("asc")
// @Success 200 {object} dto.Paginated[dto.OidcClientWithAllowedGroupsCountDto]
// @Security BearerAuth
// @Router /api/oidc/clients [get]
func (oc *OidcController) listClientsHandler(c *gin.Context) {
searchTerm := c.Query("search")
@@ -424,7 +438,6 @@ func (oc *OidcController) listClientsHandler(c *gin.Context) {
// @Produce json
// @Param client body dto.OidcClientCreateDto true "Client information"
// @Success 201 {object} dto.OidcClientWithAllowedUserGroupsDto "Created client"
// @Security BearerAuth
// @Router /api/oidc/clients [post]
func (oc *OidcController) createClientHandler(c *gin.Context) {
var input dto.OidcClientCreateDto
@@ -454,7 +467,6 @@ func (oc *OidcController) createClientHandler(c *gin.Context) {
// @Tags OIDC
// @Param id path string true "Client ID"
// @Success 204 "No Content"
// @Security BearerAuth
// @Router /api/oidc/clients/{id} [delete]
func (oc *OidcController) deleteClientHandler(c *gin.Context) {
err := oc.oidcService.DeleteClient(c.Request.Context(), c.Param("id"))
@@ -475,7 +487,6 @@ func (oc *OidcController) deleteClientHandler(c *gin.Context) {
// @Param id path string true "Client ID"
// @Param client body dto.OidcClientCreateDto true "Client information"
// @Success 200 {object} dto.OidcClientWithAllowedUserGroupsDto "Updated client"
// @Security BearerAuth
// @Router /api/oidc/clients/{id} [put]
func (oc *OidcController) updateClientHandler(c *gin.Context) {
var input dto.OidcClientCreateDto
@@ -506,7 +517,6 @@ func (oc *OidcController) updateClientHandler(c *gin.Context) {
// @Produce json
// @Param id path string true "Client ID"
// @Success 200 {object} object "{ \"secret\": \"string\" }"
// @Security BearerAuth
// @Router /api/oidc/clients/{id}/secret [post]
func (oc *OidcController) createClientSecretHandler(c *gin.Context) {
secret, err := oc.oidcService.CreateClientSecret(c.Request.Context(), c.Param("id"))
@@ -545,9 +555,8 @@ func (oc *OidcController) getClientLogoHandler(c *gin.Context) {
// @Tags OIDC
// @Accept multipart/form-data
// @Param id path string true "Client ID"
// @Param file formData file true "Logo image file (PNG, JPG, or SVG, max 2MB)"
// @Param file formData file true "Logo image file (PNG, JPG, or SVG)"
// @Success 204 "No Content"
// @Security BearerAuth
// @Router /api/oidc/clients/{id}/logo [post]
func (oc *OidcController) updateClientLogoHandler(c *gin.Context) {
file, err := c.FormFile("file")
@@ -571,7 +580,6 @@ func (oc *OidcController) updateClientLogoHandler(c *gin.Context) {
// @Tags OIDC
// @Param id path string true "Client ID"
// @Success 204 "No Content"
// @Security BearerAuth
// @Router /api/oidc/clients/{id}/logo [delete]
func (oc *OidcController) deleteClientLogoHandler(c *gin.Context) {
err := oc.oidcService.DeleteClientLogo(c.Request.Context(), c.Param("id"))
@@ -592,7 +600,6 @@ func (oc *OidcController) deleteClientLogoHandler(c *gin.Context) {
// @Param id path string true "Client ID"
// @Param groups body dto.OidcUpdateAllowedUserGroupsDto true "User group IDs"
// @Success 200 {object} dto.OidcClientDto "Updated client"
// @Security BearerAuth
// @Router /api/oidc/clients/{id}/allowed-user-groups [put]
func (oc *OidcController) updateAllowedUserGroupsHandler(c *gin.Context) {
var input dto.OidcUpdateAllowedUserGroupsDto
@@ -637,6 +644,62 @@ func (oc *OidcController) deviceAuthorizationHandler(c *gin.Context) {
c.JSON(http.StatusOK, response)
}
// listOwnAuthorizedClientsHandler godoc
// @Summary List authorized clients for current user
// @Description Get a paginated list of OIDC clients that the current user has authorized
// @Tags OIDC
// @Param pagination[page] query int false "Page number for pagination" default(1)
// @Param pagination[limit] query int false "Number of items per page" default(20)
// @Param sort[column] query string false "Column to sort by"
// @Param sort[direction] query string false "Sort direction (asc or desc)" default("asc")
// @Success 200 {object} dto.Paginated[dto.AuthorizedOidcClientDto]
// @Router /api/oidc/users/me/clients [get]
func (oc *OidcController) listOwnAuthorizedClientsHandler(c *gin.Context) {
userID := c.GetString("userID")
oc.listAuthorizedClients(c, userID)
}
// listAuthorizedClientsHandler godoc
// @Summary List authorized clients for a user
// @Description Get a paginated list of OIDC clients that a specific user has authorized
// @Tags OIDC
// @Param id path string true "User ID"
// @Param pagination[page] query int false "Page number for pagination" default(1)
// @Param pagination[limit] query int false "Number of items per page" default(20)
// @Param sort[column] query string false "Column to sort by"
// @Param sort[direction] query string false "Sort direction (asc or desc)" default("asc")
// @Success 200 {object} dto.Paginated[dto.AuthorizedOidcClientDto]
// @Router /api/oidc/users/{id}/clients [get]
func (oc *OidcController) listAuthorizedClientsHandler(c *gin.Context) {
userID := c.Param("id")
oc.listAuthorizedClients(c, userID)
}
func (oc *OidcController) listAuthorizedClients(c *gin.Context, userID string) {
var sortedPaginationRequest utils.SortedPaginationRequest
if err := c.ShouldBindQuery(&sortedPaginationRequest); err != nil {
_ = c.Error(err)
return
}
authorizedClients, pagination, err := oc.oidcService.ListAuthorizedClients(c.Request.Context(), userID, sortedPaginationRequest)
if err != nil {
_ = c.Error(err)
return
}
// Map the clients to DTOs
var authorizedClientsDto []dto.AuthorizedOidcClientDto
if err := dto.MapStructList(authorizedClients, &authorizedClientsDto); err != nil {
_ = c.Error(err)
return
}
c.JSON(http.StatusOK, dto.Paginated[dto.AuthorizedOidcClientDto]{
Data: authorizedClientsDto,
Pagination: pagination,
})
}
func (oc *OidcController) verifyDeviceCodeHandler(c *gin.Context) {
userCode := c.Query("code")
if userCode == "" {
@@ -672,3 +735,43 @@ func (oc *OidcController) getDeviceCodeInfoHandler(c *gin.Context) {
c.JSON(http.StatusOK, deviceCodeInfo)
}
// getClientPreviewHandler godoc
// @Summary Preview OIDC client data for user
// @Description Get a preview of the OIDC data (ID token, access token, userinfo) that would be sent to the client for a specific user
// @Tags OIDC
// @Produce json
// @Param id path string true "Client ID"
// @Param userId path string true "User ID to preview data for"
// @Param scopes query string false "Scopes to include in the preview (comma-separated)"
// @Success 200 {object} dto.OidcClientPreviewDto "Preview data including ID token, access token, and userinfo payloads"
// @Security BearerAuth
// @Router /api/oidc/clients/{id}/preview/{userId} [get]
func (oc *OidcController) getClientPreviewHandler(c *gin.Context) {
clientID := c.Param("id")
userID := c.Param("userId")
scopes := c.Query("scopes")
if clientID == "" {
_ = c.Error(&common.ValidationError{Message: "client ID is required"})
return
}
if userID == "" {
_ = c.Error(&common.ValidationError{Message: "user ID is required"})
return
}
if scopes == "" {
_ = c.Error(&common.ValidationError{Message: "scopes are required"})
return
}
preview, err := oc.oidcService.GetClientPreview(c.Request.Context(), clientID, userID, scopes)
if err != nil {
_ = c.Error(err)
return
}
c.JSON(http.StatusOK, preview)
}

View File

@@ -85,10 +85,10 @@ func (uc *UserController) getUserGroupsHandler(c *gin.Context) {
// @Description Get a paginated list of users with optional search and sorting
// @Tags Users
// @Param search query string false "Search term to filter users"
// @Param page query int false "Page number, starting from 1" default(1)
// @Param limit query int false "Number of items per page" default(10)
// @Param sort_column query string false "Column to sort by" default("created_at")
// @Param sort_direction query string false "Sort direction (asc or desc)" default("desc")
// @Param pagination[page] query int false "Page number for pagination" default(1)
// @Param pagination[limit] query int false "Number of items per page" default(20)
// @Param sort[column] query string false "Column to sort by"
// @Param sort[direction] query string false "Sort direction (asc or desc)" default("asc")
// @Success 200 {object} dto.Paginated[dto.UserDto]
// @Router /api/users [get]
func (uc *UserController) listUsersHandler(c *gin.Context) {

View File

@@ -40,10 +40,10 @@ type UserGroupController struct {
// @Description Get a paginated list of user groups with optional search and sorting
// @Tags User Groups
// @Param search query string false "Search term to filter user groups by name"
// @Param page query int false "Page number, starting from 1" default(1)
// @Param limit query int false "Number of items per page" default(10)
// @Param sort_column query string false "Column to sort by" default("name")
// @Param sort_direction query string false "Sort direction (asc or desc)" default("asc")
// @Param pagination[page] query int false "Page number for pagination" default(1)
// @Param pagination[limit] query int false "Number of items per page" default(20)
// @Param sort[column] query string false "Column to sort by"
// @Param sort[direction] query string false "Sort direction (asc or desc)" default("asc")
// @Success 200 {object} dto.Paginated[dto.UserGroupDtoWithUserCount]
// @Router /api/user-groups [get]
func (ugc *UserGroupController) list(c *gin.Context) {
@@ -92,7 +92,6 @@ func (ugc *UserGroupController) list(c *gin.Context) {
// @Produce json
// @Param id path string true "User Group ID"
// @Success 200 {object} dto.UserGroupDtoWithUsers
// @Security BearerAuth
// @Router /api/user-groups/{id} [get]
func (ugc *UserGroupController) get(c *gin.Context) {
group, err := ugc.UserGroupService.Get(c.Request.Context(), c.Param("id"))
@@ -118,7 +117,6 @@ func (ugc *UserGroupController) get(c *gin.Context) {
// @Produce json
// @Param userGroup body dto.UserGroupCreateDto true "User group information"
// @Success 201 {object} dto.UserGroupDtoWithUsers "Created user group"
// @Security BearerAuth
// @Router /api/user-groups [post]
func (ugc *UserGroupController) create(c *gin.Context) {
var input dto.UserGroupCreateDto
@@ -151,7 +149,6 @@ func (ugc *UserGroupController) create(c *gin.Context) {
// @Param id path string true "User Group ID"
// @Param userGroup body dto.UserGroupCreateDto true "User group information"
// @Success 200 {object} dto.UserGroupDtoWithUsers "Updated user group"
// @Security BearerAuth
// @Router /api/user-groups/{id} [put]
func (ugc *UserGroupController) update(c *gin.Context) {
var input dto.UserGroupCreateDto
@@ -183,7 +180,6 @@ func (ugc *UserGroupController) update(c *gin.Context) {
// @Produce json
// @Param id path string true "User Group ID"
// @Success 204 "No Content"
// @Security BearerAuth
// @Router /api/user-groups/{id} [delete]
func (ugc *UserGroupController) delete(c *gin.Context) {
if err := ugc.UserGroupService.Delete(c.Request.Context(), c.Param("id")); err != nil {
@@ -203,7 +199,6 @@ func (ugc *UserGroupController) delete(c *gin.Context) {
// @Param id path string true "User Group ID"
// @Param users body dto.UserGroupUpdateUsersDto true "List of user IDs to assign to this group"
// @Success 200 {object} dto.UserGroupDtoWithUsers
// @Security BearerAuth
// @Router /api/user-groups/{id}/users [put]
func (ugc *UserGroupController) updateUsers(c *gin.Context) {
var input dto.UserGroupUpdateUsersDto

View File

@@ -62,7 +62,60 @@ func mapStructInternal(sourceVal reflect.Value, destVal reflect.Value) error {
return nil
}
//nolint:gocognit
func mapField(sourceField reflect.Value, destField reflect.Value) error {
// Handle pointer to struct in source
if sourceField.Kind() == reflect.Ptr && !sourceField.IsNil() {
switch {
case sourceField.Elem().Kind() == reflect.Struct:
switch {
case destField.Kind() == reflect.Struct:
// Map from pointer to struct -> struct
return mapStructInternal(sourceField.Elem(), destField)
case destField.Kind() == reflect.Ptr && destField.CanSet():
// Map from pointer to struct -> pointer to struct
if destField.IsNil() {
destField.Set(reflect.New(destField.Type().Elem()))
}
return mapStructInternal(sourceField.Elem(), destField.Elem())
}
case destField.Kind() == reflect.Ptr &&
destField.CanSet() &&
sourceField.Elem().Type().AssignableTo(destField.Type().Elem()):
// Handle primitive pointer types (e.g., *string to *string)
if destField.IsNil() {
destField.Set(reflect.New(destField.Type().Elem()))
}
destField.Elem().Set(sourceField.Elem())
return nil
case destField.Kind() != reflect.Ptr &&
destField.CanSet() &&
sourceField.Elem().Type().AssignableTo(destField.Type()):
// Handle *T to T conversion for primitive types
destField.Set(sourceField.Elem())
return nil
}
}
// Handle pointer to struct in destination
if destField.Kind() == reflect.Ptr && destField.CanSet() {
switch {
case sourceField.Kind() == reflect.Struct:
// Map from struct -> pointer to struct
if destField.IsNil() {
destField.Set(reflect.New(destField.Type().Elem()))
}
return mapStructInternal(sourceField, destField.Elem())
case !sourceField.IsZero() && sourceField.Type().AssignableTo(destField.Type().Elem()):
// Handle T to *T conversion for primitive types
if destField.IsNil() {
destField.Set(reflect.New(destField.Type().Elem()))
}
destField.Elem().Set(sourceField)
return nil
}
}
switch {
case sourceField.Type() == destField.Type():
destField.Set(sourceField)

View File

@@ -8,10 +8,11 @@ type OidcClientMetaDataDto struct {
type OidcClientDto struct {
OidcClientMetaDataDto
CallbackURLs []string `json:"callbackURLs"`
LogoutCallbackURLs []string `json:"logoutCallbackURLs"`
IsPublic bool `json:"isPublic"`
PkceEnabled bool `json:"pkceEnabled"`
CallbackURLs []string `json:"callbackURLs"`
LogoutCallbackURLs []string `json:"logoutCallbackURLs"`
IsPublic bool `json:"isPublic"`
PkceEnabled bool `json:"pkceEnabled"`
Credentials OidcClientCredentialsDto `json:"credentials"`
}
type OidcClientWithAllowedUserGroupsDto struct {
@@ -25,11 +26,23 @@ type OidcClientWithAllowedGroupsCountDto struct {
}
type OidcClientCreateDto struct {
Name string `json:"name" binding:"required,max=50"`
CallbackURLs []string `json:"callbackURLs"`
LogoutCallbackURLs []string `json:"logoutCallbackURLs"`
IsPublic bool `json:"isPublic"`
PkceEnabled bool `json:"pkceEnabled"`
Name string `json:"name" binding:"required,max=50"`
CallbackURLs []string `json:"callbackURLs"`
LogoutCallbackURLs []string `json:"logoutCallbackURLs"`
IsPublic bool `json:"isPublic"`
PkceEnabled bool `json:"pkceEnabled"`
Credentials OidcClientCredentialsDto `json:"credentials"`
}
type OidcClientCredentialsDto struct {
FederatedIdentities []OidcClientFederatedIdentityDto `json:"federatedIdentities,omitempty"`
}
type OidcClientFederatedIdentityDto struct {
Issuer string `json:"issuer"`
Subject string `json:"subject,omitempty"`
Audience string `json:"audience,omitempty"`
JWKS string `json:"jwks,omitempty"`
}
type AuthorizeOidcClientRequestDto struct {
@@ -52,13 +65,15 @@ type AuthorizationRequiredDto struct {
}
type OidcCreateTokensDto struct {
GrantType string `form:"grant_type" binding:"required"`
Code string `form:"code"`
DeviceCode string `form:"device_code"`
ClientID string `form:"client_id"`
ClientSecret string `form:"client_secret"`
CodeVerifier string `form:"code_verifier"`
RefreshToken string `form:"refresh_token"`
GrantType string `form:"grant_type" binding:"required"`
Code string `form:"code"`
DeviceCode string `form:"device_code"`
ClientID string `form:"client_id"`
ClientSecret string `form:"client_secret"`
CodeVerifier string `form:"code_verifier"`
RefreshToken string `form:"refresh_token"`
ClientAssertion string `form:"client_assertion"`
ClientAssertionType string `form:"client_assertion_type"`
}
type OidcIntrospectDto struct {
@@ -98,9 +113,11 @@ type OidcIntrospectionResponseDto struct {
}
type OidcDeviceAuthorizationRequestDto struct {
ClientID string `form:"client_id" binding:"required"`
Scope string `form:"scope" binding:"required"`
ClientSecret string `form:"client_secret"`
ClientID string `form:"client_id" binding:"required"`
Scope string `form:"scope" binding:"required"`
ClientSecret string `form:"client_secret"`
ClientAssertion string `form:"client_assertion"`
ClientAssertionType string `form:"client_assertion_type"`
}
type OidcDeviceAuthorizationResponseDto struct {
@@ -125,3 +142,14 @@ type DeviceCodeInfoDto struct {
AuthorizationRequired bool `json:"authorizationRequired"`
Client OidcClientMetaDataDto `json:"client"`
}
type AuthorizedOidcClientDto struct {
Scope string `json:"scope"`
Client OidcClientMetaDataDto `json:"client"`
}
type OidcClientPreviewDto struct {
IdToken map[string]interface{} `json:"idToken"`
AccessToken map[string]interface{} `json:"accessToken"`
UserInfo map[string]interface{} `json:"userInfo"`
}

View File

@@ -5,8 +5,9 @@ import (
"encoding/json"
"fmt"
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
"gorm.io/gorm"
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
)
type UserAuthorizedOidcClient struct {
@@ -45,6 +46,7 @@ type OidcClient struct {
HasLogo bool `gorm:"-"`
IsPublic bool
PkceEnabled bool
Credentials OidcClientCredentials
AllowedUserGroups []UserGroup `gorm:"many2many:oidc_clients_allowed_user_groups;"`
CreatedByID string
@@ -71,9 +73,49 @@ func (c *OidcClient) AfterFind(_ *gorm.DB) (err error) {
return nil
}
type OidcClientCredentials struct { //nolint:recvcheck
FederatedIdentities []OidcClientFederatedIdentity `json:"federatedIdentities,omitempty"`
}
type OidcClientFederatedIdentity struct {
Issuer string `json:"issuer"`
Subject string `json:"subject,omitempty"`
Audience string `json:"audience,omitempty"`
JWKS string `json:"jwks,omitempty"` // URL of the JWKS
}
func (occ OidcClientCredentials) FederatedIdentityForIssuer(issuer string) (OidcClientFederatedIdentity, bool) {
if issuer == "" {
return OidcClientFederatedIdentity{}, false
}
for _, fi := range occ.FederatedIdentities {
if fi.Issuer == issuer {
return fi, true
}
}
return OidcClientFederatedIdentity{}, false
}
func (occ *OidcClientCredentials) Scan(value any) error {
switch v := value.(type) {
case []byte:
return json.Unmarshal(v, occ)
case string:
return json.Unmarshal([]byte(v), occ)
default:
return fmt.Errorf("unsupported type: %T", value)
}
}
func (occ OidcClientCredentials) Value() (driver.Value, error) {
return json.Marshal(occ)
}
type UrlList []string //nolint:recvcheck
func (cu *UrlList) Scan(value interface{}) error {
func (cu *UrlList) Scan(value any) error {
switch v := value.(type) {
case []byte:
return json.Unmarshal(v, cu)

View File

@@ -29,17 +29,17 @@ type AppConfigService struct {
db *gorm.DB
}
func NewAppConfigService(initCtx context.Context, db *gorm.DB) *AppConfigService {
func NewAppConfigService(ctx context.Context, db *gorm.DB) *AppConfigService {
service := &AppConfigService{
db: db,
}
err := service.LoadDbConfig(initCtx)
err := service.LoadDbConfig(ctx)
if err != nil {
log.Fatalf("Failed to initialize app config service: %v", err)
}
err = service.initInstanceID(initCtx)
err = service.initInstanceID(ctx)
if err != nil {
log.Fatalf("Failed to initialize instance ID: %v", err)
}
@@ -236,7 +236,7 @@ func (s *AppConfigService) UpdateAppConfig(ctx context.Context, input dto.AppCon
return res, nil
}
// UpdateAppConfigValues
// UpdateAppConfigValues updates the application configuration values in the database.
func (s *AppConfigService) UpdateAppConfigValues(ctx context.Context, keysAndValues ...string) error {
// Count of keysAndValues must be even
if len(keysAndValues)%2 != 0 {
@@ -355,24 +355,52 @@ func (s *AppConfigService) UpdateImage(ctx context.Context, uploadedFile *multip
// LoadDbConfig loads the configuration values from the database into the DbConfig struct.
func (s *AppConfigService) LoadDbConfig(ctx context.Context) (err error) {
var dest *model.AppConfig
// If the UI config is disabled, only load from the env
if common.EnvConfig.UiConfigDisabled {
dest, err = s.loadDbConfigFromEnv(ctx, s.db)
} else {
dest, err = s.loadDbConfigInternal(ctx, s.db)
}
dest, err := s.loadDbConfigInternal(ctx, s.db)
if err != nil {
return err
}
// Update the value in the object
s.dbConfig.Store(dest)
return nil
}
func (s *AppConfigService) loadDbConfigInternal(ctx context.Context, tx *gorm.DB) (*model.AppConfig, error) {
// If the UI config is disabled, only load from the env
if common.EnvConfig.UiConfigDisabled {
dest, err := s.loadDbConfigFromEnv(ctx, s.db)
return dest, err
}
// First, start from the default configuration
dest := s.getDefaultDbConfig()
// Load all configuration values from the database
// This loads all values in a single shot
var loaded []model.AppConfigVariable
queryCtx, queryCancel := context.WithTimeout(ctx, 10*time.Second)
defer queryCancel()
err := tx.
WithContext(queryCtx).
Find(&loaded).Error
if err != nil {
return nil, fmt.Errorf("failed to load configuration from the database: %w", err)
}
// Iterate through all values loaded from the database
for _, v := range loaded {
// Find the field in the struct whose "key" tag matches, then update that
err = dest.UpdateField(v.Key, v.Value, false)
// We ignore the case of fields that don't exist, as there may be leftover data in the database
if err != nil && !errors.Is(err, model.AppConfigKeyNotFoundError{}) {
return nil, fmt.Errorf("failed to process config for key '%s': %w", v.Key, err)
}
}
return dest, nil
}
func (s *AppConfigService) loadDbConfigFromEnv(ctx context.Context, tx *gorm.DB) (*model.AppConfig, error) {
// First, start from the default configuration
dest := s.getDefaultDbConfig()
@@ -414,36 +442,6 @@ func (s *AppConfigService) loadDbConfigFromEnv(ctx context.Context, tx *gorm.DB)
return dest, nil
}
func (s *AppConfigService) loadDbConfigInternal(ctx context.Context, tx *gorm.DB) (*model.AppConfig, error) {
// First, start from the default configuration
dest := s.getDefaultDbConfig()
// Load all configuration values from the database
// This loads all values in a single shot
var loaded []model.AppConfigVariable
queryCtx, queryCancel := context.WithTimeout(ctx, 10*time.Second)
defer queryCancel()
err := tx.
WithContext(queryCtx).
Find(&loaded).Error
if err != nil {
return nil, fmt.Errorf("failed to load configuration from the database: %w", err)
}
// Iterate through all values loaded from the database
for _, v := range loaded {
// Find the field in the struct whose "key" tag matches, then update that
err = dest.UpdateField(v.Key, v.Value, false)
// We ignore the case of fields that don't exist, as there may be leftover data in the database
if err != nil && !errors.Is(err, model.AppConfigKeyNotFoundError{}) {
return nil, fmt.Errorf("failed to process config for key '%s': %w", v.Key, err)
}
}
return dest, nil
}
func (s *AppConfigService) initInstanceID(ctx context.Context) error {
// Check if the instance ID is already set
instanceID := s.GetDbConfig().InstanceID.Value

View File

@@ -3,16 +3,10 @@ package service
import (
"sync/atomic"
"testing"
"time"
"github.com/glebarez/sqlite"
"gorm.io/gorm"
"gorm.io/gorm/logger"
"github.com/pocket-id/pocket-id/backend/internal/common"
"github.com/pocket-id/pocket-id/backend/internal/dto"
"github.com/pocket-id/pocket-id/backend/internal/model"
"github.com/pocket-id/pocket-id/backend/internal/utils"
"github.com/stretchr/testify/require"
)
@@ -28,7 +22,7 @@ func NewTestAppConfigService(config *model.AppConfig) *AppConfigService {
func TestLoadDbConfig(t *testing.T) {
t.Run("empty config table", func(t *testing.T) {
db := newAppConfigTestDatabaseForTest(t)
db := newDatabaseForTest(t)
service := &AppConfigService{
db: db,
}
@@ -42,7 +36,7 @@ func TestLoadDbConfig(t *testing.T) {
})
t.Run("loads value from config table", func(t *testing.T) {
db := newAppConfigTestDatabaseForTest(t)
db := newDatabaseForTest(t)
// Populate the config table with some initial values
err := db.
@@ -72,7 +66,7 @@ func TestLoadDbConfig(t *testing.T) {
})
t.Run("ignores unknown config keys", func(t *testing.T) {
db := newAppConfigTestDatabaseForTest(t)
db := newDatabaseForTest(t)
// Add an entry with a key that doesn't exist in the config struct
err := db.Create([]model.AppConfigVariable{
@@ -93,7 +87,7 @@ func TestLoadDbConfig(t *testing.T) {
})
t.Run("loading config multiple times", func(t *testing.T) {
db := newAppConfigTestDatabaseForTest(t)
db := newDatabaseForTest(t)
// Initial state
err := db.Create([]model.AppConfigVariable{
@@ -135,7 +129,7 @@ func TestLoadDbConfig(t *testing.T) {
common.EnvConfig.UiConfigDisabled = true
// Create database with config that should be ignored
db := newAppConfigTestDatabaseForTest(t)
db := newDatabaseForTest(t)
err := db.Create([]model.AppConfigVariable{
{Key: "appName", Value: "DB App"},
{Key: "sessionDuration", Value: "120"},
@@ -171,7 +165,7 @@ func TestLoadDbConfig(t *testing.T) {
common.EnvConfig.UiConfigDisabled = false
// Create database with config values that should take precedence
db := newAppConfigTestDatabaseForTest(t)
db := newDatabaseForTest(t)
err := db.Create([]model.AppConfigVariable{
{Key: "appName", Value: "DB App"},
{Key: "sessionDuration", Value: "120"},
@@ -195,7 +189,7 @@ func TestLoadDbConfig(t *testing.T) {
func TestUpdateAppConfigValues(t *testing.T) {
t.Run("update single value", func(t *testing.T) {
db := newAppConfigTestDatabaseForTest(t)
db := newDatabaseForTest(t)
// Create a service with default config
service := &AppConfigService{
@@ -220,7 +214,7 @@ func TestUpdateAppConfigValues(t *testing.T) {
})
t.Run("update multiple values", func(t *testing.T) {
db := newAppConfigTestDatabaseForTest(t)
db := newDatabaseForTest(t)
// Create a service with default config
service := &AppConfigService{
@@ -264,7 +258,7 @@ func TestUpdateAppConfigValues(t *testing.T) {
})
t.Run("empty value resets to default", func(t *testing.T) {
db := newAppConfigTestDatabaseForTest(t)
db := newDatabaseForTest(t)
// Create a service with default config
service := &AppConfigService{
@@ -285,7 +279,7 @@ func TestUpdateAppConfigValues(t *testing.T) {
})
t.Run("error with odd number of arguments", func(t *testing.T) {
db := newAppConfigTestDatabaseForTest(t)
db := newDatabaseForTest(t)
// Create a service with default config
service := &AppConfigService{
@@ -301,7 +295,7 @@ func TestUpdateAppConfigValues(t *testing.T) {
})
t.Run("error with invalid key", func(t *testing.T) {
db := newAppConfigTestDatabaseForTest(t)
db := newDatabaseForTest(t)
// Create a service with default config
service := &AppConfigService{
@@ -319,7 +313,7 @@ func TestUpdateAppConfigValues(t *testing.T) {
func TestUpdateAppConfig(t *testing.T) {
t.Run("updates configuration values from DTO", func(t *testing.T) {
db := newAppConfigTestDatabaseForTest(t)
db := newDatabaseForTest(t)
// Create a service with default config
service := &AppConfigService{
@@ -392,7 +386,7 @@ func TestUpdateAppConfig(t *testing.T) {
})
t.Run("empty values reset to defaults", func(t *testing.T) {
db := newAppConfigTestDatabaseForTest(t)
db := newDatabaseForTest(t)
// Create a service with default config and modify some values
service := &AppConfigService{
@@ -457,7 +451,7 @@ func TestUpdateAppConfig(t *testing.T) {
// Disable UI config
common.EnvConfig.UiConfigDisabled = true
db := newAppConfigTestDatabaseForTest(t)
db := newDatabaseForTest(t)
service := &AppConfigService{
db: db,
}
@@ -475,49 +469,3 @@ func TestUpdateAppConfig(t *testing.T) {
require.ErrorAs(t, err, &uiConfigDisabledErr)
})
}
// Implements gorm's logger.Writer interface
type testLoggerAdapter struct {
t *testing.T
}
func (l testLoggerAdapter) Printf(format string, args ...any) {
l.t.Logf(format, args...)
}
func newAppConfigTestDatabaseForTest(t *testing.T) *gorm.DB {
t.Helper()
// Get a name for this in-memory database that is specific to the test
dbName := utils.CreateSha256Hash(t.Name())
// Connect to a new in-memory SQL database
db, err := gorm.Open(
sqlite.Open("file:"+dbName+"?mode=memory&cache=shared"),
&gorm.Config{
TranslateError: true,
Logger: logger.New(
testLoggerAdapter{t: t},
logger.Config{
SlowThreshold: 200 * time.Millisecond,
LogLevel: logger.Info,
IgnoreRecordNotFoundError: false,
ParameterizedQueries: false,
Colorful: false,
},
),
})
require.NoError(t, err, "Failed to connect to test database")
// Create the app_config_variables table
err = db.Exec(`
CREATE TABLE app_config_variables
(
key VARCHAR(100) NOT NULL PRIMARY KEY,
value TEXT NOT NULL
)
`).Error
require.NoError(t, err, "Failed to create test config table")
return db
}

View File

@@ -5,6 +5,8 @@ package service
import (
"context"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/x509"
"encoding/base64"
"fmt"
@@ -16,6 +18,7 @@ import (
"github.com/fxamacker/cbor/v2"
"github.com/go-webauthn/webauthn/protocol"
"github.com/lestrrat-go/jwx/v3/jwk"
"github.com/lestrrat-go/jwx/v3/jwt"
"gorm.io/gorm"
"github.com/pocket-id/pocket-id/backend/internal/common"
@@ -30,14 +33,43 @@ type TestService struct {
jwtService *JwtService
appConfigService *AppConfigService
ldapService *LdapService
externalIdPKey jwk.Key
}
func NewTestService(db *gorm.DB, appConfigService *AppConfigService, jwtService *JwtService, ldapService *LdapService) *TestService {
return &TestService{db: db, appConfigService: appConfigService, jwtService: jwtService, ldapService: ldapService}
func NewTestService(db *gorm.DB, appConfigService *AppConfigService, jwtService *JwtService, ldapService *LdapService) (*TestService, error) {
s := &TestService{
db: db,
appConfigService: appConfigService,
jwtService: jwtService,
ldapService: ldapService,
}
err := s.initExternalIdP()
if err != nil {
return nil, fmt.Errorf("failed to initialize external IdP: %w", err)
}
return s, nil
}
// Initializes the "external IdP"
// This creates a new "issuing authority" containing a public JWKS
// It also stores the private key internally that will be used to issue JWTs
func (s *TestService) initExternalIdP() error {
// Generate a new ECDSA key
rawKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
return fmt.Errorf("failed to generate private key: %w", err)
}
s.externalIdPKey, err = utils.ImportRawKey(rawKey)
if err != nil {
return fmt.Errorf("failed to import private key: %w", err)
}
return nil
}
//nolint:gocognit
func (s *TestService) SeedDatabase() error {
func (s *TestService) SeedDatabase(baseURL string) error {
err := s.db.Transaction(func(tx *gorm.DB) error {
users := []model.User{
{
@@ -138,6 +170,26 @@ func (s *TestService) SeedDatabase() error {
userGroups[1],
},
},
{
Base: model.Base{
ID: "c48232ff-ff65-45ed-ae96-7afa8a9b443b",
},
Name: "Federated",
Secret: "$2a$10$Ak.FP8riD1ssy2AGGbG.gOpnp/rBpymd74j0nxNMtW0GG1Lb4gzxe", // PYjrE9u4v9GVqXKi52eur0eb2Ci4kc0x
CallbackURLs: model.UrlList{"http://federated/auth/callback"},
CreatedByID: users[1].ID,
AllowedUserGroups: []model.UserGroup{},
Credentials: model.OidcClientCredentials{
FederatedIdentities: []model.OidcClientFederatedIdentity{
{
Issuer: "https://external-idp.local",
Audience: "api://PocketID",
Subject: "c48232ff-ff65-45ed-ae96-7afa8a9b443b",
JWKS: baseURL + "/api/externalidp/jwks.json",
},
},
},
},
}
for _, client := range oidcClients {
if err := tx.Create(&client).Error; err != nil {
@@ -145,16 +197,28 @@ func (s *TestService) SeedDatabase() error {
}
}
authCode := model.OidcAuthorizationCode{
Code: "auth-code",
Scope: "openid profile",
Nonce: "nonce",
ExpiresAt: datatype.DateTime(time.Now().Add(1 * time.Hour)),
UserID: users[0].ID,
ClientID: oidcClients[0].ID,
authCodes := []model.OidcAuthorizationCode{
{
Code: "auth-code",
Scope: "openid profile",
Nonce: "nonce",
ExpiresAt: datatype.DateTime(time.Now().Add(1 * time.Hour)),
UserID: users[0].ID,
ClientID: oidcClients[0].ID,
},
{
Code: "federated",
Scope: "openid profile",
Nonce: "nonce",
ExpiresAt: datatype.DateTime(time.Now().Add(1 * time.Hour)),
UserID: users[1].ID,
ClientID: oidcClients[2].ID,
},
}
if err := tx.Create(&authCode).Error; err != nil {
return err
for _, authCode := range authCodes {
if err := tx.Create(&authCode).Error; err != nil {
return err
}
}
refreshToken := model.OidcRefreshToken{
@@ -177,13 +241,22 @@ func (s *TestService) SeedDatabase() error {
return err
}
userAuthorizedClient := model.UserAuthorizedOidcClient{
Scope: "openid profile email",
UserID: users[0].ID,
ClientID: oidcClients[0].ID,
userAuthorizedClients := []model.UserAuthorizedOidcClient{
{
Scope: "openid profile email",
UserID: users[0].ID,
ClientID: oidcClients[0].ID,
},
{
Scope: "openid profile email",
UserID: users[1].ID,
ClientID: oidcClients[2].ID,
},
}
if err := tx.Create(&userAuthorizedClient).Error; err != nil {
return err
for _, userAuthorizedClient := range userAuthorizedClients {
if err := tx.Create(&userAuthorizedClient).Error; err != nil {
return err
}
}
// To generate a new key pair, run the following command:
@@ -405,3 +478,45 @@ func (s *TestService) SetLdapTestConfig(ctx context.Context) error {
return nil
}
func (s *TestService) SignRefreshToken(userID, clientID, refreshToken string) (string, error) {
return s.jwtService.GenerateOAuthRefreshToken(userID, clientID, refreshToken)
}
// GetExternalIdPJWKS returns the JWKS for the "external IdP".
func (s *TestService) GetExternalIdPJWKS() (jwk.Set, error) {
pubKey, err := s.externalIdPKey.PublicKey()
if err != nil {
return nil, fmt.Errorf("failed to get public key: %w", err)
}
set := jwk.NewSet()
err = set.AddKey(pubKey)
if err != nil {
return nil, fmt.Errorf("failed to add public key to set: %w", err)
}
return set, nil
}
func (s *TestService) SignExternalIdPToken(iss, sub, aud string) (string, error) {
now := time.Now()
token, err := jwt.NewBuilder().
Subject(sub).
Expiration(now.Add(time.Hour)).
IssuedAt(now).
Issuer(iss).
Audience([]string{aud}).
Build()
if err != nil {
return "", fmt.Errorf("failed to build token: %w", err)
}
alg, _ := s.externalIdPKey.Algorithm()
signed, err := jwt.Sign(token, jwt.WithKey(alg, s.externalIdPKey))
if err != nil {
return "", fmt.Errorf("failed to sign token: %w", err)
}
return string(signed), nil
}

View File

@@ -4,11 +4,9 @@ import (
"context"
"crypto/rand"
"crypto/rsa"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"log"
"os"
"path/filepath"
@@ -41,9 +39,15 @@ const (
// TokenTypeClaim is the claim used to identify the type of token
TokenTypeClaim = "type"
// RefreshTokenClaim is the claim used for the refresh token's value
RefreshTokenClaim = "rt"
// OAuthAccessTokenJWTType identifies a JWT as an OAuth access token
OAuthAccessTokenJWTType = "oauth-access-token" //nolint:gosec
// OAuthRefreshTokenJWTType identifies a JWT as an OAuth refresh token
OAuthRefreshTokenJWTType = "refresh-token"
// AccessTokenJWTType identifies a JWT as an access token used by Pocket ID
AccessTokenJWTType = "access-token"
@@ -236,7 +240,8 @@ func (s *JwtService) VerifyAccessToken(tokenString string) (jwt.Token, error) {
return token, nil
}
func (s *JwtService) GenerateIDToken(userClaims map[string]any, clientID string, nonce string) (string, error) {
// BuildIDToken creates an ID token with all claims
func (s *JwtService) BuildIDToken(userClaims map[string]any, clientID string, nonce string) (jwt.Token, error) {
now := time.Now()
token, err := jwt.NewBuilder().
Expiration(now.Add(1 * time.Hour)).
@@ -244,33 +249,43 @@ func (s *JwtService) GenerateIDToken(userClaims map[string]any, clientID string,
Issuer(common.EnvConfig.AppURL).
Build()
if err != nil {
return "", fmt.Errorf("failed to build token: %w", err)
return nil, fmt.Errorf("failed to build token: %w", err)
}
err = SetAudienceString(token, clientID)
if err != nil {
return "", fmt.Errorf("failed to set 'aud' claim in token: %w", err)
return nil, fmt.Errorf("failed to set 'aud' claim in token: %w", err)
}
err = SetTokenType(token, IDTokenJWTType)
if err != nil {
return "", fmt.Errorf("failed to set 'type' claim in token: %w", err)
return nil, fmt.Errorf("failed to set 'type' claim in token: %w", err)
}
for k, v := range userClaims {
err = token.Set(k, v)
if err != nil {
return "", fmt.Errorf("failed to set claim '%s': %w", k, err)
return nil, fmt.Errorf("failed to set claim '%s': %w", k, err)
}
}
if nonce != "" {
err = token.Set("nonce", nonce)
if err != nil {
return "", fmt.Errorf("failed to set claim 'nonce': %w", err)
return nil, fmt.Errorf("failed to set claim 'nonce': %w", err)
}
}
return token, nil
}
// GenerateIDToken creates and signs an ID token
func (s *JwtService) GenerateIDToken(userClaims map[string]any, clientID string, nonce string) (string, error) {
token, err := s.BuildIDToken(userClaims, clientID, nonce)
if err != nil {
return "", err
}
alg, _ := s.privateKey.Algorithm()
signed, err := jwt.Sign(token, jwt.WithKey(alg, s.privateKey))
if err != nil {
@@ -313,7 +328,8 @@ func (s *JwtService) VerifyIdToken(tokenString string, acceptExpiredTokens bool)
return token, nil
}
func (s *JwtService) GenerateOauthAccessToken(user model.User, clientID string) (string, error) {
// BuildOAuthAccessToken creates an OAuth access token with all claims
func (s *JwtService) BuildOAuthAccessToken(user model.User, clientID string) (jwt.Token, error) {
now := time.Now()
token, err := jwt.NewBuilder().
Subject(user.ID).
@@ -322,17 +338,27 @@ func (s *JwtService) GenerateOauthAccessToken(user model.User, clientID string)
Issuer(common.EnvConfig.AppURL).
Build()
if err != nil {
return "", fmt.Errorf("failed to build token: %w", err)
return nil, fmt.Errorf("failed to build token: %w", err)
}
err = SetAudienceString(token, clientID)
if err != nil {
return "", fmt.Errorf("failed to set 'aud' claim in token: %w", err)
return nil, fmt.Errorf("failed to set 'aud' claim in token: %w", err)
}
err = SetTokenType(token, OAuthAccessTokenJWTType)
if err != nil {
return "", fmt.Errorf("failed to set 'type' claim in token: %w", err)
return nil, fmt.Errorf("failed to set 'type' claim in token: %w", err)
}
return token, nil
}
// GenerateOAuthAccessToken creates and signs an OAuth access token
func (s *JwtService) GenerateOAuthAccessToken(user model.User, clientID string) (string, error) {
token, err := s.BuildOAuthAccessToken(user, clientID)
if err != nil {
return "", err
}
alg, _ := s.privateKey.Algorithm()
@@ -344,7 +370,7 @@ func (s *JwtService) GenerateOauthAccessToken(user model.User, clientID string)
return string(signed), nil
}
func (s *JwtService) VerifyOauthAccessToken(tokenString string) (jwt.Token, error) {
func (s *JwtService) VerifyOAuthAccessToken(tokenString string) (jwt.Token, error) {
alg, _ := s.privateKey.Algorithm()
token, err := jwt.ParseString(
tokenString,
@@ -361,6 +387,96 @@ func (s *JwtService) VerifyOauthAccessToken(tokenString string) (jwt.Token, erro
return token, nil
}
func (s *JwtService) GenerateOAuthRefreshToken(userID string, clientID string, refreshToken string) (string, error) {
now := time.Now()
token, err := jwt.NewBuilder().
Subject(userID).
Expiration(now.Add(RefreshTokenDuration)).
IssuedAt(now).
Issuer(common.EnvConfig.AppURL).
Build()
if err != nil {
return "", fmt.Errorf("failed to build token: %w", err)
}
err = token.Set(RefreshTokenClaim, refreshToken)
if err != nil {
return "", fmt.Errorf("failed to set 'rt' claim in token: %w", err)
}
err = SetAudienceString(token, clientID)
if err != nil {
return "", fmt.Errorf("failed to set 'aud' claim in token: %w", err)
}
err = SetTokenType(token, OAuthRefreshTokenJWTType)
if err != nil {
return "", fmt.Errorf("failed to set 'type' claim in token: %w", err)
}
alg, _ := s.privateKey.Algorithm()
signed, err := jwt.Sign(token, jwt.WithKey(alg, s.privateKey))
if err != nil {
return "", fmt.Errorf("failed to sign token: %w", err)
}
return string(signed), nil
}
func (s *JwtService) VerifyOAuthRefreshToken(tokenString string) (userID, clientID, rt string, err error) {
alg, _ := s.privateKey.Algorithm()
token, err := jwt.ParseString(
tokenString,
jwt.WithValidate(true),
jwt.WithKey(alg, s.privateKey),
jwt.WithAcceptableSkew(clockSkew),
jwt.WithIssuer(common.EnvConfig.AppURL),
jwt.WithValidator(TokenTypeValidator(OAuthRefreshTokenJWTType)),
)
if err != nil {
return "", "", "", fmt.Errorf("failed to parse token: %w", err)
}
err = token.Get(RefreshTokenClaim, &rt)
if err != nil {
return "", "", "", fmt.Errorf("failed to get '%s' claim from token: %w", RefreshTokenClaim, err)
}
audiences, ok := token.Audience()
if !ok || len(audiences) != 1 || audiences[0] == "" {
return "", "", "", errors.New("failed to get 'aud' claim from token")
}
clientID = audiences[0]
userID, ok = token.Subject()
if !ok {
return "", "", "", errors.New("failed to get 'sub' claim from token")
}
return userID, clientID, rt, nil
}
// GetTokenType returns the type of the JWT token issued by Pocket ID, but **does not validate it**.
func (s *JwtService) GetTokenType(tokenString string) (string, jwt.Token, error) {
// Disable validation and verification to parse the token without checking it
token, err := jwt.ParseString(
tokenString,
jwt.WithValidate(false),
jwt.WithVerify(false),
)
if err != nil {
return "", nil, fmt.Errorf("failed to parse token: %w", err)
}
var tokenType string
err = token.Get(TokenTypeClaim, &tokenType)
if err != nil {
return "", nil, fmt.Errorf("failed to get token type claim: %w", err)
}
return tokenType, token, nil
}
// GetPublicJWK returns the JSON Web Key (JWK) for the public key.
func (s *JwtService) GetPublicJWK() (jwk.Key, error) {
if s.privateKey == nil {
@@ -372,7 +488,7 @@ func (s *JwtService) GetPublicJWK() (jwk.Key, error) {
return nil, fmt.Errorf("failed to get public key: %w", err)
}
EnsureAlgInKey(pubKey)
utils.EnsureAlgInKey(pubKey)
return pubKey, nil
}
@@ -415,27 +531,6 @@ func (s *JwtService) loadKeyJWK(path string) (jwk.Key, error) {
return key, nil
}
// EnsureAlgInKey ensures that the key contains an "alg" parameter, set depending on the key type
func EnsureAlgInKey(key jwk.Key) {
_, ok := key.Algorithm()
if ok {
// Algorithm is already set
return
}
switch key.KeyType() {
case jwa.RSA():
// Default to RS256 for RSA keys
_ = key.Set(jwk.AlgorithmKey, jwa.RS256())
case jwa.EC():
// Default to ES256 for ECDSA keys
_ = key.Set(jwk.AlgorithmKey, jwa.ES256())
case jwa.OKP():
// Default to EdDSA for OKP keys
_ = key.Set(jwk.AlgorithmKey, jwa.EdDSA())
}
}
func (s *JwtService) generateNewRSAKey() (jwk.Key, error) {
// We generate RSA keys only
rawKey, err := rsa.GenerateKey(rand.Reader, RsaKeySize)
@@ -444,27 +539,7 @@ func (s *JwtService) generateNewRSAKey() (jwk.Key, error) {
}
// Import the raw key
return importRawKey(rawKey)
}
func importRawKey(rawKey any) (jwk.Key, error) {
key, err := jwk.Import(rawKey)
if err != nil {
return nil, fmt.Errorf("failed to import generated private key: %w", err)
}
// Generate the key ID
kid, err := generateRandomKeyID()
if err != nil {
return nil, fmt.Errorf("failed to generate key ID: %w", err)
}
_ = key.Set(jwk.KeyIDKey, kid)
// Set other required fields
_ = key.Set(jwk.KeyUsageKey, KeyUsageSigning)
EnsureAlgInKey(key)
return key, err
return utils.ImportRawKey(rawKey)
}
// SaveKeyJWK saves a JWK to a file
@@ -492,16 +567,6 @@ func SaveKeyJWK(key jwk.Key, path string) error {
return nil
}
// generateRandomKeyID generates a random key ID.
func generateRandomKeyID() (string, error) {
buf := make([]byte, 8)
_, err := io.ReadFull(rand.Reader, buf)
if err != nil {
return "", fmt.Errorf("failed to read random bytes: %w", err)
}
return base64.RawURLEncoding.EncodeToString(buf), nil
}
// GetIsAdmin returns the value of the "isAdmin" claim in the token
func GetIsAdmin(token jwt.Token) (bool, error) {
if !token.Has(IsAdminClaim) {
@@ -509,7 +574,10 @@ func GetIsAdmin(token jwt.Token) (bool, error) {
}
var isAdmin bool
err := token.Get(IsAdminClaim, &isAdmin)
return isAdmin, err
if err != nil {
return false, fmt.Errorf("failed to get 'isAdmin' claim from token: %w", err)
}
return isAdmin, nil
}
// SetTokenType sets the "type" claim in the token

View File

@@ -21,6 +21,7 @@ import (
"github.com/pocket-id/pocket-id/backend/internal/common"
"github.com/pocket-id/pocket-id/backend/internal/model"
"github.com/pocket-id/pocket-id/backend/internal/utils"
)
func TestJwtService_Init(t *testing.T) {
@@ -881,7 +882,7 @@ func TestGenerateVerifyIdToken(t *testing.T) {
})
}
func TestGenerateVerifyOauthAccessToken(t *testing.T) {
func TestGenerateVerifyOAuthAccessToken(t *testing.T) {
// Create a temporary directory for the test
tempDir := t.TempDir()
@@ -913,12 +914,12 @@ func TestGenerateVerifyOauthAccessToken(t *testing.T) {
const clientID = "test-client-123"
// Generate a token
tokenString, err := service.GenerateOauthAccessToken(user, clientID)
tokenString, err := service.GenerateOAuthAccessToken(user, clientID)
require.NoError(t, err, "Failed to generate OAuth access token")
assert.NotEmpty(t, tokenString, "Token should not be empty")
// Verify the token
claims, err := service.VerifyOauthAccessToken(tokenString)
claims, err := service.VerifyOAuthAccessToken(tokenString)
require.NoError(t, err, "Failed to verify generated OAuth access token")
// Check the claims
@@ -971,7 +972,7 @@ func TestGenerateVerifyOauthAccessToken(t *testing.T) {
require.NoError(t, err, "Failed to sign token")
// Verify should fail due to expiration
_, err = service.VerifyOauthAccessToken(string(signed))
_, err = service.VerifyOAuthAccessToken(string(signed))
require.Error(t, err, "Verification should fail with expired token")
assert.Contains(t, err.Error(), `"exp" not satisfied`, "Error message should indicate token verification failure")
})
@@ -995,11 +996,11 @@ func TestGenerateVerifyOauthAccessToken(t *testing.T) {
const clientID = "test-client-789"
// Generate a token with the first service
tokenString, err := service1.GenerateOauthAccessToken(user, clientID)
tokenString, err := service1.GenerateOAuthAccessToken(user, clientID)
require.NoError(t, err, "Failed to generate OAuth access token")
// Verify with the second service should fail due to different keys
_, err = service2.VerifyOauthAccessToken(tokenString)
_, err = service2.VerifyOAuthAccessToken(tokenString)
require.Error(t, err, "Verification should fail with invalid signature")
assert.Contains(t, err.Error(), "verification error", "Error message should indicate token verification failure")
})
@@ -1031,12 +1032,12 @@ func TestGenerateVerifyOauthAccessToken(t *testing.T) {
const clientID = "eddsa-oauth-client"
// Generate a token
tokenString, err := service.GenerateOauthAccessToken(user, clientID)
tokenString, err := service.GenerateOAuthAccessToken(user, clientID)
require.NoError(t, err, "Failed to generate OAuth access token with key")
assert.NotEmpty(t, tokenString, "Token should not be empty")
// Verify the token
claims, err := service.VerifyOauthAccessToken(tokenString)
claims, err := service.VerifyOAuthAccessToken(tokenString)
require.NoError(t, err, "Failed to verify generated OAuth access token with key")
// Check the claims
@@ -1085,12 +1086,12 @@ func TestGenerateVerifyOauthAccessToken(t *testing.T) {
const clientID = "ecdsa-oauth-client"
// Generate a token
tokenString, err := service.GenerateOauthAccessToken(user, clientID)
tokenString, err := service.GenerateOAuthAccessToken(user, clientID)
require.NoError(t, err, "Failed to generate OAuth access token with key")
assert.NotEmpty(t, tokenString, "Token should not be empty")
// Verify the token
claims, err := service.VerifyOauthAccessToken(tokenString)
claims, err := service.VerifyOAuthAccessToken(tokenString)
require.NoError(t, err, "Failed to verify generated OAuth access token with key")
// Check the claims
@@ -1139,12 +1140,12 @@ func TestGenerateVerifyOauthAccessToken(t *testing.T) {
const clientID = "rsa-oauth-client"
// Generate a token
tokenString, err := service.GenerateOauthAccessToken(user, clientID)
tokenString, err := service.GenerateOAuthAccessToken(user, clientID)
require.NoError(t, err, "Failed to generate OAuth access token with key")
assert.NotEmpty(t, tokenString, "Token should not be empty")
// Verify the token
claims, err := service.VerifyOauthAccessToken(tokenString)
claims, err := service.VerifyOAuthAccessToken(tokenString)
require.NoError(t, err, "Failed to verify generated OAuth access token with key")
// Check the claims
@@ -1167,6 +1168,92 @@ func TestGenerateVerifyOauthAccessToken(t *testing.T) {
})
}
func TestGenerateVerifyOAuthRefreshToken(t *testing.T) {
// Create a temporary directory for the test
tempDir := t.TempDir()
// Initialize the JWT service with a mock AppConfigService
mockConfig := NewTestAppConfigService(&model.AppConfig{})
// Setup the environment variable required by the token verification
originalAppURL := common.EnvConfig.AppURL
common.EnvConfig.AppURL = "https://test.example.com"
defer func() {
common.EnvConfig.AppURL = originalAppURL
}()
t.Run("generates and verifies refresh token", func(t *testing.T) {
// Create a JWT service
service := &JwtService{}
err := service.init(mockConfig, tempDir)
require.NoError(t, err, "Failed to initialize JWT service")
// Create a test user
const (
userID = "user123"
clientID = "client123"
refreshToken = "rt-123"
)
// Generate a token
tokenString, err := service.GenerateOAuthRefreshToken(userID, clientID, refreshToken)
require.NoError(t, err, "Failed to generate refresh token")
assert.NotEmpty(t, tokenString, "Token should not be empty")
// Verify the token
resUser, resClient, resRT, err := service.VerifyOAuthRefreshToken(tokenString)
require.NoError(t, err, "Failed to verify generated token")
assert.Equal(t, userID, resUser, "Should return correct user ID")
assert.Equal(t, clientID, resClient, "Should return correct client ID")
assert.Equal(t, refreshToken, resRT, "Should return correct refresh token")
})
t.Run("fails verification for expired token", func(t *testing.T) {
// Create a JWT service
service := &JwtService{}
err := service.init(mockConfig, tempDir)
require.NoError(t, err, "Failed to initialize JWT service")
// Generate a token using JWT directly to create an expired token
token, err := jwt.NewBuilder().
Subject("user789").
Expiration(time.Now().Add(-1 * time.Hour)). // Expired 1 hour ago
IssuedAt(time.Now().Add(-2 * time.Hour)).
Audience([]string{"client123"}).
Issuer(common.EnvConfig.AppURL).
Build()
require.NoError(t, err, "Failed to build token")
signed, err := jwt.Sign(token, jwt.WithKey(jwa.RS256(), service.privateKey))
require.NoError(t, err, "Failed to sign token")
// Verify should fail due to expiration
_, _, _, err = service.VerifyOAuthRefreshToken(string(signed))
require.Error(t, err, "Verification should fail with expired token")
assert.Contains(t, err.Error(), `"exp" not satisfied`, "Error message should indicate token verification failure")
})
t.Run("fails verification with invalid signature", func(t *testing.T) {
// Create two JWT services with different keys
service1 := &JwtService{}
err := service1.init(mockConfig, t.TempDir())
require.NoError(t, err, "Failed to initialize first JWT service")
service2 := &JwtService{}
err = service2.init(mockConfig, t.TempDir())
require.NoError(t, err, "Failed to initialize second JWT service")
// Generate a token with the first service
tokenString, err := service1.GenerateOAuthRefreshToken("user789", "client123", "my-rt-123")
require.NoError(t, err, "Failed to generate refresh token")
// Verify with the second service should fail due to different keys
_, _, _, err = service2.VerifyOAuthRefreshToken(tokenString)
require.Error(t, err, "Verification should fail with invalid signature")
assert.Contains(t, err.Error(), "verification error", "Error message should indicate token verification failure")
})
}
func TestTokenTypeValidator(t *testing.T) {
// Create a context for the validator function
ctx := context.Background()
@@ -1212,13 +1299,110 @@ func TestTokenTypeValidator(t *testing.T) {
require.Error(t, err, "Validator should reject token without type claim")
assert.Contains(t, err.Error(), "failed to get token type claim")
})
}
func TestGetTokenType(t *testing.T) {
// Create a temporary directory for the test
tempDir := t.TempDir()
// Initialize the JWT service
mockConfig := NewTestAppConfigService(&model.AppConfig{})
service := &JwtService{}
err := service.init(mockConfig, tempDir)
require.NoError(t, err, "Failed to initialize JWT service")
buildTokenForType := func(t *testing.T, typ string, setClaimsFn func(b *jwt.Builder)) string {
t.Helper()
b := jwt.NewBuilder()
b.Subject("user123")
if setClaimsFn != nil {
setClaimsFn(b)
}
token, err := b.Build()
require.NoError(t, err, "Failed to build token")
err = SetTokenType(token, typ)
require.NoError(t, err, "Failed to set token type")
alg, _ := service.privateKey.Algorithm()
signed, err := jwt.Sign(token, jwt.WithKey(alg, service.privateKey))
require.NoError(t, err, "Failed to sign token")
return string(signed)
}
t.Run("correctly identifies access tokens", func(t *testing.T) {
tokenString := buildTokenForType(t, AccessTokenJWTType, nil)
// Get the token type without validating
tokenType, _, err := service.GetTokenType(tokenString)
require.NoError(t, err, "GetTokenType should not return an error")
assert.Equal(t, AccessTokenJWTType, tokenType, "Token type should be correctly identified as access token")
})
t.Run("correctly identifies ID tokens", func(t *testing.T) {
tokenString := buildTokenForType(t, IDTokenJWTType, nil)
// Get the token type without validating
tokenType, _, err := service.GetTokenType(tokenString)
require.NoError(t, err, "GetTokenType should not return an error")
assert.Equal(t, IDTokenJWTType, tokenType, "Token type should be correctly identified as ID token")
})
t.Run("correctly identifies OAuth access tokens", func(t *testing.T) {
tokenString := buildTokenForType(t, OAuthAccessTokenJWTType, nil)
// Get the token type without validating
tokenType, _, err := service.GetTokenType(tokenString)
require.NoError(t, err, "GetTokenType should not return an error")
assert.Equal(t, OAuthAccessTokenJWTType, tokenType, "Token type should be correctly identified as OAuth access token")
})
t.Run("correctly identifies refresh tokens", func(t *testing.T) {
tokenString := buildTokenForType(t, OAuthRefreshTokenJWTType, nil)
// Get the token type without validating
tokenType, _, err := service.GetTokenType(tokenString)
require.NoError(t, err, "GetTokenType should not return an error")
assert.Equal(t, OAuthRefreshTokenJWTType, tokenType, "Token type should be correctly identified as refresh token")
})
t.Run("works with expired tokens", func(t *testing.T) {
tokenString := buildTokenForType(t, AccessTokenJWTType, func(b *jwt.Builder) {
b.Expiration(time.Now().Add(-1 * time.Hour)) // Expired 1 hour ago
})
// Get the token type without validating
tokenType, _, err := service.GetTokenType(tokenString)
require.NoError(t, err, "GetTokenType should not return an error for expired tokens")
assert.Equal(t, AccessTokenJWTType, tokenType, "Token type should be correctly identified even for expired tokens")
})
t.Run("returns error for malformed tokens", func(t *testing.T) {
// Try to get the token type of a malformed token
tokenType, _, err := service.GetTokenType("not.a.valid.jwt.token")
require.Error(t, err, "GetTokenType should return an error for malformed tokens")
assert.Empty(t, tokenType, "Token type should be empty for malformed tokens")
})
t.Run("returns error for tokens without type claim", func(t *testing.T) {
// Create a token without type claim
tokenString := buildTokenForType(t, "", nil)
// Get the token type without validating
tokenType, _, err := service.GetTokenType(tokenString)
require.Error(t, err, "GetTokenType should return an error for tokens without type claim")
assert.Empty(t, tokenType, "Token type should be empty when type claim is missing")
assert.Contains(t, err.Error(), "failed to get token type claim", "Error message should indicate missing token type claim")
})
}
func importKey(t *testing.T, privateKeyRaw any, path string) string {
t.Helper()
privateKey, err := importRawKey(privateKeyRaw)
privateKey, err := utils.ImportRawKey(privateKeyRaw)
require.NoError(t, err, "Failed to import private key")
err = SaveKeyJWK(privateKey, filepath.Join(path, PrivateKeyFile))

View File

@@ -3,18 +3,25 @@ package service
import (
"context"
"crypto/sha256"
"crypto/tls"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"log"
"log/slog"
"mime/multipart"
"net/http"
"os"
"regexp"
"slices"
"strings"
"time"
"github.com/lestrrat-go/httprc/v3"
"github.com/lestrrat-go/httprc/v3/errsink"
"github.com/lestrrat-go/jwx/v3/jwk"
"github.com/lestrrat-go/jwx/v3/jws"
"github.com/lestrrat-go/jwx/v3/jwt"
"golang.org/x/crypto/bcrypt"
"gorm.io/gorm"
@@ -31,6 +38,11 @@ const (
GrantTypeAuthorizationCode = "authorization_code"
GrantTypeRefreshToken = "refresh_token"
GrantTypeDeviceCode = "urn:ietf:params:oauth:grant-type:device_code"
ClientAssertionTypeJWTBearer = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer" //nolint:gosec
RefreshTokenDuration = 30 * 24 * time.Hour // 30 days
DeviceCodeDuration = 15 * time.Minute
)
type OidcService struct {
@@ -39,16 +51,61 @@ type OidcService struct {
appConfigService *AppConfigService
auditLogService *AuditLogService
customClaimService *CustomClaimService
httpClient *http.Client
jwkCache *jwk.Cache
}
func NewOidcService(db *gorm.DB, jwtService *JwtService, appConfigService *AppConfigService, auditLogService *AuditLogService, customClaimService *CustomClaimService) *OidcService {
return &OidcService{
func NewOidcService(
ctx context.Context,
db *gorm.DB,
jwtService *JwtService,
appConfigService *AppConfigService,
auditLogService *AuditLogService,
customClaimService *CustomClaimService,
) (s *OidcService, err error) {
s = &OidcService{
db: db,
jwtService: jwtService,
appConfigService: appConfigService,
auditLogService: auditLogService,
customClaimService: customClaimService,
}
// Note: we don't pass the HTTP Client with OTel instrumented to this because requests are always made in background and not tied to a specific trace
s.jwkCache, err = s.getJWKCache(ctx)
if err != nil {
return nil, err
}
return s, nil
}
func (s *OidcService) getJWKCache(ctx context.Context) (*jwk.Cache, error) {
// We need to create a custom HTTP client to set a timeout.
client := s.httpClient
if client == nil {
client = &http.Client{
Timeout: 20 * time.Second,
}
defaultTransport, ok := http.DefaultTransport.(*http.Transport)
if !ok {
// Indicates a development-time error
panic("Default transport is not of type *http.Transport")
}
transport := defaultTransport.Clone()
transport.TLSClientConfig.MinVersion = tls.VersionTLS12
client.Transport = transport
}
// Create the JWKS cache
return jwk.NewCache(ctx,
httprc.NewClient(
httprc.WithErrorSink(errsink.NewSlog(slog.Default())),
httprc.WithHTTPClient(client),
),
)
}
func (s *OidcService) Authorize(ctx context.Context, input dto.AuthorizeOidcClientRequestDto, userID, ipAddress, userAgent string) (string, string, error) {
@@ -198,7 +255,7 @@ func (s *OidcService) createTokenFromDeviceCode(ctx context.Context, input dto.O
tx.Rollback()
}()
_, err := s.verifyClientCredentialsInternal(ctx, input.ClientID, input.ClientSecret, tx)
_, err := s.verifyClientCredentialsInternal(ctx, tx, clientAuthCredentialsFromCreateTokensDto(&input))
if err != nil {
return CreatedTokens{}, err
}
@@ -249,7 +306,7 @@ func (s *OidcService) createTokenFromDeviceCode(ctx context.Context, input dto.O
return CreatedTokens{}, err
}
accessToken, err := s.jwtService.GenerateOauthAccessToken(deviceAuth.User, input.ClientID)
accessToken, err := s.jwtService.GenerateOAuthAccessToken(deviceAuth.User, input.ClientID)
if err != nil {
return CreatedTokens{}, err
}
@@ -279,7 +336,7 @@ func (s *OidcService) createTokenFromAuthorizationCode(ctx context.Context, inpu
tx.Rollback()
}()
client, err := s.verifyClientCredentialsInternal(ctx, input.ClientID, input.ClientSecret, tx)
client, err := s.verifyClientCredentialsInternal(ctx, tx, clientAuthCredentialsFromCreateTokensDto(&input))
if err != nil {
return CreatedTokens{}, err
}
@@ -321,7 +378,7 @@ func (s *OidcService) createTokenFromAuthorizationCode(ctx context.Context, inpu
return CreatedTokens{}, err
}
accessToken, err := s.jwtService.GenerateOauthAccessToken(authorizationCodeMetaData.User, input.ClientID)
accessToken, err := s.jwtService.GenerateOAuthAccessToken(authorizationCodeMetaData.User, input.ClientID)
if err != nil {
return CreatedTokens{}, err
}
@@ -352,22 +409,39 @@ func (s *OidcService) createTokenFromRefreshToken(ctx context.Context, input dto
return CreatedTokens{}, &common.OidcMissingRefreshTokenError{}
}
// Validate the signed refresh token and extract the actual token (which is a claim in the signed one)
userID, clientID, rt, err := s.jwtService.VerifyOAuthRefreshToken(input.RefreshToken)
if err != nil {
return CreatedTokens{}, &common.OidcInvalidRefreshTokenError{}
}
tx := s.db.Begin()
defer func() {
tx.Rollback()
}()
_, err := s.verifyClientCredentialsInternal(ctx, input.ClientID, input.ClientSecret, tx)
client, err := s.verifyClientCredentialsInternal(ctx, tx, clientAuthCredentialsFromCreateTokensDto(&input))
if err != nil {
return CreatedTokens{}, err
}
// The ID of the client that made the call must match the client ID in the token
if client.ID != clientID {
return CreatedTokens{}, &common.OidcInvalidRefreshTokenError{}
}
// Verify refresh token
var storedRefreshToken model.OidcRefreshToken
err = tx.
WithContext(ctx).
Preload("User").
Where("token = ? AND expires_at > ?", utils.CreateSha256Hash(input.RefreshToken), datatype.DateTime(time.Now())).
Where(
"token = ? AND expires_at > ? AND user_id = ? AND client_id = ?",
utils.CreateSha256Hash(rt),
datatype.DateTime(time.Now()),
userID,
input.ClientID,
).
First(&storedRefreshToken).
Error
if err != nil {
@@ -383,7 +457,7 @@ func (s *OidcService) createTokenFromRefreshToken(ctx context.Context, input dto
}
// Generate a new access token
accessToken, err := s.jwtService.GenerateOauthAccessToken(storedRefreshToken.User, input.ClientID)
accessToken, err := s.jwtService.GenerateOAuthAccessToken(storedRefreshToken.User, input.ClientID)
if err != nil {
return CreatedTokens{}, err
}
@@ -415,70 +489,125 @@ func (s *OidcService) createTokenFromRefreshToken(ctx context.Context, input dto
}, nil
}
func (s *OidcService) IntrospectToken(ctx context.Context, clientID, clientSecret, tokenString string) (introspectDto dto.OidcIntrospectionResponseDto, err error) {
if clientID == "" || clientSecret == "" {
func (s *OidcService) IntrospectToken(ctx context.Context, creds ClientAuthCredentials, tokenString string) (introspectDto dto.OidcIntrospectionResponseDto, err error) {
// Get the type of the token and the client ID
tokenType, token, err := s.jwtService.GetTokenType(tokenString)
if err != nil {
// We just treat the token as invalid
introspectDto.Active = false
return introspectDto, nil //nolint:nilerr
}
// If we don't have a client ID, get it from the token
// Otherwise, we need to make sure that the client ID passed as credential matches
tokenAudiences, _ := token.Audience()
if len(tokenAudiences) != 1 || tokenAudiences[0] == "" {
// We just treat the token as invalid
introspectDto.Active = false
return introspectDto, nil
}
if creds.ClientID == "" {
creds.ClientID = tokenAudiences[0]
} else if creds.ClientID != tokenAudiences[0] {
return introspectDto, &common.OidcMissingClientCredentialsError{}
}
_, err = s.verifyClientCredentialsInternal(ctx, clientID, clientSecret, s.db)
// Verify the credentials for the call
client, err := s.verifyClientCredentialsInternal(ctx, s.db, creds)
if err != nil {
return introspectDto, err
}
token, err := s.jwtService.VerifyOauthAccessToken(tokenString)
if err != nil {
if errors.Is(err, jwt.ParseError()) {
// It's apparently not a valid JWT token, so we check if it's a valid refresh_token.
return s.introspectRefreshToken(ctx, tokenString)
}
// Every failure we get means the token is invalid. Nothing more to do with the error.
// Introspect the token
switch tokenType {
case OAuthAccessTokenJWTType:
return s.introspectAccessToken(client.ID, tokenString)
case OAuthRefreshTokenJWTType:
return s.introspectRefreshToken(ctx, client.ID, tokenString)
default:
// We just treat the token as invalid
introspectDto.Active = false
return introspectDto, nil
}
}
func (s *OidcService) introspectAccessToken(clientID string, tokenString string) (introspectDto dto.OidcIntrospectionResponseDto, err error) {
token, err := s.jwtService.VerifyOAuthAccessToken(tokenString)
if err != nil {
// Every failure we get means the token is invalid. Nothing more to do with the error.
introspectDto.Active = false
return introspectDto, nil //nolint:nilerr
}
// The ID of the client that made the request must match the client ID in the token
audience, ok := token.Audience()
if !ok || len(audience) != 1 || audience[0] == "" {
introspectDto.Active = false
return introspectDto, nil
}
if audience[0] != clientID {
return introspectDto, &common.OidcMissingClientCredentialsError{}
}
introspectDto.Active = true
introspectDto.TokenType = "access_token"
introspectDto.Audience = audience
if token.Has("scope") {
var asString string
var asStrings []string
var (
asString string
asStrings []string
)
if err := token.Get("scope", &asString); err == nil {
introspectDto.Scope = asString
} else if err := token.Get("scope", &asStrings); err == nil {
introspectDto.Scope = strings.Join(asStrings, " ")
}
}
if expiration, hasExpiration := token.Expiration(); hasExpiration {
if expiration, ok := token.Expiration(); ok {
introspectDto.Expiration = expiration.Unix()
}
if issuedAt, hasIssuedAt := token.IssuedAt(); hasIssuedAt {
if issuedAt, ok := token.IssuedAt(); ok {
introspectDto.IssuedAt = issuedAt.Unix()
}
if notBefore, hasNotBefore := token.NotBefore(); hasNotBefore {
if notBefore, ok := token.NotBefore(); ok {
introspectDto.NotBefore = notBefore.Unix()
}
if subject, hasSubject := token.Subject(); hasSubject {
if subject, ok := token.Subject(); ok {
introspectDto.Subject = subject
}
if audience, hasAudience := token.Audience(); hasAudience {
introspectDto.Audience = audience
}
if issuer, hasIssuer := token.Issuer(); hasIssuer {
if issuer, ok := token.Issuer(); ok {
introspectDto.Issuer = issuer
}
if identifier, hasIdentifier := token.JwtID(); hasIdentifier {
if identifier, ok := token.JwtID(); ok {
introspectDto.Identifier = identifier
}
return introspectDto, nil
}
func (s *OidcService) introspectRefreshToken(ctx context.Context, refreshToken string) (introspectDto dto.OidcIntrospectionResponseDto, err error) {
func (s *OidcService) introspectRefreshToken(ctx context.Context, clientID string, refreshToken string) (introspectDto dto.OidcIntrospectionResponseDto, err error) {
// Validate the signed refresh token and extract the actual token (which is a claim in the signed one)
tokenUserID, tokenClientID, tokenRT, err := s.jwtService.VerifyOAuthRefreshToken(refreshToken)
if err != nil {
return introspectDto, fmt.Errorf("invalid refresh token: %w", err)
}
// The ID of the client that made the call must match the client ID in the token
if tokenClientID != clientID {
return introspectDto, errors.New("invalid refresh token: client ID does not match")
}
var storedRefreshToken model.OidcRefreshToken
err = s.db.
WithContext(ctx).
Preload("User").
Where("token = ? AND expires_at > ?", utils.CreateSha256Hash(refreshToken), datatype.DateTime(time.Now())).
Where(
"token = ? AND expires_at > ? AND user_id = ? AND client_id = ?",
utils.CreateSha256Hash(tokenRT),
datatype.DateTime(time.Now()),
tokenUserID,
tokenClientID,
).
First(&storedRefreshToken).
Error
if err != nil {
@@ -542,13 +671,9 @@ func (s *OidcService) ListClients(ctx context.Context, name string, sortedPagina
func (s *OidcService) CreateClient(ctx context.Context, input dto.OidcClientCreateDto, userID string) (model.OidcClient, error) {
client := model.OidcClient{
Name: input.Name,
CallbackURLs: input.CallbackURLs,
LogoutCallbackURLs: input.LogoutCallbackURLs,
CreatedByID: userID,
IsPublic: input.IsPublic,
PkceEnabled: input.PkceEnabled,
CreatedByID: userID,
}
updateOIDCClientModelFromDto(&client, &input)
err := s.db.
WithContext(ctx).
@@ -577,11 +702,7 @@ func (s *OidcService) UpdateClient(ctx context.Context, clientID string, input d
return model.OidcClient{}, err
}
client.Name = input.Name
client.CallbackURLs = input.CallbackURLs
client.LogoutCallbackURLs = input.LogoutCallbackURLs
client.IsPublic = input.IsPublic
client.PkceEnabled = input.IsPublic || input.PkceEnabled
updateOIDCClientModelFromDto(&client, &input)
err = tx.
WithContext(ctx).
@@ -599,6 +720,29 @@ func (s *OidcService) UpdateClient(ctx context.Context, clientID string, input d
return client, nil
}
func updateOIDCClientModelFromDto(client *model.OidcClient, input *dto.OidcClientCreateDto) {
// Base fields
client.Name = input.Name
client.CallbackURLs = input.CallbackURLs
client.LogoutCallbackURLs = input.LogoutCallbackURLs
client.IsPublic = input.IsPublic
// PKCE is required for public clients
client.PkceEnabled = input.IsPublic || input.PkceEnabled
// Credentials
if len(input.Credentials.FederatedIdentities) > 0 {
client.Credentials.FederatedIdentities = make([]model.OidcClientFederatedIdentity, len(input.Credentials.FederatedIdentities))
for i, fi := range input.Credentials.FederatedIdentities {
client.Credentials.FederatedIdentities[i] = model.OidcClientFederatedIdentity{
Issuer: fi.Issuer,
Audience: fi.Audience,
Subject: fi.Subject,
JWKS: fi.JWKS,
}
}
}
}
func (s *OidcService) DeleteClient(ctx context.Context, clientID string) error {
var client model.OidcClient
err := s.db.
@@ -744,6 +888,7 @@ func (s *OidcService) DeleteClientLogo(ctx context.Context, clientID string) err
return errors.New("image not found")
}
oldImageType := *client.ImageType
client.ImageType = nil
err = tx.
WithContext(ctx).
@@ -753,7 +898,7 @@ func (s *OidcService) DeleteClientLogo(ctx context.Context, clientID string) err
return err
}
imagePath := common.EnvConfig.UploadPath + "/oidc-client-images/" + client.ID + "." + *client.ImageType
imagePath := common.EnvConfig.UploadPath + "/oidc-client-images/" + client.ID + "." + oldImageType
if err := os.Remove(imagePath); err != nil {
return err
}
@@ -766,97 +911,6 @@ func (s *OidcService) DeleteClientLogo(ctx context.Context, clientID string) err
return nil
}
func (s *OidcService) GetUserClaimsForClient(ctx context.Context, userID string, clientID string) (map[string]interface{}, error) {
tx := s.db.Begin()
defer func() {
tx.Rollback()
}()
claims, err := s.getUserClaimsForClientInternal(ctx, userID, clientID, s.db)
if err != nil {
return nil, err
}
err = tx.Commit().Error
if err != nil {
return nil, err
}
return claims, nil
}
func (s *OidcService) getUserClaimsForClientInternal(ctx context.Context, userID string, clientID string, tx *gorm.DB) (map[string]interface{}, error) {
var authorizedOidcClient model.UserAuthorizedOidcClient
err := tx.
WithContext(ctx).
Preload("User.UserGroups").
First(&authorizedOidcClient, "user_id = ? AND client_id = ?", userID, clientID).
Error
if err != nil {
return nil, err
}
user := authorizedOidcClient.User
scopes := strings.Split(authorizedOidcClient.Scope, " ")
claims := map[string]interface{}{
"sub": user.ID,
}
if slices.Contains(scopes, "email") {
claims["email"] = user.Email
claims["email_verified"] = s.appConfigService.GetDbConfig().EmailsVerified.IsTrue()
}
if slices.Contains(scopes, "groups") {
userGroups := make([]string, len(user.UserGroups))
for i, group := range user.UserGroups {
userGroups[i] = group.Name
}
claims["groups"] = userGroups
}
profileClaims := map[string]interface{}{
"given_name": user.FirstName,
"family_name": user.LastName,
"name": user.FullName(),
"preferred_username": user.Username,
"picture": common.EnvConfig.AppURL + "/api/users/" + user.ID + "/profile-picture.png",
}
if slices.Contains(scopes, "profile") {
// Add profile claims
for k, v := range profileClaims {
claims[k] = v
}
// Add custom claims
customClaims, err := s.customClaimService.GetCustomClaimsForUserWithUserGroups(ctx, userID, tx)
if err != nil {
return nil, err
}
for _, customClaim := range customClaims {
// The value of the custom claim can be a JSON object or a string
var jsonValue interface{}
err := json.Unmarshal([]byte(customClaim.Value), &jsonValue)
if err == nil {
// It's JSON so we store it as an object
claims[customClaim.Key] = jsonValue
} else {
// Marshalling failed, so we store it as a string
claims[customClaim.Key] = customClaim.Value
}
}
}
if slices.Contains(scopes, "email") {
claims["email"] = user.Email
}
return claims, nil
}
func (s *OidcService) UpdateAllowedUserGroups(ctx context.Context, id string, input dto.OidcUpdateAllowedUserGroupsDto) (client model.OidcClient, err error) {
tx := s.db.Begin()
defer func() {
@@ -1078,7 +1132,12 @@ func (s *OidcService) addCallbackURLToClient(ctx context.Context, client *model.
}
func (s *OidcService) CreateDeviceAuthorization(ctx context.Context, input dto.OidcDeviceAuthorizationRequestDto) (*dto.OidcDeviceAuthorizationResponseDto, error) {
client, err := s.verifyClientCredentialsInternal(ctx, input.ClientID, input.ClientSecret, s.db)
client, err := s.verifyClientCredentialsInternal(ctx, s.db, ClientAuthCredentials{
ClientID: input.ClientID,
ClientSecret: input.ClientSecret,
ClientAssertionType: input.ClientAssertionType,
ClientAssertion: input.ClientAssertion,
})
if err != nil {
return nil, err
}
@@ -1098,7 +1157,7 @@ func (s *OidcService) CreateDeviceAuthorization(ctx context.Context, input dto.O
DeviceCode: deviceCode,
UserCode: userCode,
Scope: input.Scope,
ExpiresAt: datatype.DateTime(time.Now().Add(15 * time.Minute)),
ExpiresAt: datatype.DateTime(time.Now().Add(DeviceCodeDuration)),
IsAuthorized: false,
ClientID: client.ID,
}
@@ -1112,7 +1171,7 @@ func (s *OidcService) CreateDeviceAuthorization(ctx context.Context, input dto.O
UserCode: userCode,
VerificationURI: common.EnvConfig.AppURL + "/device",
VerificationURIComplete: common.EnvConfig.AppURL + "/device?code=" + userCode,
ExpiresIn: 900, // 15 minutes
ExpiresIn: int(DeviceCodeDuration.Seconds()),
Interval: 5,
}, nil
}
@@ -1243,6 +1302,20 @@ func (s *OidcService) GetAllowedGroupsCountOfClient(ctx context.Context, id stri
return count, nil
}
func (s *OidcService) ListAuthorizedClients(ctx context.Context, userID string, sortedPaginationRequest utils.SortedPaginationRequest) ([]model.UserAuthorizedOidcClient, utils.PaginationResponse, error) {
query := s.db.
WithContext(ctx).
Model(&model.UserAuthorizedOidcClient{}).
Preload("Client").
Where("user_id = ?", userID)
var authorizedClients []model.UserAuthorizedOidcClient
response, err := utils.PaginateAndSort(sortedPaginationRequest, query, &authorizedClients)
return authorizedClients, response, err
}
func (s *OidcService) createRefreshToken(ctx context.Context, clientID string, userID string, scope string, tx *gorm.DB) (string, error) {
refreshToken, err := utils.GenerateRandomAlphanumericString(40)
if err != nil {
@@ -1254,7 +1327,7 @@ func (s *OidcService) createRefreshToken(ctx context.Context, clientID string, u
refreshTokenHash := utils.CreateSha256Hash(refreshToken)
m := model.OidcRefreshToken{
ExpiresAt: datatype.DateTime(time.Now().Add(30 * 24 * time.Hour)), // 30 days
ExpiresAt: datatype.DateTime(time.Now().Add(RefreshTokenDuration)),
Token: refreshTokenHash,
ClientID: clientID,
UserID: userID,
@@ -1269,7 +1342,13 @@ func (s *OidcService) createRefreshToken(ctx context.Context, clientID string, u
return "", err
}
return refreshToken, nil
// Sign the refresh token
signed, err := s.jwtService.GenerateOAuthRefreshToken(userID, clientID, refreshToken)
if err != nil {
return "", fmt.Errorf("failed to sign refresh token: %w", err)
}
return signed, nil
}
func (s *OidcService) createAuthorizedClientInternal(ctx context.Context, userID string, clientID string, scope string, tx *gorm.DB) error {
@@ -1290,33 +1369,300 @@ func (s *OidcService) createAuthorizedClientInternal(ctx context.Context, userID
return err
}
func (s *OidcService) verifyClientCredentialsInternal(ctx context.Context, clientID, clientSecret string, tx *gorm.DB) (model.OidcClient, error) {
type ClientAuthCredentials struct {
ClientID string
ClientSecret string
ClientAssertion string
ClientAssertionType string
}
func clientAuthCredentialsFromCreateTokensDto(d *dto.OidcCreateTokensDto) ClientAuthCredentials {
return ClientAuthCredentials{
ClientID: d.ClientID,
ClientSecret: d.ClientSecret,
ClientAssertion: d.ClientAssertion,
ClientAssertionType: d.ClientAssertionType,
}
}
func (s *OidcService) verifyClientCredentialsInternal(ctx context.Context, tx *gorm.DB, input ClientAuthCredentials) (*model.OidcClient, error) {
// First, ensure we have a valid client ID
if clientID == "" {
return model.OidcClient{}, &common.OidcMissingClientCredentialsError{}
if input.ClientID == "" {
return nil, &common.OidcMissingClientCredentialsError{}
}
// Load the OIDC client's configuration
var client model.OidcClient
err := tx.
WithContext(ctx).
First(&client, "id = ?", clientID).
First(&client, "id = ?", input.ClientID).
Error
if err != nil {
return model.OidcClient{}, err
return nil, err
}
// If we have a client secret, we validate it
// Otherwise, we require the client to be public
if clientSecret != "" {
err = bcrypt.CompareHashAndPassword([]byte(client.Secret), []byte(clientSecret))
// We have 3 options
// If credentials are provided, we validate them; otherwise, we can continue without credentials for public clients only
switch {
// First, if we have a client secret, we validate it
case input.ClientSecret != "":
err = bcrypt.CompareHashAndPassword([]byte(client.Secret), []byte(input.ClientSecret))
if err != nil {
return model.OidcClient{}, &common.OidcClientSecretInvalidError{}
return nil, &common.OidcClientSecretInvalidError{}
}
return &client, nil
// Next, check if we want to use client assertions from federated identities
case input.ClientAssertionType == ClientAssertionTypeJWTBearer && input.ClientAssertion != "":
err = s.verifyClientAssertionFromFederatedIdentities(ctx, &client, input)
if err != nil {
log.Printf("Invalid assertion for client '%s': %v", client.ID, err)
return nil, &common.OidcClientAssertionInvalidError{}
}
return &client, nil
// There's no credentials
// This is allowed only if the client is public
case client.IsPublic:
return &client, nil
// If we're here, we have no credentials AND the client is not public, so credentials are required
default:
return nil, &common.OidcMissingClientCredentialsError{}
}
}
func (s *OidcService) jwkSetForURL(ctx context.Context, url string) (set jwk.Set, err error) {
// Check if we have already registered the URL
if !s.jwkCache.IsRegistered(ctx, url) {
// We set a timeout because otherwise Register will keep trying in case of errors
registerCtx, registerCancel := context.WithTimeout(ctx, 15*time.Second)
defer registerCancel()
// We need to register the URL
err = s.jwkCache.Register(
registerCtx,
url,
jwk.WithMaxInterval(24*time.Hour),
jwk.WithMinInterval(15*time.Minute),
jwk.WithWaitReady(true),
)
// In case of race conditions (two goroutines calling jwkCache.Register at the same time), it's possible we can get a conflict anyways, so we ignore that error
if err != nil && !errors.Is(err, httprc.ErrResourceAlreadyExists()) {
return nil, fmt.Errorf("failed to register JWK set: %w", err)
}
return client, nil
} else if !client.IsPublic {
return model.OidcClient{}, &common.OidcMissingClientCredentialsError{}
}
return client, nil
jwks, err := s.jwkCache.CachedSet(url)
if err != nil {
return nil, fmt.Errorf("failed to get cached JWK set: %w", err)
}
return jwks, nil
}
func (s *OidcService) verifyClientAssertionFromFederatedIdentities(ctx context.Context, client *model.OidcClient, input ClientAuthCredentials) error {
// First, parse the assertion JWT, without validating it, to check the issuer
assertion := []byte(input.ClientAssertion)
insecureToken, err := jwt.ParseInsecure(assertion)
if err != nil {
return fmt.Errorf("failed to parse client assertion JWT: %w", err)
}
issuer, _ := insecureToken.Issuer()
if issuer == "" {
return errors.New("client assertion does not contain an issuer claim")
}
// Ensure that this client is federated with the one that issued the token
ocfi, ok := client.Credentials.FederatedIdentityForIssuer(issuer)
if !ok {
return fmt.Errorf("client assertion is not from an allowed issuer: %s", issuer)
}
// Get the JWK set for the issuer
jwksURL := ocfi.JWKS
if jwksURL == "" {
// Default URL is from the issuer
if strings.HasSuffix(issuer, "/") {
jwksURL = issuer + ".well-known/jwks.json"
} else {
jwksURL = issuer + "/.well-known/jwks.json"
}
}
jwks, err := s.jwkSetForURL(ctx, jwksURL)
if err != nil {
return fmt.Errorf("failed to get JWK set for issuer '%s': %w", issuer, err)
}
// Set default audience and subject if missing
audience := ocfi.Audience
if audience == "" {
// Default to the Pocket ID's URL
audience = common.EnvConfig.AppURL
}
subject := ocfi.Subject
if subject == "" {
// Default to the client ID, per RFC 7523
subject = client.ID
}
// Now re-parse the token with proper validation
// (Note: we don't use jwt.WithIssuer() because that would be redundant)
_, err = jwt.Parse(assertion,
jwt.WithValidate(true),
jwt.WithAcceptableSkew(clockSkew),
jwt.WithKeySet(jwks, jws.WithInferAlgorithmFromKey(true), jws.WithUseDefault(true)),
jwt.WithAudience(audience),
jwt.WithSubject(subject),
)
if err != nil {
return fmt.Errorf("client assertion is not valid: %w", err)
}
// If we're here, the assertion is valid
return nil
}
func (s *OidcService) GetClientPreview(ctx context.Context, clientID string, userID string, scopes string) (*dto.OidcClientPreviewDto, error) {
tx := s.db.Begin()
defer func() {
tx.Rollback()
}()
client, err := s.getClientInternal(ctx, clientID, tx)
if err != nil {
return nil, err
}
var user model.User
err = tx.
WithContext(ctx).
Preload("UserGroups").
First(&user, "id = ?", userID).
Error
if err != nil {
return nil, err
}
if !s.IsUserGroupAllowedToAuthorize(user, client) {
return nil, &common.OidcAccessDeniedError{}
}
dummyAuthorizedClient := model.UserAuthorizedOidcClient{
UserID: userID,
ClientID: clientID,
Scope: scopes,
User: user,
}
userClaims, err := s.getUserClaimsFromAuthorizedClient(ctx, &dummyAuthorizedClient, tx)
if err != nil {
return nil, err
}
// Commit the transaction before signing tokens to avoid locking the database for longer
err = tx.Commit().Error
if err != nil {
return nil, err
}
idToken, err := s.jwtService.BuildIDToken(userClaims, clientID, "")
if err != nil {
return nil, err
}
accessToken, err := s.jwtService.BuildOAuthAccessToken(user, clientID)
if err != nil {
return nil, err
}
idTokenPayload, err := utils.GetClaimsFromToken(idToken)
if err != nil {
return nil, err
}
accessTokenPayload, err := utils.GetClaimsFromToken(accessToken)
if err != nil {
return nil, err
}
return &dto.OidcClientPreviewDto{
IdToken: idTokenPayload,
AccessToken: accessTokenPayload,
UserInfo: userClaims,
}, nil
}
func (s *OidcService) GetUserClaimsForClient(ctx context.Context, userID string, clientID string) (map[string]any, error) {
return s.getUserClaimsForClientInternal(ctx, userID, clientID, s.db)
}
func (s *OidcService) getUserClaimsForClientInternal(ctx context.Context, userID string, clientID string, tx *gorm.DB) (map[string]any, error) {
var authorizedOidcClient model.UserAuthorizedOidcClient
err := tx.
WithContext(ctx).
Preload("User.UserGroups").
First(&authorizedOidcClient, "user_id = ? AND client_id = ?", userID, clientID).
Error
if err != nil {
return nil, err
}
return s.getUserClaimsFromAuthorizedClient(ctx, &authorizedOidcClient, tx)
}
func (s *OidcService) getUserClaimsFromAuthorizedClient(ctx context.Context, authorizedClient *model.UserAuthorizedOidcClient, tx *gorm.DB) (map[string]any, error) {
user := authorizedClient.User
scopes := strings.Split(authorizedClient.Scope, " ")
claims := make(map[string]any, 10)
claims["sub"] = user.ID
if slices.Contains(scopes, "email") {
claims["email"] = user.Email
claims["email_verified"] = s.appConfigService.GetDbConfig().EmailsVerified.IsTrue()
}
if slices.Contains(scopes, "groups") {
userGroups := make([]string, len(user.UserGroups))
for i, group := range user.UserGroups {
userGroups[i] = group.Name
}
claims["groups"] = userGroups
}
if slices.Contains(scopes, "profile") {
// Add profile claims
claims["given_name"] = user.FirstName
claims["family_name"] = user.LastName
claims["name"] = user.FullName()
claims["preferred_username"] = user.Username
claims["picture"] = common.EnvConfig.AppURL + "/api/users/" + user.ID + "/profile-picture.png"
// Add custom claims
customClaims, err := s.customClaimService.GetCustomClaimsForUserWithUserGroups(ctx, user.ID, tx)
if err != nil {
return nil, err
}
for _, customClaim := range customClaims {
// The value of the custom claim can be a JSON object or a string
var jsonValue any
err := json.Unmarshal([]byte(customClaim.Value), &jsonValue)
if err == nil {
// It's JSON, so we store it as an object
claims[customClaim.Key] = jsonValue
} else {
// Marshaling failed, so we store it as a string
claims[customClaim.Key] = customClaim.Value
}
}
}
if slices.Contains(scopes, "email") {
claims["email"] = user.Email
}
return claims, nil
}

View File

@@ -0,0 +1,365 @@
package service
import (
"context"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"encoding/json"
"net/http"
"testing"
"time"
"github.com/lestrrat-go/jwx/v3/jwa"
"github.com/lestrrat-go/jwx/v3/jwk"
"github.com/lestrrat-go/jwx/v3/jwt"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/pocket-id/pocket-id/backend/internal/common"
"github.com/pocket-id/pocket-id/backend/internal/dto"
)
// generateTestECDSAKey creates an ECDSA key for testing
func generateTestECDSAKey(t *testing.T) (jwk.Key, []byte) {
t.Helper()
privateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
require.NoError(t, err)
privateJwk, err := jwk.Import(privateKey)
require.NoError(t, err)
err = privateJwk.Set(jwk.KeyIDKey, "test-key-1")
require.NoError(t, err)
err = privateJwk.Set(jwk.AlgorithmKey, "ES256")
require.NoError(t, err)
err = privateJwk.Set("use", "sig")
require.NoError(t, err)
publicJwk, err := jwk.PublicKeyOf(privateJwk)
require.NoError(t, err)
// Create a JWK Set with the public key
jwkSet := jwk.NewSet()
err = jwkSet.AddKey(publicJwk)
require.NoError(t, err)
jwkSetJSON, err := json.Marshal(jwkSet)
require.NoError(t, err)
return privateJwk, jwkSetJSON
}
func TestOidcService_jwkSetForURL(t *testing.T) {
// Generate a test key for JWKS
_, jwkSetJSON1 := generateTestECDSAKey(t)
_, jwkSetJSON2 := generateTestECDSAKey(t)
// Create a mock HTTP client with responses for different URLs
const (
url1 = "https://example.com/.well-known/jwks.json"
url2 = "https://other-issuer.com/jwks"
)
mockResponses := map[string]*http.Response{
//nolint:bodyclose
url1: NewMockResponse(http.StatusOK, string(jwkSetJSON1)),
//nolint:bodyclose
url2: NewMockResponse(http.StatusOK, string(jwkSetJSON2)),
}
httpClient := &http.Client{
Transport: &MockRoundTripper{
Responses: mockResponses,
},
}
// Create the OidcService with our mock client
s := &OidcService{
httpClient: httpClient,
}
var err error
s.jwkCache, err = s.getJWKCache(t.Context())
require.NoError(t, err)
t.Run("Fetches and caches JWK set", func(t *testing.T) {
jwks, err := s.jwkSetForURL(t.Context(), url1)
require.NoError(t, err)
require.NotNil(t, jwks)
// Verify the JWK set contains our key
require.Equal(t, 1, jwks.Len())
})
t.Run("Fails with invalid URL", func(t *testing.T) {
ctx, cancel := context.WithTimeout(t.Context(), 2*time.Second)
defer cancel()
_, err := s.jwkSetForURL(ctx, "https://bad-url.com")
require.Error(t, err)
require.ErrorIs(t, err, context.DeadlineExceeded)
})
t.Run("Safe for concurrent use", func(t *testing.T) {
const concurrency = 20
// Channel to collect errors
errChan := make(chan error, concurrency)
// Start concurrent requests
for range concurrency {
go func() {
jwks, err := s.jwkSetForURL(t.Context(), url2)
if err != nil {
errChan <- err
return
}
// Verify the JWK set is valid
if jwks == nil || jwks.Len() != 1 {
errChan <- assert.AnError
return
}
errChan <- nil
}()
}
// Check for errors
for range concurrency {
assert.NoError(t, <-errChan, "Concurrent JWK set fetching should not produce errors")
}
})
}
func TestOidcService_verifyClientCredentialsInternal(t *testing.T) {
const (
federatedClientIssuer = "https://external-idp.com"
federatedClientAudience = "https://pocket-id.com"
federatedClientSubject = "123456abcdef"
federatedClientIssuerDefaults = "https://external-idp-defaults.com/"
)
var err error
// Create a test database
db := newDatabaseForTest(t)
// Create two JWKs for testing
privateJWK, jwkSetJSON := generateTestECDSAKey(t)
require.NoError(t, err)
privateJWKDefaults, jwkSetJSONDefaults := generateTestECDSAKey(t)
require.NoError(t, err)
// Create a mock HTTP client with custom transport to return the JWKS
httpClient := &http.Client{
Transport: &MockRoundTripper{
Responses: map[string]*http.Response{
//nolint:bodyclose
federatedClientIssuer + "/jwks.json": NewMockResponse(http.StatusOK, string(jwkSetJSON)),
//nolint:bodyclose
federatedClientIssuerDefaults + ".well-known/jwks.json": NewMockResponse(http.StatusOK, string(jwkSetJSONDefaults)),
},
},
}
// Init the OidcService
s := &OidcService{
db: db,
httpClient: httpClient,
}
s.jwkCache, err = s.getJWKCache(t.Context())
require.NoError(t, err)
// Create the test clients
// 1. Confidential client
confidentialClient, err := s.CreateClient(t.Context(), dto.OidcClientCreateDto{
Name: "Confidential Client",
CallbackURLs: []string{"https://example.com/callback"},
}, "test-user-id")
require.NoError(t, err)
// Create a client secret for the confidential client
confidentialSecret, err := s.CreateClientSecret(t.Context(), confidentialClient.ID)
require.NoError(t, err)
// 2. Public client
publicClient, err := s.CreateClient(t.Context(), dto.OidcClientCreateDto{
Name: "Public Client",
CallbackURLs: []string{"https://example.com/callback"},
IsPublic: true,
}, "test-user-id")
require.NoError(t, err)
// 3. Confidential client with federated identity
federatedClient, err := s.CreateClient(t.Context(), dto.OidcClientCreateDto{
Name: "Federated Client",
CallbackURLs: []string{"https://example.com/callback"},
Credentials: dto.OidcClientCredentialsDto{
FederatedIdentities: []dto.OidcClientFederatedIdentityDto{
{
Issuer: federatedClientIssuer,
Audience: federatedClientAudience,
Subject: federatedClientSubject,
JWKS: federatedClientIssuer + "/jwks.json",
},
{Issuer: federatedClientIssuerDefaults},
},
},
}, "test-user-id")
require.NoError(t, err)
// Test cases for confidential client (using client secret)
t.Run("Confidential client", func(t *testing.T) {
t.Run("Succeeds with valid secret", func(t *testing.T) {
// Test with valid client credentials
client, err := s.verifyClientCredentialsInternal(t.Context(), s.db, ClientAuthCredentials{
ClientID: confidentialClient.ID,
ClientSecret: confidentialSecret,
})
require.NoError(t, err)
require.NotNil(t, client)
assert.Equal(t, confidentialClient.ID, client.ID)
})
t.Run("Fails with invalid secret", func(t *testing.T) {
// Test with invalid client secret
client, err := s.verifyClientCredentialsInternal(t.Context(), s.db, ClientAuthCredentials{
ClientID: confidentialClient.ID,
ClientSecret: "invalid-secret",
})
require.Error(t, err)
require.ErrorIs(t, err, &common.OidcClientSecretInvalidError{})
assert.Nil(t, client)
})
t.Run("Fails with missing secret", func(t *testing.T) {
// Test with missing client secret
client, err := s.verifyClientCredentialsInternal(t.Context(), s.db, ClientAuthCredentials{
ClientID: confidentialClient.ID,
})
require.Error(t, err)
require.ErrorIs(t, err, &common.OidcMissingClientCredentialsError{})
assert.Nil(t, client)
})
})
// Test cases for public client
t.Run("Public client", func(t *testing.T) {
t.Run("Succeeds with no credentials", func(t *testing.T) {
// Public clients don't require client secret
client, err := s.verifyClientCredentialsInternal(t.Context(), s.db, ClientAuthCredentials{
ClientID: publicClient.ID,
})
require.NoError(t, err)
require.NotNil(t, client)
assert.Equal(t, publicClient.ID, client.ID)
})
})
// Test cases for federated client using JWT assertion
t.Run("Federated client", func(t *testing.T) {
t.Run("Succeeds with valid JWT", func(t *testing.T) {
// Create JWT for federated identity
token, err := jwt.NewBuilder().
Issuer(federatedClientIssuer).
Audience([]string{federatedClientAudience}).
Subject(federatedClientSubject).
IssuedAt(time.Now()).
Expiration(time.Now().Add(10 * time.Minute)).
Build()
require.NoError(t, err)
signedToken, err := jwt.Sign(token, jwt.WithKey(jwa.ES256(), privateJWK))
require.NoError(t, err)
// Test with valid JWT assertion
client, err := s.verifyClientCredentialsInternal(t.Context(), s.db, ClientAuthCredentials{
ClientID: federatedClient.ID,
ClientAssertionType: ClientAssertionTypeJWTBearer,
ClientAssertion: string(signedToken),
})
require.NoError(t, err)
require.NotNil(t, client)
assert.Equal(t, federatedClient.ID, client.ID)
})
t.Run("Fails with malformed JWT", func(t *testing.T) {
// Test with invalid JWT assertion (just a random string)
client, err := s.verifyClientCredentialsInternal(t.Context(), s.db, ClientAuthCredentials{
ClientID: federatedClient.ID,
ClientAssertionType: ClientAssertionTypeJWTBearer,
ClientAssertion: "invalid.jwt.token",
})
require.Error(t, err)
require.ErrorIs(t, err, &common.OidcClientAssertionInvalidError{})
assert.Nil(t, client)
})
testBadJWT := func(builderFn func(builder *jwt.Builder)) func(t *testing.T) {
return func(t *testing.T) {
// Populate all claims with valid values
builder := jwt.NewBuilder().
Issuer(federatedClientIssuer).
Audience([]string{federatedClientAudience}).
Subject(federatedClientSubject).
IssuedAt(time.Now()).
Expiration(time.Now().Add(10 * time.Minute))
// Call builderFn to override the claims
builderFn(builder)
token, err := builder.Build()
require.NoError(t, err)
signedToken, err := jwt.Sign(token, jwt.WithKey(jwa.ES256(), privateJWK))
require.NoError(t, err)
// Test with invalid JWT assertion
client, err := s.verifyClientCredentialsInternal(t.Context(), s.db, ClientAuthCredentials{
ClientID: federatedClient.ID,
ClientAssertionType: ClientAssertionTypeJWTBearer,
ClientAssertion: string(signedToken),
})
require.Error(t, err)
require.ErrorIs(t, err, &common.OidcClientAssertionInvalidError{})
require.Nil(t, client)
}
}
t.Run("Fails with expired JWT", testBadJWT(func(builder *jwt.Builder) {
builder.Expiration(time.Now().Add(-30 * time.Minute))
}))
t.Run("Fails with wrong issuer in JWT", testBadJWT(func(builder *jwt.Builder) {
builder.Issuer("https://bad-issuer.com")
}))
t.Run("Fails with wrong audience in JWT", testBadJWT(func(builder *jwt.Builder) {
builder.Audience([]string{"bad-audience"})
}))
t.Run("Fails with wrong subject in JWT", testBadJWT(func(builder *jwt.Builder) {
builder.Subject("bad-subject")
}))
t.Run("Uses default values for audience and subject", func(t *testing.T) {
// Create JWT for federated identity
token, err := jwt.NewBuilder().
Issuer(federatedClientIssuerDefaults).
Audience([]string{common.EnvConfig.AppURL}).
Subject(federatedClient.ID).
IssuedAt(time.Now()).
Expiration(time.Now().Add(10 * time.Minute)).
Build()
require.NoError(t, err)
signedToken, err := jwt.Sign(token, jwt.WithKey(jwa.ES256(), privateJWKDefaults))
require.NoError(t, err)
// Test with valid JWT assertion
client, err := s.verifyClientCredentialsInternal(t.Context(), s.db, ClientAuthCredentials{
ClientID: federatedClient.ID,
ClientAssertionType: ClientAssertionTypeJWTBearer,
ClientAssertion: string(signedToken),
})
require.NoError(t, err)
require.NotNil(t, client)
assert.Equal(t, federatedClient.ID, client.ID)
})
})
}

View File

@@ -0,0 +1,97 @@
package service
import (
"io"
"net/http"
"strings"
"testing"
"time"
_ "github.com/golang-migrate/migrate/v4/source/file"
"github.com/glebarez/sqlite"
"github.com/golang-migrate/migrate/v4"
sqliteMigrate "github.com/golang-migrate/migrate/v4/database/sqlite3"
"github.com/golang-migrate/migrate/v4/source/iofs"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
"gorm.io/gorm/logger"
"github.com/pocket-id/pocket-id/backend/internal/utils"
"github.com/pocket-id/pocket-id/backend/resources"
)
func newDatabaseForTest(t *testing.T) *gorm.DB {
t.Helper()
// Get a name for this in-memory database that is specific to the test
dbName := utils.CreateSha256Hash(t.Name())
// Connect to a new in-memory SQL database
db, err := gorm.Open(
sqlite.Open("file:"+dbName+"?mode=memory&cache=shared"),
&gorm.Config{
TranslateError: true,
Logger: logger.New(
testLoggerAdapter{t: t},
logger.Config{
SlowThreshold: 200 * time.Millisecond,
LogLevel: logger.Info,
IgnoreRecordNotFoundError: false,
ParameterizedQueries: false,
Colorful: false,
},
),
})
require.NoError(t, err, "Failed to connect to test database")
// Perform migrations with the embedded migrations
sqlDB, err := db.DB()
require.NoError(t, err, "Failed to get sql.DB")
driver, err := sqliteMigrate.WithInstance(sqlDB, &sqliteMigrate.Config{})
require.NoError(t, err, "Failed to create migration driver")
source, err := iofs.New(resources.FS, "migrations/sqlite")
require.NoError(t, err, "Failed to create embedded migration source")
m, err := migrate.NewWithInstance("iofs", source, "pocket-id", driver)
require.NoError(t, err, "Failed to create migration instance")
err = m.Up()
require.NoError(t, err, "Failed to perform migrations")
return db
}
// Implements gorm's logger.Writer interface
type testLoggerAdapter struct {
t *testing.T
}
func (l testLoggerAdapter) Printf(format string, args ...any) {
l.t.Logf(format, args...)
}
// MockRoundTripper is a custom http.RoundTripper that returns responses based on the URL
type MockRoundTripper struct {
Err error
Responses map[string]*http.Response
}
// RoundTrip implements the http.RoundTripper interface
func (m *MockRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
// Check if we have a specific response for this URL
for url, resp := range m.Responses {
if req.URL.String() == url {
return resp, nil
}
}
return NewMockResponse(http.StatusNotFound, ""), nil
}
// NewMockResponse creates an http.Response with the given status code and body
func NewMockResponse(statusCode int, body string) *http.Response {
return &http.Response{
StatusCode: statusCode,
Body: io.NopCloser(strings.NewReader(body)),
Header: make(http.Header),
}
}

View File

@@ -0,0 +1,18 @@
package utils
import (
"net/http"
"strings"
)
// BearerAuth returns the value of the bearer token in the Authorization header if present
func BearerAuth(r *http.Request) (string, bool) {
const prefix = "bearer "
authHeader := r.Header.Get("Authorization")
if len(authHeader) >= len(prefix) && strings.ToLower(authHeader[:len(prefix)]) == prefix {
return authHeader[len(prefix):], true
}
return "", false
}

View File

@@ -0,0 +1,65 @@
package utils
import (
"net/http"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestBearerAuth(t *testing.T) {
tests := []struct {
name string
authHeader string
expectedToken string
expectedFound bool
}{
{
name: "Valid bearer token",
authHeader: "Bearer token123",
expectedToken: "token123",
expectedFound: true,
},
{
name: "Valid bearer token with mixed case",
authHeader: "beARer token456",
expectedToken: "token456",
expectedFound: true,
},
{
name: "No bearer prefix",
authHeader: "Basic dXNlcjpwYXNz",
expectedToken: "",
expectedFound: false,
},
{
name: "Empty auth header",
authHeader: "",
expectedToken: "",
expectedFound: false,
},
{
name: "Bearer prefix only",
authHeader: "Bearer ",
expectedToken: "",
expectedFound: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req, err := http.NewRequestWithContext(t.Context(), http.MethodGet, "http://example.com", nil)
require.NoError(t, err, "Failed to create request")
if tt.authHeader != "" {
req.Header.Set("Authorization", tt.authHeader)
}
token, found := BearerAuth(req)
assert.Equal(t, tt.expectedFound, found)
assert.Equal(t, tt.expectedToken, token)
})
}
}

View File

@@ -0,0 +1,69 @@
package utils
import (
"crypto/rand"
"encoding/base64"
"fmt"
"io"
"github.com/lestrrat-go/jwx/v3/jwa"
"github.com/lestrrat-go/jwx/v3/jwk"
)
const (
// KeyUsageSigning is the usage for the private keys, for the "use" property
KeyUsageSigning = "sig"
)
// ImportRawKey imports a crypto key in "raw" format (e.g. crypto.PrivateKey) into a jwk.Key.
// It also populates additional fields such as the key ID, usage, and alg.
func ImportRawKey(rawKey any) (jwk.Key, error) {
key, err := jwk.Import(rawKey)
if err != nil {
return nil, fmt.Errorf("failed to import generated private key: %w", err)
}
// Generate the key ID
kid, err := generateRandomKeyID()
if err != nil {
return nil, fmt.Errorf("failed to generate key ID: %w", err)
}
_ = key.Set(jwk.KeyIDKey, kid)
// Set other required fields
_ = key.Set(jwk.KeyUsageKey, KeyUsageSigning)
EnsureAlgInKey(key)
return key, nil
}
// generateRandomKeyID generates a random key ID.
func generateRandomKeyID() (string, error) {
buf := make([]byte, 8)
_, err := io.ReadFull(rand.Reader, buf)
if err != nil {
return "", fmt.Errorf("failed to read random bytes: %w", err)
}
return base64.RawURLEncoding.EncodeToString(buf), nil
}
// EnsureAlgInKey ensures that the key contains an "alg" parameter, set depending on the key type
func EnsureAlgInKey(key jwk.Key) {
_, ok := key.Algorithm()
if ok {
// Algorithm is already set
return
}
switch key.KeyType() {
case jwa.RSA():
// Default to RS256 for RSA keys
_ = key.Set(jwk.AlgorithmKey, jwa.RS256())
case jwa.EC():
// Default to ES256 for ECDSA keys
_ = key.Set(jwk.AlgorithmKey, jwa.ES256())
case jwa.OKP():
// Default to EdDSA for OKP keys
_ = key.Set(jwk.AlgorithmKey, jwa.EdDSA())
}
}

View File

@@ -0,0 +1,20 @@
package utils
import (
"fmt"
"github.com/lestrrat-go/jwx/v3/jwt"
)
func GetClaimsFromToken(token jwt.Token) (map[string]any, error) {
keys := token.Keys()
claims := make(map[string]any, len(keys))
for _, key := range keys {
var value any
if err := token.Get(key, &value); err != nil {
return nil, fmt.Errorf("failed to get claim %s: %w", key, err)
}
claims[key] = value
}
return claims, nil
}

View File

@@ -34,9 +34,12 @@ func PaginateAndSort(sortedPaginationRequest SortedPaginationRequest, query *gor
sortField, sortFieldFound := reflect.TypeOf(result).Elem().Elem().FieldByName(capitalizedSortColumn)
isSortable, _ := strconv.ParseBool(sortField.Tag.Get("sortable"))
isValidSortOrder := sort.Direction == "asc" || sort.Direction == "desc"
if sortFieldFound && isSortable && isValidSortOrder {
if sort.Direction == "" || (sort.Direction != "asc" && sort.Direction != "desc") {
sort.Direction = "asc"
}
if sortFieldFound && isSortable {
columnName := CamelCaseToSnakeCase(sort.Column)
query = query.Clauses(clause.OrderBy{
Columns: []clause.OrderByColumn{

File diff suppressed because one or more lines are too long

View File

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

View File

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

View File

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

View File

@@ -0,0 +1 @@
ALTER TABLE oidc_clients ADD COLUMN credentials TEXT NULL;

View File

@@ -1,10 +1,9 @@
{
"$schema": "https://next.shadcn-svelte.com/schema.json",
"$schema": "https://shadcn-svelte.com/schema.json",
"style": "default",
"tailwind": {
"config": "tailwind.config.ts",
"css": "src/app.css",
"baseColor": "zinc"
"baseColor": "neutral"
},
"aliases": {
"components": "$lib/components",
@@ -14,5 +13,5 @@
"lib": "$lib"
},
"typescript": true,
"registry": "https://next.shadcn-svelte.com/registry"
"registry": "https://shadcn-svelte.com/registry"
}

View File

@@ -144,8 +144,6 @@
"expires_at": "Vyprší",
"when_this_api_key_will_expire": "Až vyprší platnost tohoto API klíče.",
"optional_description_to_help_identify_this_keys_purpose": "Volitelný popis, který pomůže identifikovat účel tohoto klíče.",
"name_must_be_at_least_3_characters": "Název musí obsahovat alespoň 3 znaky",
"name_cannot_exceed_50_characters": "Název nesmí překročit 50 znaků",
"expiration_date_must_be_in_the_future": "Datum vypršení musí být v budoucnu",
"revoke_api_key": "Zrušit API klíč",
"never": "Nikdy",
@@ -168,7 +166,7 @@
"test_email_sent_successfully": "Testovací e-mail byl úspěšně odeslán na vaši e-mailovou adresu.",
"failed_to_send_test_email": "Nepodařilo se odeslat testovací e-mail. Pro více informací zkontrolujte protokoly serveru.",
"smtp_configuration": "Nastavení SMTP",
"smtp_host": "SMTP Host",
"smtp_host": "SMTP Hostitel",
"smtp_port": "SMTP Port",
"smtp_user": "SMTP Uživatel",
"smtp_password": "SMTP Heslo",
@@ -198,8 +196,8 @@
"ldap_sync_finished": "LDAP synchronizace dokončena",
"client_configuration": "Nastavení klienta",
"ldap_url": "LDAP URL",
"ldap_bind_dn": "LDAP Bind DN",
"ldap_bind_password": "LDAP Bind Password",
"ldap_bind_dn": "LDAP Uživatel",
"ldap_bind_password": "LDAP Heslo",
"ldap_base_dn": "LDAP Base DN",
"user_search_filter": "Filtr vyhledávání uživatelů",
"the_search_filter_to_use_to_search_or_sync_users": "Hledaný filtr pro vyhledávání/synchronizaci uživatelů.",
@@ -289,8 +287,8 @@
"oidc_discovery_url": "OIDC Discovery URL",
"token_url": "Token URL",
"userinfo_url": "Userinfo URL",
"logout_url": "Logout URL",
"certificate_url": "Certificate URL",
"logout_url": "URL pro odhlášení",
"certificate_url": "URL certifikátu",
"enabled": "Povoleno",
"disabled": "Zakázáno",
"oidc_client_updated_successfully": "OIDC klient úspěšně aktualizován",
@@ -301,11 +299,11 @@
"allowed_user_groups_updated_successfully": "Povolené skupiny uživatelů byly úspěšně aktualizovány",
"oidc_client_name": "OIDC Klient {name}",
"client_id": "ID klienta",
"client_secret": "Client secret",
"client_secret": "Tajný klíč",
"show_more_details": "Zobrazit další podrobnosti",
"allowed_user_groups": "Povolené skupiny uživatelů",
"add_user_groups_to_this_client_to_restrict_access_to_users_in_these_groups": "Přidejte do tohoto klienta uživatelské skupiny, abyste omezili přístup pouze pro uživatele v těchto skupinách. Pokud nejsou vybrány žádné skupiny uživatelů, všichni uživatelé budou mít přístup k tomuto klientovi.",
"favicon": "Favicon",
"favicon": "Favicona",
"light_mode_logo": "Logo světlého režimu",
"dark_mode_logo": "Logo tmavého režimu",
"background_image": "Obrázek na pozadí",
@@ -327,7 +325,7 @@
"client_authorization": "Autorizace klienta",
"new_client_authorization": "Nová autorizace klienta",
"disable_animations": "Zakázat animace",
"turn_off_all_animations_throughout_the_admin_ui": "Vypnout všechny animace v celém administrátorském rozhraní.",
"turn_off_ui_animations": "Vypnout všechny animace v celém administrátorském rozhraní.",
"user_disabled": "Účet deaktivován",
"disabled_users_cannot_log_in_or_use_services": "Zakázaní uživatelé se nemohou přihlásit nebo používat služby.",
"user_disabled_successfully": "Uživatel byl úspěšně deaktivován.",
@@ -340,14 +338,38 @@
"login_code_email_success": "Přihlašovací kód byl odeslán uživateli.",
"send_email": "Odeslat e-mail",
"show_code": "Zobrazit kód",
"callback_url_description": "URL(s) provided by your client. Will be automatically added if left blank. Wildcards (*) are supported, but best avoided for better security.",
"logout_callback_url_description": "URL(s) provided by your client for logout. Wildcards (*) are supported, but best avoided for better security.",
"callback_url_description": "URL poskytnuté vaším klientem. Bude automaticky přidáno, pokud necháte prázdné. Zástupné znaky (*) jsou podporovány, ale raději se jim vyhýbejte, pro lepší bezpečnost.",
"logout_callback_url_description": "URL poskytnuté klientem pro odhlášení. Klientské zástupné znaky (*) jsou podporovány, ale raději se jim vyhýbejte, pro lepší bezpečnost.",
"api_key_expiration": "Vypršení platnosti API klíče",
"send_an_email_to_the_user_when_their_api_key_is_about_to_expire": "Pošlete uživateli e-mail, jakmile jejich API klíč brzy vyprší.",
"authorize_device": "Autorizovat zařízení",
"the_device_has_been_authorized": "Zařízení bylo autorizováno.",
"enter_code_displayed_in_previous_step": "Zadejte kód, který byl zobrazen v předchozím kroku.",
"authorize": "Autorizovat",
"federated_client_credentials": "Federated Client Credentials",
"federated_client_credentials_description": "Using federated client credentials, you can authenticate OIDC clients using JWT tokens issued by third-party authorities.",
"add_federated_client_credential": "Add Federated Client Credential",
"add_another_federated_client_credential": "Add another federated client credential",
"oidc_allowed_group_count": "Počet povolených skupin",
"unrestricted": "Bez omezení"
"unrestricted": "Bez omezení",
"show_advanced_options": "Show Advanced Options",
"hide_advanced_options": "Hide Advanced Options",
"oidc_data_preview": "OIDC Data Preview",
"preview_the_oidc_data_that_would_be_sent_for_different_users": "Preview the OIDC data that would be sent for different users",
"id_token": "ID Token",
"access_token": "Access Token",
"userinfo": "Userinfo",
"id_token_payload": "ID Token Payload",
"access_token_payload": "Access Token Payload",
"userinfo_endpoint_response": "Userinfo Endpoint Response",
"copy": "Copy",
"no_preview_data_available": "No preview data available",
"copy_all": "Copy All",
"preview": "Preview",
"preview_for_user": "Preview for {name} ({email})",
"preview_the_oidc_data_that_would_be_sent_for_this_user": "Preview the OIDC data that would be sent for this user",
"show": "Show",
"select_an_option": "Select an option",
"select_user": "Select User",
"error": "Error"
}

375
frontend/messages/da.json Normal file
View File

@@ -0,0 +1,375 @@
{
"$schema": "https://inlang.com/schema/inlang-message-format",
"my_account": "Min konto",
"logout": "Log ud",
"confirm": "Bekræft",
"key": "Key",
"value": "Value",
"remove_custom_claim": "Remove custom claim",
"add_custom_claim": "Add custom claim",
"add_another": "Add another",
"select_a_date": "Select a date",
"select_file": "Select File",
"profile_picture": "Profile Picture",
"profile_picture_is_managed_by_ldap_server": "The profile picture is managed by the LDAP server and cannot be changed here.",
"click_profile_picture_to_upload_custom": "Click on the profile picture to upload a custom one from your files.",
"image_should_be_in_format": "The image should be in PNG or JPEG format.",
"items_per_page": "Items per page",
"no_items_found": "No items found",
"search": "Søg...",
"expand_card": "Expand card",
"copied": "Kopieret",
"click_to_copy": "Click to copy",
"something_went_wrong": "Something went wrong",
"go_back_to_home": "Go back to home",
"dont_have_access_to_your_passkey": "Don't have access to your passkey?",
"login_background": "Login background",
"logo": "Logo",
"login_code": "Login Code",
"create_a_login_code_to_sign_in_without_a_passkey_once": "Create a login code that the user can use to sign in without a passkey once.",
"one_hour": "1 time",
"twelve_hours": "12 timer",
"one_day": "1 dag",
"one_week": "1 uge",
"one_month": "1 måned",
"expiration": "Expiration",
"generate_code": "Generer kode",
"name": "Navn",
"browser_unsupported": "Browser unsupported",
"this_browser_does_not_support_passkeys": "This browser doesn't support passkeys. Please use an alternative sign in method.",
"an_unknown_error_occurred": "An unknown error occurred",
"authentication_process_was_aborted": "The authentication process was aborted",
"error_occurred_with_authenticator": "An error occurred with the authenticator",
"authenticator_does_not_support_discoverable_credentials": "The authenticator does not support discoverable credentials",
"authenticator_does_not_support_resident_keys": "The authenticator does not support resident keys",
"passkey_was_previously_registered": "This passkey was previously registered",
"authenticator_does_not_support_any_of_the_requested_algorithms": "The authenticator does not support any of the requested algorithms",
"authenticator_timed_out": "The authenticator timed out",
"critical_error_occurred_contact_administrator": "A critical error occurred. Please contact your administrator.",
"sign_in_to": "Sign in to {name}",
"client_not_found": "Client not found",
"client_wants_to_access_the_following_information": "<b>{client}</b> wants to access the following information:",
"do_you_want_to_sign_in_to_client_with_your_app_name_account": "Do you want to sign in to <b>{client}</b> with your {appName} account?",
"email": "Email",
"view_your_email_address": "View your email address",
"profile": "Profile",
"view_your_profile_information": "View your profile information",
"groups": "Groups",
"view_the_groups_you_are_a_member_of": "View the groups you are a member of",
"cancel": "Cancel",
"sign_in": "Sign in",
"try_again": "Try again",
"client_logo": "Client Logo",
"sign_out": "Sign out",
"do_you_want_to_sign_out_of_pocketid_with_the_account": "Do you want to sign out of {appName} with the account <b>{username}</b>?",
"sign_in_to_appname": "Sign in to {appName}",
"please_try_to_sign_in_again": "Please try to sign in again.",
"authenticate_yourself_with_your_passkey_to_access_the_admin_panel": "Authenticate yourself with your passkey to access the admin panel.",
"authenticate": "Authenticate",
"appname_setup": "{appName} Setup",
"please_try_again": "Please try again.",
"you_are_about_to_sign_in_to_the_initial_admin_account": "You're about to sign in to the initial admin account. Anyone with this link can access the account until a passkey is added. Please set up a passkey as soon as possible to prevent unauthorized access.",
"continue": "Continue",
"alternative_sign_in": "Alternative Sign In",
"if_you_do_not_have_access_to_your_passkey_you_can_sign_in_using_one_of_the_following_methods": "If you don't have access to your passkey, you can sign in using one of the following methods.",
"use_your_passkey_instead": "Use your passkey instead?",
"email_login": "E-mail Login",
"enter_a_login_code_to_sign_in": "Enter a login code to sign in.",
"request_a_login_code_via_email": "Request a login code via email.",
"go_back": "Gå tilbage",
"an_email_has_been_sent_to_the_provided_email_if_it_exists_in_the_system": "An email has been sent to the provided email, if it exists in the system.",
"enter_code": "Indtast kode",
"enter_your_email_address_to_receive_an_email_with_a_login_code": "Enter your email address to receive an email with a login code.",
"your_email": "Din e-mail",
"submit": "Submit",
"enter_the_code_you_received_to_sign_in": "Enter the code you received to sign in.",
"code": "Code",
"invalid_redirect_url": "Invalid redirect URL",
"audit_log": "Audit Log",
"users": "Brugere",
"user_groups": "Brugergrupper",
"oidc_clients": "OIDC Clients",
"api_keys": "API Keys",
"application_configuration": "Application Configuration",
"settings": "Indstillinger",
"update_pocket_id": "Opdater Pocket ID",
"powered_by": "Powered by",
"see_your_account_activities_from_the_last_3_months": "See your account activities from the last 3 months.",
"time": "Tid",
"event": "Event",
"approximate_location": "Approximate Location",
"ip_address": "IP-adresse",
"device": "Enhed",
"client": "Klient",
"unknown": "Ukendt",
"account_details_updated_successfully": "Account details updated successfully",
"profile_picture_updated_successfully": "Profile picture updated successfully. It may take a few minutes to update.",
"account_settings": "Account Settings",
"passkey_missing": "Passkey missing",
"please_provide_a_passkey_to_prevent_losing_access_to_your_account": "Please add a passkey to prevent losing access to your account.",
"single_passkey_configured": "Single Passkey Configured",
"it_is_recommended_to_add_more_than_one_passkey": "It is recommended to add more than one passkey to avoid losing access to your account.",
"account_details": "Account Details",
"passkeys": "Passkeys",
"manage_your_passkeys_that_you_can_use_to_authenticate_yourself": "Manage your passkeys that you can use to authenticate yourself.",
"add_passkey": "Add Passkey",
"create_a_one_time_login_code_to_sign_in_from_a_different_device_without_a_passkey": "Create a one-time login code to sign in from a different device without a passkey.",
"create": "Create",
"first_name": "First name",
"last_name": "Last name",
"username": "Username",
"save": "Save",
"username_can_only_contain": "Username can only contain lowercase letters, numbers, underscores, dots, hyphens, and '@' symbols",
"sign_in_using_the_following_code_the_code_will_expire_in_minutes": "Sign in using the following code. The code will expire in 15 minutes.",
"or_visit": "or visit",
"added_on": "Added on",
"rename": "Rename",
"delete": "Delete",
"are_you_sure_you_want_to_delete_this_passkey": "Are you sure you want to delete this passkey?",
"passkey_deleted_successfully": "Passkey deleted successfully",
"delete_passkey_name": "Delete {passkeyName}",
"passkey_name_updated_successfully": "Passkey name updated successfully",
"name_passkey": "Name Passkey",
"name_your_passkey_to_easily_identify_it_later": "Name your passkey to easily identify it later.",
"create_api_key": "Create API Key",
"add_a_new_api_key_for_programmatic_access": "Add a new API key for programmatic access.",
"add_api_key": "Add API Key",
"manage_api_keys": "Manage API Keys",
"api_key_created": "API Key Created",
"for_security_reasons_this_key_will_only_be_shown_once": "For security reasons, this key will only be shown once. Please store it securely.",
"description": "Description",
"api_key": "API Key",
"close": "Close",
"name_to_identify_this_api_key": "Name to identify this API key.",
"expires_at": "Expires At",
"when_this_api_key_will_expire": "When this API key will expire.",
"optional_description_to_help_identify_this_keys_purpose": "Optional description to help identify this key's purpose.",
"expiration_date_must_be_in_the_future": "Expiration date must be in the future",
"revoke_api_key": "Revoke API Key",
"never": "Never",
"revoke": "Revoke",
"api_key_revoked_successfully": "API key revoked successfully",
"are_you_sure_you_want_to_revoke_the_api_key_apikeyname": "Are you sure you want to revoke the API key \"{apiKeyName}\"? This will break any integrations using this key.",
"last_used": "Last Used",
"actions": "Actions",
"images_updated_successfully": "Images updated successfully",
"general": "General",
"configure_smtp_to_send_emails": "Enable email notifications to alert users when a login is detected from a new device or location.",
"ldap": "LDAP",
"configure_ldap_settings_to_sync_users_and_groups_from_an_ldap_server": "Configure LDAP settings to sync users and groups from an LDAP server.",
"images": "Images",
"update": "Update",
"email_configuration_updated_successfully": "Email configuration updated successfully",
"save_changes_question": "Save changes?",
"you_have_to_save_the_changes_before_sending_a_test_email_do_you_want_to_save_now": "You have to save the changes before sending a test email. Do you want to save now?",
"save_and_send": "Save and send",
"test_email_sent_successfully": "Test email sent successfully to your email address.",
"failed_to_send_test_email": "Failed to send test email. Check the server logs for more information.",
"smtp_configuration": "SMTP Configuration",
"smtp_host": "SMTP Host",
"smtp_port": "SMTP Port",
"smtp_user": "SMTP User",
"smtp_password": "SMTP Password",
"smtp_from": "SMTP From",
"smtp_tls_option": "SMTP TLS Option",
"email_tls_option": "Email TLS Option",
"skip_certificate_verification": "Skip Certificate Verification",
"this_can_be_useful_for_selfsigned_certificates": "This can be useful for self-signed certificates.",
"enabled_emails": "Enabled Emails",
"email_login_notification": "Email Login Notification",
"send_an_email_to_the_user_when_they_log_in_from_a_new_device": "Send an email to the user when they log in from a new device.",
"emai_login_code_requested_by_user": "Email Login Code Requested by User",
"allow_users_to_sign_in_with_a_login_code_sent_to_their_email": "Allows users to bypass passkeys by requesting a login code sent to their email. This reduces the security significantly as anyone with access to the user's email can gain entry.",
"email_login_code_from_admin": "Email Login Code from Admin",
"allows_an_admin_to_send_a_login_code_to_the_user": "Allows an admin to send a login code to the user via email.",
"send_test_email": "Send test email",
"application_configuration_updated_successfully": "Application configuration updated successfully",
"application_name": "Application Name",
"session_duration": "Session Duration",
"the_duration_of_a_session_in_minutes_before_the_user_has_to_sign_in_again": "The duration of a session in minutes before the user has to sign in again.",
"enable_self_account_editing": "Enable Self-Account Editing",
"whether_the_users_should_be_able_to_edit_their_own_account_details": "Whether the users should be able to edit their own account details.",
"emails_verified": "Emails Verified",
"whether_the_users_email_should_be_marked_as_verified_for_the_oidc_clients": "Whether the user's email should be marked as verified for the OIDC clients.",
"ldap_configuration_updated_successfully": "LDAP configuration updated successfully",
"ldap_disabled_successfully": "LDAP disabled successfully",
"ldap_sync_finished": "LDAP sync finished",
"client_configuration": "Client Configuration",
"ldap_url": "LDAP URL",
"ldap_bind_dn": "LDAP Bind DN",
"ldap_bind_password": "LDAP Bind Password",
"ldap_base_dn": "LDAP Base DN",
"user_search_filter": "User Search Filter",
"the_search_filter_to_use_to_search_or_sync_users": "The Search filter to use to search/sync users.",
"groups_search_filter": "Groups Search Filter",
"the_search_filter_to_use_to_search_or_sync_groups": "The Search filter to use to search/sync groups.",
"attribute_mapping": "Attribute Mapping",
"user_unique_identifier_attribute": "User Unique Identifier Attribute",
"the_value_of_this_attribute_should_never_change": "The value of this attribute should never change.",
"username_attribute": "Username Attribute",
"user_mail_attribute": "User Mail Attribute",
"user_first_name_attribute": "User First Name Attribute",
"user_last_name_attribute": "User Last Name Attribute",
"user_profile_picture_attribute": "User Profile Picture Attribute",
"the_value_of_this_attribute_can_either_be_a_url_binary_or_base64_encoded_image": "The value of this attribute can either be a URL, a binary or a base64 encoded image.",
"group_members_attribute": "Group Members Attribute",
"the_attribute_to_use_for_querying_members_of_a_group": "The attribute to use for querying members of a group.",
"group_unique_identifier_attribute": "Group Unique Identifier Attribute",
"group_name_attribute": "Group Name Attribute",
"admin_group_name": "Admin Group Name",
"members_of_this_group_will_have_admin_privileges_in_pocketid": "Members of this group will have Admin Privileges in Pocket ID.",
"disable": "Disable",
"sync_now": "Sync now",
"enable": "Enable",
"user_created_successfully": "User created successfully",
"create_user": "Create User",
"add_a_new_user_to_appname": "Add a new user to {appName}",
"add_user": "Add User",
"manage_users": "Manage Users",
"admin_privileges": "Admin Privileges",
"admins_have_full_access_to_the_admin_panel": "Admins have full access to the admin panel.",
"delete_firstname_lastname": "Delete {firstName} {lastName}",
"are_you_sure_you_want_to_delete_this_user": "Are you sure you want to delete this user?",
"user_deleted_successfully": "User deleted successfully",
"role": "Role",
"source": "Source",
"admin": "Admin",
"user": "User",
"local": "Local",
"toggle_menu": "Toggle menu",
"edit": "Edit",
"user_groups_updated_successfully": "User groups updated successfully",
"user_updated_successfully": "User updated successfully",
"custom_claims_updated_successfully": "Custom claims updated successfully",
"back": "Back",
"user_details_firstname_lastname": "User Details {firstName} {lastName}",
"manage_which_groups_this_user_belongs_to": "Manage which groups this user belongs to.",
"custom_claims": "Custom Claims",
"custom_claims_are_key_value_pairs_that_can_be_used_to_store_additional_information_about_a_user": "Custom claims are key-value pairs that can be used to store additional information about a user. These claims will be included in the ID token if the scope 'profile' is requested.",
"user_group_created_successfully": "User group created successfully",
"create_user_group": "Create User Group",
"create_a_new_group_that_can_be_assigned_to_users": "Create a new group that can be assigned to users.",
"add_group": "Add Group",
"manage_user_groups": "Manage User Groups",
"friendly_name": "Friendly Name",
"name_that_will_be_displayed_in_the_ui": "Name that will be displayed in the UI",
"name_that_will_be_in_the_groups_claim": "Name that will be in the \"groups\" claim",
"delete_name": "Delete {name}",
"are_you_sure_you_want_to_delete_this_user_group": "Are you sure you want to delete this user group?",
"user_group_deleted_successfully": "User group deleted successfully",
"user_count": "User Count",
"user_group_updated_successfully": "User group updated successfully",
"users_updated_successfully": "Users updated successfully",
"user_group_details_name": "User Group Details {name}",
"assign_users_to_this_group": "Assign users to this group.",
"custom_claims_are_key_value_pairs_that_can_be_used_to_store_additional_information_about_a_user_prioritized": "Custom claims are key-value pairs that can be used to store additional information about a user. These claims will be included in the ID token if the scope 'profile' is requested. Custom claims defined on the user will be prioritized if there are conflicts.",
"oidc_client_created_successfully": "OIDC client created successfully",
"create_oidc_client": "Create OIDC Client",
"add_a_new_oidc_client_to_appname": "Add a new OIDC client to {appName}.",
"add_oidc_client": "Add OIDC Client",
"manage_oidc_clients": "Manage OIDC Clients",
"one_time_link": "One Time Link",
"use_this_link_to_sign_in_once": "Use this link to sign in once. This is needed for users who haven't added a passkey yet or have lost it.",
"add": "Add",
"callback_urls": "Callback URLs",
"logout_callback_urls": "Logout Callback URLs",
"public_client": "Public Client",
"public_clients_description": "Public clients do not have a client secret. They are designed for mobile, web, and native applications where secrets cannot be securely stored.",
"pkce": "PKCE",
"public_key_code_exchange_is_a_security_feature_to_prevent_csrf_and_authorization_code_interception_attacks": "Public Key Code Exchange is a security feature to prevent CSRF and authorization code interception attacks.",
"name_logo": "{name} logo",
"change_logo": "Change Logo",
"upload_logo": "Upload Logo",
"remove_logo": "Remove Logo",
"are_you_sure_you_want_to_delete_this_oidc_client": "Are you sure you want to delete this OIDC client?",
"oidc_client_deleted_successfully": "OIDC client deleted successfully",
"authorization_url": "Authorization URL",
"oidc_discovery_url": "OIDC Discovery URL",
"token_url": "Token URL",
"userinfo_url": "Userinfo URL",
"logout_url": "Logout URL",
"certificate_url": "Certificate URL",
"enabled": "Enabled",
"disabled": "Disabled",
"oidc_client_updated_successfully": "OIDC client updated successfully",
"create_new_client_secret": "Create new client secret",
"are_you_sure_you_want_to_create_a_new_client_secret": "Are you sure you want to create a new client secret? The old one will be invalidated.",
"generate": "Generate",
"new_client_secret_created_successfully": "New client secret created successfully",
"allowed_user_groups_updated_successfully": "Allowed user groups updated successfully",
"oidc_client_name": "OIDC Client {name}",
"client_id": "Client ID",
"client_secret": "Client secret",
"show_more_details": "Show more details",
"allowed_user_groups": "Allowed User Groups",
"add_user_groups_to_this_client_to_restrict_access_to_users_in_these_groups": "Add user groups to this client to restrict access to users in these groups. If no user groups are selected, all users will have access to this client.",
"favicon": "Favicon",
"light_mode_logo": "Light Mode Logo",
"dark_mode_logo": "Dark Mode Logo",
"background_image": "Background Image",
"language": "Language",
"reset_profile_picture_question": "Reset profile picture?",
"this_will_remove_the_uploaded_image_and_reset_the_profile_picture_to_default": "This will remove the uploaded image, and reset the profile picture to default. Do you want to continue?",
"reset": "Reset",
"reset_to_default": "Reset to default",
"profile_picture_has_been_reset": "Profile picture has been reset. It may take a few minutes to update.",
"select_the_language_you_want_to_use": "Select the language you want to use. Some languages may not be fully translated.",
"personal": "Personal",
"global": "Global",
"all_users": "All Users",
"all_events": "All Events",
"all_clients": "All Clients",
"global_audit_log": "Global Audit Log",
"see_all_account_activities_from_the_last_3_months": "See all user activity for the last 3 months.",
"token_sign_in": "Token Sign In",
"client_authorization": "Client Authorization",
"new_client_authorization": "New Client Authorization",
"disable_animations": "Disable Animations",
"turn_off_ui_animations": "Turn off all animations throughout the Admin UI.",
"user_disabled": "Account Disabled",
"disabled_users_cannot_log_in_or_use_services": "Disabled users cannot log in or use services.",
"user_disabled_successfully": "User has been disabled successfully.",
"user_enabled_successfully": "User has been enabled successfully.",
"status": "Status",
"disable_firstname_lastname": "Disable {firstName} {lastName}",
"are_you_sure_you_want_to_disable_this_user": "Are you sure you want to disable this user? They will not be able to log in or access any services.",
"ldap_soft_delete_users": "Keep disabled users from LDAP.",
"ldap_soft_delete_users_description": "When enabled, users removed from LDAP will be disabled rather than deleted from the system.",
"login_code_email_success": "The login code has been sent to the user.",
"send_email": "Send Email",
"show_code": "Show Code",
"callback_url_description": "URL(s) provided by your client. Will be automatically added if left blank. Wildcards (*) are supported, but best avoided for better security.",
"logout_callback_url_description": "URL(s) provided by your client for logout. Wildcards (*) are supported, but best avoided for better security.",
"api_key_expiration": "API Key Expiration",
"send_an_email_to_the_user_when_their_api_key_is_about_to_expire": "Send an email to the user when their API key is about to expire.",
"authorize_device": "Authorize Device",
"the_device_has_been_authorized": "The device has been authorized.",
"enter_code_displayed_in_previous_step": "Enter the code that was displayed in the previous step.",
"authorize": "Authorize",
"federated_client_credentials": "Federated Client Credentials",
"federated_client_credentials_description": "Using federated client credentials, you can authenticate OIDC clients using JWT tokens issued by third-party authorities.",
"add_federated_client_credential": "Add Federated Client Credential",
"add_another_federated_client_credential": "Add another federated client credential",
"oidc_allowed_group_count": "Allowed Group Count",
"unrestricted": "Unrestricted",
"show_advanced_options": "Show Advanced Options",
"hide_advanced_options": "Hide Advanced Options",
"oidc_data_preview": "OIDC Data Preview",
"preview_the_oidc_data_that_would_be_sent_for_different_users": "Preview the OIDC data that would be sent for different users",
"id_token": "ID Token",
"access_token": "Access Token",
"userinfo": "Userinfo",
"id_token_payload": "ID Token Payload",
"access_token_payload": "Access Token Payload",
"userinfo_endpoint_response": "Userinfo Endpoint Response",
"copy": "Copy",
"no_preview_data_available": "No preview data available",
"copy_all": "Copy All",
"preview": "Preview",
"preview_for_user": "Preview for {name} ({email})",
"preview_the_oidc_data_that_would_be_sent_for_this_user": "Preview the OIDC data that would be sent for this user",
"show": "Show",
"select_an_option": "Select an option",
"select_user": "Select User",
"error": "Error"
}

View File

@@ -144,8 +144,6 @@
"expires_at": "Ablaufdatum",
"when_this_api_key_will_expire": "Wann der API Key ablaufen wird.",
"optional_description_to_help_identify_this_keys_purpose": "Optionale Beschreibung, um den Zweck dieses Schlüssels zu identifizieren.",
"name_must_be_at_least_3_characters": "Der Name muss mindestens 3 Zeichen lang sein",
"name_cannot_exceed_50_characters": "Der Name darf nicht länger als 50 Zeichen sein",
"expiration_date_must_be_in_the_future": "Ablaufdatum muss in der Zukunft liegen",
"revoke_api_key": "API Key widerrufen",
"never": "Nie",
@@ -327,7 +325,7 @@
"client_authorization": "Client-Autorisierung",
"new_client_authorization": "Neue Client-Autorisierung",
"disable_animations": "Animationen deaktivieren",
"turn_off_all_animations_throughout_the_admin_ui": "Deaktiviert alle Animationen in der Benutzeroberfläche.",
"turn_off_ui_animations": "Deaktiviert alle Animationen in der Benutzeroberfläche.",
"user_disabled": "Account deaktiviert",
"disabled_users_cannot_log_in_or_use_services": "Deaktivierte Benutzer können sich nicht anmelden oder Dienste nutzen.",
"user_disabled_successfully": "Der Benutzer wurde erfolgreich deaktiviert.",
@@ -348,6 +346,30 @@
"the_device_has_been_authorized": "Das Gerät wurde autorisiert.",
"enter_code_displayed_in_previous_step": "Gib den Code ein, der im vorherigen Schritt angezeigt wurde.",
"authorize": "Autorisieren",
"federated_client_credentials": "Federated Client Credentials",
"federated_client_credentials_description": "Using federated client credentials, you can authenticate OIDC clients using JWT tokens issued by third-party authorities.",
"add_federated_client_credential": "Add Federated Client Credential",
"add_another_federated_client_credential": "Add another federated client credential",
"oidc_allowed_group_count": "Erlaubte Gruppenanzahl",
"unrestricted": "Uneingeschränkt"
"unrestricted": "Uneingeschränkt",
"show_advanced_options": "Show Advanced Options",
"hide_advanced_options": "Hide Advanced Options",
"oidc_data_preview": "OIDC Data Preview",
"preview_the_oidc_data_that_would_be_sent_for_different_users": "Preview the OIDC data that would be sent for different users",
"id_token": "ID Token",
"access_token": "Access Token",
"userinfo": "Userinfo",
"id_token_payload": "ID Token Payload",
"access_token_payload": "Access Token Payload",
"userinfo_endpoint_response": "Userinfo Endpoint Response",
"copy": "Copy",
"no_preview_data_available": "No preview data available",
"copy_all": "Copy All",
"preview": "Preview",
"preview_for_user": "Preview for {name} ({email})",
"preview_the_oidc_data_that_would_be_sent_for_this_user": "Preview the OIDC data that would be sent for this user",
"show": "Show",
"select_an_option": "Select an option",
"select_user": "Select User",
"error": "Error"
}

View File

@@ -3,6 +3,7 @@
"my_account": "My Account",
"logout": "Logout",
"confirm": "Confirm",
"docs": "Docs",
"key": "Key",
"value": "Value",
"remove_custom_claim": "Remove custom claim",
@@ -144,8 +145,6 @@
"expires_at": "Expires At",
"when_this_api_key_will_expire": "When this API key will expire.",
"optional_description_to_help_identify_this_keys_purpose": "Optional description to help identify this key's purpose.",
"name_must_be_at_least_3_characters": "Name must be at least 3 characters",
"name_cannot_exceed_50_characters": "Name cannot exceed 50 characters",
"expiration_date_must_be_in_the_future": "Expiration date must be in the future",
"revoke_api_key": "Revoke API Key",
"never": "Never",
@@ -327,7 +326,7 @@
"client_authorization": "Client Authorization",
"new_client_authorization": "New Client Authorization",
"disable_animations": "Disable Animations",
"turn_off_all_animations_throughout_the_admin_ui": "Turn off all animations throughout the Admin UI.",
"turn_off_ui_animations": "Turn off animations troughout the UI.",
"user_disabled": "Account Disabled",
"disabled_users_cannot_log_in_or_use_services": "Disabled users cannot log in or use services.",
"user_disabled_successfully": "User has been disabled successfully.",
@@ -348,6 +347,30 @@
"the_device_has_been_authorized": "The device has been authorized.",
"enter_code_displayed_in_previous_step": "Enter the code that was displayed in the previous step.",
"authorize": "Authorize",
"federated_client_credentials": "Federated Client Credentials",
"federated_client_credentials_description": "Using federated client credentials, you can authenticate OIDC clients using JWT tokens issued by third-party authorities.",
"add_federated_client_credential": "Add Federated Client Credential",
"add_another_federated_client_credential": "Add another federated client credential",
"oidc_allowed_group_count": "Allowed Group Count",
"unrestricted": "Unrestricted"
"unrestricted": "Unrestricted",
"show_advanced_options": "Show Advanced Options",
"hide_advanced_options": "Hide Advanced Options",
"oidc_data_preview": "OIDC Data Preview",
"preview_the_oidc_data_that_would_be_sent_for_different_users": "Preview the OIDC data that would be sent for different users",
"id_token": "ID Token",
"access_token": "Access Token",
"userinfo": "Userinfo",
"id_token_payload": "ID Token Payload",
"access_token_payload": "Access Token Payload",
"userinfo_endpoint_response": "Userinfo Endpoint Response",
"copy": "Copy",
"no_preview_data_available": "No preview data available",
"copy_all": "Copy All",
"preview": "Preview",
"preview_for_user": "Preview for {name} ({email})",
"preview_the_oidc_data_that_would_be_sent_for_this_user": "Preview the OIDC data that would be sent for this user",
"show": "Show",
"select_an_option": "Select an option",
"select_user": "Select User",
"error": "Error"
}

View File

@@ -144,8 +144,6 @@
"expires_at": "Expira el",
"when_this_api_key_will_expire": "Cuando esta clave de API caducará.",
"optional_description_to_help_identify_this_keys_purpose": "Descripción opcional para ayudar a identificar el propósito de esta clave.",
"name_must_be_at_least_3_characters": "El nombre debe tener al menos 3 caracteres",
"name_cannot_exceed_50_characters": "El nombre no puede exceder los 50 caracteres",
"expiration_date_must_be_in_the_future": "La fecha de caducidad debe ser en el futuro",
"revoke_api_key": "Invalidar API Key",
"never": "Nunca",
@@ -281,7 +279,7 @@
"public_key_code_exchange_is_a_security_feature_to_prevent_csrf_and_authorization_code_interception_attacks": "Public Key Code Exchange is a security feature to prevent CSRF and authorization code interception attacks.",
"name_logo": "{name} logo",
"change_logo": "Change Logo",
"upload_logo": "Upload Logo",
"upload_logo": "Subir Logo",
"remove_logo": "Remove Logo",
"are_you_sure_you_want_to_delete_this_oidc_client": "Are you sure you want to delete this OIDC client?",
"oidc_client_deleted_successfully": "OIDC client deleted successfully",
@@ -306,7 +304,7 @@
"allowed_user_groups": "Allowed User Groups",
"add_user_groups_to_this_client_to_restrict_access_to_users_in_these_groups": "Add user groups to this client to restrict access to users in these groups. If no user groups are selected, all users will have access to this client.",
"favicon": "Favicon",
"light_mode_logo": "Light Mode Logo",
"light_mode_logo": "Logo del modo Claro",
"dark_mode_logo": "Dark Mode Logo",
"background_image": "Background Image",
"language": "Language",
@@ -327,7 +325,7 @@
"client_authorization": "Client Authorization",
"new_client_authorization": "New Client Authorization",
"disable_animations": "Disable Animations",
"turn_off_all_animations_throughout_the_admin_ui": "Turn off all animations throughout the Admin UI.",
"turn_off_ui_animations": "Turn off all animations throughout the Admin UI.",
"user_disabled": "Account Disabled",
"disabled_users_cannot_log_in_or_use_services": "Disabled users cannot log in or use services.",
"user_disabled_successfully": "User has been disabled successfully.",
@@ -348,6 +346,30 @@
"the_device_has_been_authorized": "The device has been authorized.",
"enter_code_displayed_in_previous_step": "Enter the code that was displayed in the previous step.",
"authorize": "Authorize",
"federated_client_credentials": "Federated Client Credentials",
"federated_client_credentials_description": "Using federated client credentials, you can authenticate OIDC clients using JWT tokens issued by third-party authorities.",
"add_federated_client_credential": "Add Federated Client Credential",
"add_another_federated_client_credential": "Add another federated client credential",
"oidc_allowed_group_count": "Allowed Group Count",
"unrestricted": "Unrestricted"
"unrestricted": "Unrestricted",
"show_advanced_options": "Show Advanced Options",
"hide_advanced_options": "Hide Advanced Options",
"oidc_data_preview": "OIDC Data Preview",
"preview_the_oidc_data_that_would_be_sent_for_different_users": "Preview the OIDC data that would be sent for different users",
"id_token": "ID Token",
"access_token": "Access Token",
"userinfo": "Userinfo",
"id_token_payload": "ID Token Payload",
"access_token_payload": "Access Token Payload",
"userinfo_endpoint_response": "Userinfo Endpoint Response",
"copy": "Copy",
"no_preview_data_available": "No preview data available",
"copy_all": "Copy All",
"preview": "Preview",
"preview_for_user": "Preview for {name} ({email})",
"preview_the_oidc_data_that_would_be_sent_for_this_user": "Preview the OIDC data that would be sent for this user",
"show": "Show",
"select_an_option": "Select an option",
"select_user": "Select User",
"error": "Error"
}

View File

@@ -144,8 +144,6 @@
"expires_at": "Date d'expiration",
"when_this_api_key_will_expire": "Date d'expiration de la clé API.",
"optional_description_to_help_identify_this_keys_purpose": "Description facultative pour aider à identifier le but de cette clé.",
"name_must_be_at_least_3_characters": "Le nom doit contenir au moins 3 caractères",
"name_cannot_exceed_50_characters": "Le nom ne doit pas dépasser un maximum de 50 caractères",
"expiration_date_must_be_in_the_future": "La date d'expiration doit être dans le futur",
"revoke_api_key": "Révoquer la clé API",
"never": "Jamais",
@@ -327,7 +325,7 @@
"client_authorization": "Client Authorization",
"new_client_authorization": "New Client Authorization",
"disable_animations": "Disable Animations",
"turn_off_all_animations_throughout_the_admin_ui": "Turn off all animations throughout the Admin UI.",
"turn_off_ui_animations": "Turn off all animations throughout the Admin UI.",
"user_disabled": "Account Disabled",
"disabled_users_cannot_log_in_or_use_services": "Disabled users cannot log in or use services.",
"user_disabled_successfully": "User has been disabled successfully.",
@@ -348,6 +346,30 @@
"the_device_has_been_authorized": "The device has been authorized.",
"enter_code_displayed_in_previous_step": "Enter the code that was displayed in the previous step.",
"authorize": "Authorize",
"federated_client_credentials": "Federated Client Credentials",
"federated_client_credentials_description": "Using federated client credentials, you can authenticate OIDC clients using JWT tokens issued by third-party authorities.",
"add_federated_client_credential": "Add Federated Client Credential",
"add_another_federated_client_credential": "Add another federated client credential",
"oidc_allowed_group_count": "Allowed Group Count",
"unrestricted": "Unrestricted"
"unrestricted": "Unrestricted",
"show_advanced_options": "Show Advanced Options",
"hide_advanced_options": "Hide Advanced Options",
"oidc_data_preview": "OIDC Data Preview",
"preview_the_oidc_data_that_would_be_sent_for_different_users": "Preview the OIDC data that would be sent for different users",
"id_token": "ID Token",
"access_token": "Access Token",
"userinfo": "Userinfo",
"id_token_payload": "ID Token Payload",
"access_token_payload": "Access Token Payload",
"userinfo_endpoint_response": "Userinfo Endpoint Response",
"copy": "Copy",
"no_preview_data_available": "No preview data available",
"copy_all": "Copy All",
"preview": "Preview",
"preview_for_user": "Preview for {name} ({email})",
"preview_the_oidc_data_that_would_be_sent_for_this_user": "Preview the OIDC data that would be sent for this user",
"show": "Show",
"select_an_option": "Select an option",
"select_user": "Select User",
"error": "Error"
}

View File

@@ -144,8 +144,6 @@
"expires_at": "Scade il",
"when_this_api_key_will_expire": "Quando scadrà questa chiave API.",
"optional_description_to_help_identify_this_keys_purpose": "Descrizione opzionale per aiutare a identificare lo scopo di questa chiave.",
"name_must_be_at_least_3_characters": "Il nome deve essere di almeno 3 caratteri",
"name_cannot_exceed_50_characters": "Il nome non può superare i 50 caratteri",
"expiration_date_must_be_in_the_future": "La data di scadenza deve essere nel futuro",
"revoke_api_key": "Revoca Chiave API",
"never": "Mai",
@@ -327,7 +325,7 @@
"client_authorization": "Autorizzazione client",
"new_client_authorization": "Nuova autorizzazione client",
"disable_animations": "Disabilita animazioni",
"turn_off_all_animations_throughout_the_admin_ui": "Disattiva tutte le animazioni nell'interfaccia di amministrazione.",
"turn_off_ui_animations": "Disattiva tutte le animazioni nell'interfaccia di amministrazione.",
"user_disabled": "Account disabilitato",
"disabled_users_cannot_log_in_or_use_services": "Gli utenti disabilitati non possono accedere o utilizzare i servizi.",
"user_disabled_successfully": "Utente disabilitato con successo.",
@@ -348,6 +346,30 @@
"the_device_has_been_authorized": "Il dispositivo è stato autorizzato.",
"enter_code_displayed_in_previous_step": "Inserisci il codice visualizzato nel passaggio precedente.",
"authorize": "Autorizza",
"federated_client_credentials": "Identità Federate",
"federated_client_credentials_description": "Utilizzando identità federate, è possibile autenticare i client OIDC utilizzando i token JWT emessi da autorità di terze parti.",
"add_federated_client_credential": "Aggiungi Identità Federata",
"add_another_federated_client_credential": "Aggiungi un'altra identità federata",
"oidc_allowed_group_count": "Numero Gruppi Consentiti",
"unrestricted": "Illimitati"
"unrestricted": "Illimitati",
"show_advanced_options": "Mostra Opzioni Avanzate",
"hide_advanced_options": "Nascondi Opzioni Avanzate",
"oidc_data_preview": "OIDC Data Preview",
"preview_the_oidc_data_that_would_be_sent_for_different_users": "Preview the OIDC data that would be sent for different users",
"id_token": "ID Token",
"access_token": "Access Token",
"userinfo": "Userinfo",
"id_token_payload": "ID Token Payload",
"access_token_payload": "Access Token Payload",
"userinfo_endpoint_response": "Userinfo Endpoint Response",
"copy": "Copy",
"no_preview_data_available": "No preview data available",
"copy_all": "Copy All",
"preview": "Preview",
"preview_for_user": "Preview for {name} ({email})",
"preview_the_oidc_data_that_would_be_sent_for_this_user": "Preview the OIDC data that would be sent for this user",
"show": "Show",
"select_an_option": "Select an option",
"select_user": "Select User",
"error": "Error"
}

View File

@@ -144,8 +144,6 @@
"expires_at": "Verloopt op",
"when_this_api_key_will_expire": "Wanneer deze API-sleutel verloopt.",
"optional_description_to_help_identify_this_keys_purpose": "Optionele beschrijving om het doel van deze sleutel te helpen identificeren.",
"name_must_be_at_least_3_characters": "Naam moet minimaal 3 tekens lang zijn",
"name_cannot_exceed_50_characters": "Naam mag niet langer zijn dan 50 tekens",
"expiration_date_must_be_in_the_future": "Vervaldatum moet in de toekomst liggen",
"revoke_api_key": "API-sleutel intrekken",
"never": "Nooit",
@@ -327,7 +325,7 @@
"client_authorization": "Client autorisatie",
"new_client_authorization": "Nieuwe clientautorisatie",
"disable_animations": "Disable Animations",
"turn_off_all_animations_throughout_the_admin_ui": "Turn off all animations throughout the Admin UI.",
"turn_off_ui_animations": "Turn off all animations throughout the Admin UI.",
"user_disabled": "Account Disabled",
"disabled_users_cannot_log_in_or_use_services": "Disabled users cannot log in or use services.",
"user_disabled_successfully": "User has been disabled successfully.",
@@ -348,6 +346,30 @@
"the_device_has_been_authorized": "The device has been authorized.",
"enter_code_displayed_in_previous_step": "Enter the code that was displayed in the previous step.",
"authorize": "Authorize",
"federated_client_credentials": "Federated Client Credentials",
"federated_client_credentials_description": "Using federated client credentials, you can authenticate OIDC clients using JWT tokens issued by third-party authorities.",
"add_federated_client_credential": "Add Federated Client Credential",
"add_another_federated_client_credential": "Add another federated client credential",
"oidc_allowed_group_count": "Allowed Group Count",
"unrestricted": "Unrestricted"
"unrestricted": "Unrestricted",
"show_advanced_options": "Show Advanced Options",
"hide_advanced_options": "Hide Advanced Options",
"oidc_data_preview": "OIDC Data Preview",
"preview_the_oidc_data_that_would_be_sent_for_different_users": "Preview the OIDC data that would be sent for different users",
"id_token": "ID Token",
"access_token": "Access Token",
"userinfo": "Userinfo",
"id_token_payload": "ID Token Payload",
"access_token_payload": "Access Token Payload",
"userinfo_endpoint_response": "Userinfo Endpoint Response",
"copy": "Copy",
"no_preview_data_available": "No preview data available",
"copy_all": "Copy All",
"preview": "Preview",
"preview_for_user": "Preview for {name} ({email})",
"preview_the_oidc_data_that_would_be_sent_for_this_user": "Preview the OIDC data that would be sent for this user",
"show": "Show",
"select_an_option": "Select an option",
"select_user": "Select User",
"error": "Error"
}

View File

@@ -144,8 +144,6 @@
"expires_at": "Wygasa o",
"when_this_api_key_will_expire": "Kiedy ten klucz API wygaśnie.",
"optional_description_to_help_identify_this_keys_purpose": "Opcjonalny opis, aby pomóc zidentyfikować cel tego klucza.",
"name_must_be_at_least_3_characters": "Nazwa musi mieć co najmniej 3 znaki",
"name_cannot_exceed_50_characters": "Nazwa nie może przekraczać 50 znaków",
"expiration_date_must_be_in_the_future": "Data wygaśnięcia musi być w przyszłości",
"revoke_api_key": "Unieważnij klucz API",
"never": "Nigdy",
@@ -327,7 +325,7 @@
"client_authorization": "Autoryzacja klienta",
"new_client_authorization": "Nowa autoryzacja klienta",
"disable_animations": "Wyłącz animacje",
"turn_off_all_animations_throughout_the_admin_ui": "Wyłącz wszystkie animacje w całym interfejsie administracyjnym.",
"turn_off_ui_animations": "Wyłącz wszystkie animacje w całym interfejsie administracyjnym.",
"user_disabled": "Konto wyłączone",
"disabled_users_cannot_log_in_or_use_services": "Wyłączone konta użytkowników nie mogą się logować ani korzystać z usług.",
"user_disabled_successfully": "Sukces! Konto zostało wyłączone.",
@@ -348,6 +346,30 @@
"the_device_has_been_authorized": "Urządzenie zostało autoryzowane.",
"enter_code_displayed_in_previous_step": "Wprowadź kod wyświetlony w poprzednim kroku.",
"authorize": "Autoryzuj",
"federated_client_credentials": "Federated Client Credentials",
"federated_client_credentials_description": "Using federated client credentials, you can authenticate OIDC clients using JWT tokens issued by third-party authorities.",
"add_federated_client_credential": "Add Federated Client Credential",
"add_another_federated_client_credential": "Add another federated client credential",
"oidc_allowed_group_count": "Allowed Group Count",
"unrestricted": "Unrestricted"
"unrestricted": "Unrestricted",
"show_advanced_options": "Show Advanced Options",
"hide_advanced_options": "Hide Advanced Options",
"oidc_data_preview": "OIDC Data Preview",
"preview_the_oidc_data_that_would_be_sent_for_different_users": "Preview the OIDC data that would be sent for different users",
"id_token": "ID Token",
"access_token": "Access Token",
"userinfo": "Userinfo",
"id_token_payload": "ID Token Payload",
"access_token_payload": "Access Token Payload",
"userinfo_endpoint_response": "Userinfo Endpoint Response",
"copy": "Copy",
"no_preview_data_available": "No preview data available",
"copy_all": "Copy All",
"preview": "Preview",
"preview_for_user": "Preview for {name} ({email})",
"preview_the_oidc_data_that_would_be_sent_for_this_user": "Preview the OIDC data that would be sent for this user",
"show": "Show",
"select_an_option": "Select an option",
"select_user": "Select User",
"error": "Error"
}

View File

@@ -144,8 +144,6 @@
"expires_at": "Expires At",
"when_this_api_key_will_expire": "When this API key will expire.",
"optional_description_to_help_identify_this_keys_purpose": "Optional description to help identify this key's purpose.",
"name_must_be_at_least_3_characters": "Name must be at least 3 characters",
"name_cannot_exceed_50_characters": "Name cannot exceed 50 characters",
"expiration_date_must_be_in_the_future": "Expiration date must be in the future",
"revoke_api_key": "Revoke API Key",
"never": "Nunca",
@@ -327,7 +325,7 @@
"client_authorization": "Client Authorization",
"new_client_authorization": "New Client Authorization",
"disable_animations": "Disable Animations",
"turn_off_all_animations_throughout_the_admin_ui": "Turn off all animations throughout the Admin UI.",
"turn_off_ui_animations": "Turn off all animations throughout the Admin UI.",
"user_disabled": "Account Disabled",
"disabled_users_cannot_log_in_or_use_services": "Disabled users cannot log in or use services.",
"user_disabled_successfully": "User has been disabled successfully.",
@@ -348,6 +346,30 @@
"the_device_has_been_authorized": "The device has been authorized.",
"enter_code_displayed_in_previous_step": "Enter the code that was displayed in the previous step.",
"authorize": "Authorize",
"federated_client_credentials": "Federated Client Credentials",
"federated_client_credentials_description": "Using federated client credentials, you can authenticate OIDC clients using JWT tokens issued by third-party authorities.",
"add_federated_client_credential": "Add Federated Client Credential",
"add_another_federated_client_credential": "Add another federated client credential",
"oidc_allowed_group_count": "Allowed Group Count",
"unrestricted": "Unrestricted"
"unrestricted": "Unrestricted",
"show_advanced_options": "Show Advanced Options",
"hide_advanced_options": "Hide Advanced Options",
"oidc_data_preview": "OIDC Data Preview",
"preview_the_oidc_data_that_would_be_sent_for_different_users": "Preview the OIDC data that would be sent for different users",
"id_token": "ID Token",
"access_token": "Access Token",
"userinfo": "Userinfo",
"id_token_payload": "ID Token Payload",
"access_token_payload": "Access Token Payload",
"userinfo_endpoint_response": "Userinfo Endpoint Response",
"copy": "Copy",
"no_preview_data_available": "No preview data available",
"copy_all": "Copy All",
"preview": "Preview",
"preview_for_user": "Preview for {name} ({email})",
"preview_the_oidc_data_that_would_be_sent_for_this_user": "Preview the OIDC data that would be sent for this user",
"show": "Show",
"select_an_option": "Select an option",
"select_user": "Select User",
"error": "Error"
}

View File

@@ -144,8 +144,6 @@
"expires_at": "Действителен до",
"when_this_api_key_will_expire": "Когда срок действия этого API ключа истечет.",
"optional_description_to_help_identify_this_keys_purpose": "Опциональное описание, чтобы помочь определить цель этого ключа.",
"name_must_be_at_least_3_characters": "Имя должно содержать не менее 3 символов",
"name_cannot_exceed_50_characters": "Длина имени не может превышать 50 символов",
"expiration_date_must_be_in_the_future": "Дата истечения должна быть определена в будущем",
"revoke_api_key": "Отозвать API ключ",
"never": "Никогда",
@@ -327,7 +325,7 @@
"client_authorization": "Авторизация в клиенте",
"new_client_authorization": "Новая авторизация в клиенте",
"disable_animations": "Отключить анимации",
"turn_off_all_animations_throughout_the_admin_ui": "Выключить все анимации в интерфейсе администратора.",
"turn_off_ui_animations": "Выключить все анимации в интерфейсе администратора.",
"user_disabled": "Аккаунт отключен",
"disabled_users_cannot_log_in_or_use_services": "Отключенные пользователи не могут войти или использовать сервисы.",
"user_disabled_successfully": "Пользователь успешно отключен.",
@@ -348,6 +346,30 @@
"the_device_has_been_authorized": "Устройство авторизовано.",
"enter_code_displayed_in_previous_step": "Введите код, который был отображен на предыдущем шаге.",
"authorize": "Авторизируйте",
"federated_client_credentials": "Federated Client Credentials",
"federated_client_credentials_description": "Using federated client credentials, you can authenticate OIDC clients using JWT tokens issued by third-party authorities.",
"add_federated_client_credential": "Add Federated Client Credential",
"add_another_federated_client_credential": "Add another federated client credential",
"oidc_allowed_group_count": "Кол-во разрешенных групп",
"unrestricted": "Не ограничено"
"unrestricted": "Не ограничено",
"show_advanced_options": "Show Advanced Options",
"hide_advanced_options": "Hide Advanced Options",
"oidc_data_preview": "OIDC Data Preview",
"preview_the_oidc_data_that_would_be_sent_for_different_users": "Preview the OIDC data that would be sent for different users",
"id_token": "ID Token",
"access_token": "Access Token",
"userinfo": "Userinfo",
"id_token_payload": "ID Token Payload",
"access_token_payload": "Access Token Payload",
"userinfo_endpoint_response": "Userinfo Endpoint Response",
"copy": "Copy",
"no_preview_data_available": "No preview data available",
"copy_all": "Copy All",
"preview": "Preview",
"preview_for_user": "Preview for {name} ({email})",
"preview_the_oidc_data_that_would_be_sent_for_this_user": "Preview the OIDC data that would be sent for this user",
"show": "Show",
"select_an_option": "Select an option",
"select_user": "Select User",
"error": "Error"
}

View File

@@ -5,18 +5,18 @@
"confirm": "确认",
"key": "Key",
"value": "Value",
"remove_custom_claim": "除自定义声明",
"remove_custom_claim": "除自定义声明",
"add_custom_claim": "添加自定义声明",
"add_another": "添加另一个",
"select_a_date": "选择日期",
"select_file": "选择文件",
"select_file": "选择上传文件",
"profile_picture": "头像",
"profile_picture_is_managed_by_ldap_server": "头像由 LDAP 服务器管理,无法在此处更改。",
"click_profile_picture_to_upload_custom": "点击头像从文件中上传您的自定义头像。",
"click_profile_picture_to_upload_custom": "点击头像从文件中上传自定义头像。",
"image_should_be_in_format": "图片应为 PNG 或 JPEG 格式。",
"items_per_page": "每页条数",
"no_items_found": "🌱 这里暂时空空如也",
"search": "搜索...",
"no_items_found": "🌱 这里暂时空空如也",
"search": "搜索",
"expand_card": "展开卡片",
"copied": "已复制",
"click_to_copy": "点击复制",
@@ -33,7 +33,7 @@
"one_week": "1 周",
"one_month": "1 个月",
"expiration": "到期时间",
"generate_code": "生成码",
"generate_code": "生成登录码",
"name": "名称",
"browser_unsupported": "浏览器不支持",
"this_browser_does_not_support_passkeys": "此浏览器不支持通行密钥。请使用其他登录方式。",
@@ -59,19 +59,19 @@
"cancel": "取消",
"sign_in": "登录",
"try_again": "重试",
"client_logo": "客户端标志",
"client_logo": "客户端 Logo",
"sign_out": "登出",
"do_you_want_to_sign_out_of_pocketid_with_the_account": "您是否希望使用账户 <b>{username}</b> 登出 Pocket ID",
"do_you_want_to_sign_out_of_pocketid_with_the_account": "您确定要退出 {appName} 应用中的帐号 <b>{username}</b> ",
"sign_in_to_appname": "登录到 {appName}",
"please_try_to_sign_in_again": "请尝试重新登录。",
"authenticate_yourself_with_your_passkey_to_access_the_admin_panel": "使用通行密钥或通过临时登录码进行登录",
"authenticate_yourself_with_your_passkey_to_access_the_admin_panel": "使用通行密钥或通过临时登录码进行登录",
"authenticate": "登录",
"appname_setup": "{appName} 设置",
"please_try_again": "请重试。",
"please_try_again": "请再试一次。",
"you_are_about_to_sign_in_to_the_initial_admin_account": "您即将登录到初始管理员账户。在此添加通行密钥之前,任何拥有此链接的人都可以访问该账户。请尽快设置通行密钥以防止未经授权的访问。",
"continue": "继续",
"alternative_sign_in": "替代登录方式",
"if_you_do_not_have_access_to_your_passkey_you_can_sign_in_using_one_of_the_following_methods": "如果您无法访问您的通行密钥,可以使用以下方之一登录。",
"if_you_do_not_have_access_to_your_passkey_you_can_sign_in_using_one_of_the_following_methods": "如果您无法使用通行密钥,可以通过以下方之一登录。",
"use_your_passkey_instead": "改用您的通行密钥?",
"email_login": "电子邮件登录",
"enter_a_login_code_to_sign_in": "输入一次性登录码以登录。",
@@ -102,8 +102,8 @@
"device": "设备",
"client": "客户端",
"unknown": "未知",
"account_details_updated_successfully": "账户详细信息更新成功",
"profile_picture_updated_successfully": "头像更新成功。可能需要几分钟才能新。",
"account_details_updated_successfully": "账户信息已成功更新",
"profile_picture_updated_successfully": "头像更新成功。可能需要几分钟才能完成刷新。",
"account_settings": "账户设置",
"passkey_missing": "尚未绑定通行密钥",
"please_provide_a_passkey_to_prevent_losing_access_to_your_account": "请添加通行密钥以防止失去对账户的访问。",
@@ -111,7 +111,7 @@
"it_is_recommended_to_add_more_than_one_passkey": "建议添加多个通行密钥以避免失去对账户的访问。",
"account_details": "账户详情",
"passkeys": "通行密钥",
"manage_your_passkeys_that_you_can_use_to_authenticate_yourself": "管理您可以用来进行身份验证的通行密钥。",
"manage_your_passkeys_that_you_can_use_to_authenticate_yourself": "管理您的通行密钥,以用于身份验证。",
"add_passkey": "添加通行密钥",
"create_a_one_time_login_code_to_sign_in_from_a_different_device_without_a_passkey": "创建一次性登录码,以便从不同设备登录而无需通行密钥。",
"create": "创建",
@@ -120,15 +120,15 @@
"username": "用户名",
"save": "保存",
"username_can_only_contain": "用户名只能包含小写字母、数字、下划线、点、连字符和 '@' 符号",
"sign_in_using_the_following_code_the_code_will_expire_in_minutes": "使用以下代码登录。代码将在 15 分钟后过期。",
"sign_in_using_the_following_code_the_code_will_expire_in_minutes": "使用以下代码登录。代码将在 15 分钟后失效。",
"or_visit": "或访问",
"added_on": "添加于",
"rename": "重命名",
"delete": "删除",
"are_you_sure_you_want_to_delete_this_passkey": "您确定要删除此通行密钥吗?",
"passkey_deleted_successfully": "通行密钥删除成功",
"passkey_deleted_successfully": "已成功删除通行密钥",
"delete_passkey_name": "删除 {passkeyName}",
"passkey_name_updated_successfully": "通行密钥名称更新成功",
"passkey_name_updated_successfully": "已成功更新通行密钥名称",
"name_passkey": "重命名通行密钥",
"name_your_passkey_to_easily_identify_it_later": "为您的通行密钥命名,以便以后轻松识别。",
"create_api_key": "创建 API 密钥",
@@ -136,27 +136,25 @@
"add_api_key": "添加 API 密钥",
"manage_api_keys": "管理 API 密钥",
"api_key_created": "API 密钥已创建",
"for_security_reasons_this_key_will_only_be_shown_once": "出于安全原因,此密钥会显示一次请妥善保存。",
"for_security_reasons_this_key_will_only_be_shown_once": "出于安全原因,此密钥会显示一次请妥善保存。",
"description": "描述",
"api_key": "API 密钥",
"close": "关闭",
"name_to_identify_this_api_key": "用于识别此 API 密钥的名称。",
"expires_at": "期时间",
"when_this_api_key_will_expire": "此 API 密钥的期时间。",
"optional_description_to_help_identify_this_keys_purpose": "可选描述,帮助识别此密钥的用途。",
"name_must_be_at_least_3_characters": "名称必须至少为 3 个字符",
"name_cannot_exceed_50_characters": "名称不能超过 50 个字符",
"expiration_date_must_be_in_the_future": "过期日期必须是未来的日期",
"expires_at": "期时间",
"when_this_api_key_will_expire": "此 API 密钥的期时间。",
"optional_description_to_help_identify_this_keys_purpose": "可选描述,用于帮助识别此密钥的用途。",
"expiration_date_must_be_in_the_future": "到期日期必须设定为未来的日期",
"revoke_api_key": "撤销 API 密钥",
"never": "永不",
"revoke": "撤销",
"api_key_revoked_successfully": "API 密钥撤销成功",
"are_you_sure_you_want_to_revoke_the_api_key_apikeyname": "您确定要撤销 API 密钥 \"{apiKeyName}\" 吗?这将中断使用此密钥的任何集成。",
"last_used": "最后使用",
"last_used": "上次使用时间",
"actions": "操作",
"images_updated_successfully": "图片更新成功",
"images_updated_successfully": "已成功更新图片",
"general": "常规",
"configure_smtp_to_send_emails": "启用电子邮件通知,以便在新设备或位置检测到登录时提醒用户。",
"configure_smtp_to_send_emails": "启用电子邮件通知,当检测到来自新设备或位置登录时提醒用户。",
"ldap": "LDAP",
"configure_ldap_settings_to_sync_users_and_groups_from_an_ldap_server": "配置 LDAP 设置以从 LDAP 服务器同步用户和群组。",
"images": "图片",
@@ -166,7 +164,7 @@
"you_have_to_save_the_changes_before_sending_a_test_email_do_you_want_to_save_now": "在发送测试电子邮件之前,您必须保存更改。是否现在保存?",
"save_and_send": "保存并发送",
"test_email_sent_successfully": "测试电子邮件已成功发送到您的电子邮件地址。",
"failed_to_send_test_email": "发送测试电子邮件失败。请检查服务器日志以获取更多信息。",
"failed_to_send_test_email": "发送测试电子邮件失败。请检查服务器日志以获取详细信息。",
"smtp_configuration": "SMTP 配置",
"smtp_host": "SMTP 主机",
"smtp_port": "SMTP 端口",
@@ -178,23 +176,23 @@
"skip_certificate_verification": "跳过证书验证",
"this_can_be_useful_for_selfsigned_certificates": "这对于自签名证书很有用。",
"enabled_emails": "启用的电子邮件",
"email_login_notification": "电子邮件登录通知",
"send_an_email_to_the_user_when_they_log_in_from_a_new_device": "用户新设备登录时,向其发送电子邮件。",
"email_login_notification": "登录时的电子邮件通知",
"send_an_email_to_the_user_when_they_log_in_from_a_new_device": "用户通过新设备登录时发送一封电子邮件通知。",
"emai_login_code_requested_by_user": "用户请求的电子邮件登录代码",
"allow_users_to_sign_in_with_a_login_code_sent_to_their_email": "允许用户通过发送到其电子邮件的登录代码登录。这会显著降低安全性,因为任何有权访问用户电子邮件的人都可以进入。",
"email_login_code_from_admin": "管理员发送的电子邮件登录代码",
"allows_an_admin_to_send_a_login_code_to_the_user": "允许管理员通过电子邮件向用户发送登录代码。",
"send_test_email": "发送测试电子邮件",
"application_configuration_updated_successfully": "应用配置更新成功",
"application_configuration_updated_successfully": "已成功更新应用配置",
"application_name": "应用名称",
"session_duration": "会话持续时间",
"the_duration_of_a_session_in_minutes_before_the_user_has_to_sign_in_again": "用户需再次登录之前的会话持续时间(分钟)。",
"enable_self_account_editing": "启用自助账户编辑",
"whether_the_users_should_be_able_to_edit_their_own_account_details": "用户是否能够编辑自己的账户详细信息。",
"session_duration": "会话时长",
"the_duration_of_a_session_in_minutes_before_the_user_has_to_sign_in_again": "用户需再次登录之前的会话时长(以分钟为单位)。",
"enable_self_account_editing": "启用用户自行编辑账户功能",
"whether_the_users_should_be_able_to_edit_their_own_account_details": "用户是否能够编辑自己的账户详细信息。",
"emails_verified": "已验证的邮箱地址",
"whether_the_users_email_should_be_marked_as_verified_for_the_oidc_clients": "用户的电子邮件是否应标记为已验证,适用于 OIDC 客户端。",
"ldap_configuration_updated_successfully": "LDAP 配置更新成功",
"ldap_disabled_successfully": "LDAP 禁用成功",
"ldap_disabled_successfully": "LDAP 已成功禁用",
"ldap_sync_finished": "LDAP 同步完成",
"client_configuration": "客户端配置",
"ldap_url": "LDAP URL",
@@ -202,9 +200,9 @@
"ldap_bind_password": "LDAP Bind Password",
"ldap_base_dn": "LDAP Base DN",
"user_search_filter": "User Search Filter",
"the_search_filter_to_use_to_search_or_sync_users": "用于搜索/同步用户的搜索过滤器。",
"the_search_filter_to_use_to_search_or_sync_users": "用于搜索同步用户的筛选器。",
"groups_search_filter": "Groups Search Filter",
"the_search_filter_to_use_to_search_or_sync_groups": "用于搜索/同步群组的搜索过滤器。",
"the_search_filter_to_use_to_search_or_sync_groups": "用于搜索同步群组的筛选器。",
"attribute_mapping": "属性映射",
"user_unique_identifier_attribute": "User Unique Identifier Attribute",
"the_value_of_this_attribute_should_never_change": "此属性的值不应更改。",
@@ -213,26 +211,26 @@
"user_first_name_attribute": "User First Name Attribute",
"user_last_name_attribute": "User Last Name Attribute",
"user_profile_picture_attribute": "User Profile Picture Attribute",
"the_value_of_this_attribute_can_either_be_a_url_binary_or_base64_encoded_image": "此属性的值可以是 URL、二进制或 base64 编码的图像。",
"the_value_of_this_attribute_can_either_be_a_url_binary_or_base64_encoded_image": "此属性的值可以是 URL、二进制数据Base64 编码的图像。",
"group_members_attribute": "Group Members Attribute",
"the_attribute_to_use_for_querying_members_of_a_group": "用于查询群组成员的属性。",
"group_unique_identifier_attribute": "Group Unique Identifier Attribute",
"group_name_attribute": "Group Name Attribute",
"admin_group_name": "Admin Group Name",
"admin_group_name": "管理员组名称",
"members_of_this_group_will_have_admin_privileges_in_pocketid": "此群组的成员将在 Pocket ID 中拥有管理员权限。",
"disable": "禁用",
"sync_now": "立即同步",
"enable": "启用",
"user_created_successfully": "用户创建成功",
"user_created_successfully": "已成功创建用户",
"create_user": "创建用户",
"add_a_new_user_to_appname": " {appName} 添加新用户",
"add_a_new_user_to_appname": "将新用户添加到 {appName} ",
"add_user": "添加用户",
"manage_users": "管理用户",
"admin_privileges": "管理员权限",
"admins_have_full_access_to_the_admin_panel": "管理员拥有管理面板的完全访问权限。",
"delete_firstname_lastname": "删除 {firstName} {lastName}",
"delete_firstname_lastname": "删除 {lastName} {firstName}",
"are_you_sure_you_want_to_delete_this_user": "您确定要删除此用户吗?",
"user_deleted_successfully": "用户删除成功",
"user_deleted_successfully": "已成功删除用户",
"role": "角色",
"source": "来源",
"admin": "管理员",
@@ -244,7 +242,7 @@
"user_updated_successfully": "用户更新成功",
"custom_claims_updated_successfully": "自定义声明更新成功",
"back": "返回",
"user_details_firstname_lastname": "用户详情 {firstName} {lastName}",
"user_details_firstname_lastname": "用户详情 {lastName} {firstName}",
"manage_which_groups_this_user_belongs_to": "管理此用户所属的群组。",
"custom_claims": "自定义声明",
"custom_claims_are_key_value_pairs_that_can_be_used_to_store_additional_information_about_a_user": "自定义声明是键值对,可用于存储有关用户的额外信息。如果请求了 \"profile\" 范围,这些声明将包含在 ID Token 中。",
@@ -258,25 +256,25 @@
"name_that_will_be_in_the_groups_claim": "将在 \"groups\" 声明中显示的名称",
"delete_name": "删除 {name}",
"are_you_sure_you_want_to_delete_this_user_group": "您确定要删除此用户组吗?",
"user_group_deleted_successfully": "用户组删除成功",
"user_group_deleted_successfully": "已成功删除用户组",
"user_count": "用户数",
"user_group_updated_successfully": "用户组更新成功",
"users_updated_successfully": "用户更新成功",
"user_group_updated_successfully": "已成功更新用户组",
"users_updated_successfully": "已成功更新用户信息",
"user_group_details_name": "用户组详情 {name}",
"assign_users_to_this_group": "将用户分配到此群组。",
"custom_claims_are_key_value_pairs_that_can_be_used_to_store_additional_information_about_a_user_prioritized": "自定义声明是键值对,可用于存储有关用户的额外信息。如果请求了 'profile' 范围,这些声明将包含在 ID 令牌中。如果存在冲突,用户上定义的自定义声明将优先。",
"oidc_client_created_successfully": "OIDC 客户端创建成功",
"oidc_client_created_successfully": "已成功创建 OIDC 客户端",
"create_oidc_client": "创建 OIDC 客户端",
"add_a_new_oidc_client_to_appname": "向 {appName} 添加新的 OIDC 客户端。",
"add_a_new_oidc_client_to_appname": "将新的 OIDC 客户端添加到 {appName}。",
"add_oidc_client": "添加 OIDC 客户端",
"manage_oidc_clients": "管理 OIDC 客户端",
"one_time_link": "一次性链接",
"use_this_link_to_sign_in_once": "使用此链接一次性登录。这对尚未添加通行密钥或丢失通行密钥的用户必要。",
"use_this_link_to_sign_in_once": "使用此链接进行一次性登录。这对尚未添加或丢失通行密钥的用户来说非常必要。",
"add": "添加",
"callback_urls": "Callback URL",
"logout_callback_urls": "Logout Callback URL",
"public_client": "公共客户端",
"public_clients_description": "公共客户端没有客户端密钥,而是使用 PKCE。如果您的客户端是 SPA 或移动应用,请启用此选项。",
"public_clients_description": "公共客户端没有客户端密钥。它们用于无法安全存储密钥的移动端、Web端和原生应用程序。",
"pkce": "PKCE",
"public_key_code_exchange_is_a_security_feature_to_prevent_csrf_and_authorization_code_interception_attacks": "公钥代码交换是一种安全功能,可防止 CSRF 和授权代码拦截攻击。",
"name_logo": "{name} Logo",
@@ -298,7 +296,7 @@
"are_you_sure_you_want_to_create_a_new_client_secret": "您确定要创建新的客户端密钥吗?旧的密钥将被失效。",
"generate": "生成",
"new_client_secret_created_successfully": "新客户端密钥创建成功",
"allowed_user_groups_updated_successfully": "允许的用户组更新成功",
"allowed_user_groups_updated_successfully": "已成功更新允许的用户组",
"oidc_client_name": "OIDC 客户端 {name}",
"client_id": "客户端 ID",
"client_secret": "客户端密钥",
@@ -311,9 +309,9 @@
"background_image": "背景图片",
"language": "语言",
"reset_profile_picture_question": "重置头像?",
"this_will_remove_the_uploaded_image_and_reset_the_profile_picture_to_default": "这将除已上传的图片,并将头像重置为默认值。您是否要继续?",
"this_will_remove_the_uploaded_image_and_reset_the_profile_picture_to_default": "这将除已上传的图片,并将头像重置为默认图片。您确定要继续",
"reset": "重置",
"reset_to_default": "重置为默认",
"reset_to_default": "恢复默认设置",
"profile_picture_has_been_reset": "头像已重置。可能需要几分钟才能更新。",
"select_the_language_you_want_to_use": "选择您要使用的语言。某些语言可能未完全翻译。",
"personal": "个人",
@@ -326,28 +324,52 @@
"token_sign_in": "Token 登录",
"client_authorization": "客户端授权",
"new_client_authorization": "首次客户端授权",
"disable_animations": "禁用动画",
"turn_off_all_animations_throughout_the_admin_ui": "关闭管理用户界面中的所有动画。",
"disable_animations": "关闭动画",
"turn_off_ui_animations": "关闭管理界面中的所有动画效果。",
"user_disabled": "账户已禁用",
"disabled_users_cannot_log_in_or_use_services": "禁用的用户无法登录或使用服务。",
"user_disabled_successfully": "用户已成功禁用。",
"user_enabled_successfully": "用户已成功启用。",
"status": "状态",
"disable_firstname_lastname": "禁用 {firstName} {lastName}",
"disable_firstname_lastname": "禁用 {lastName} {firstName}",
"are_you_sure_you_want_to_disable_this_user": "您确定要禁用此用户吗?他们将无法登录或访问任何服务。",
"ldap_soft_delete_users": "保留来自 LDAP 禁用用户。",
"ldap_soft_delete_users_description": "启用后,从 LDAP 中移除的用户将禁用,而不从系统中删除。",
"ldap_soft_delete_users": "保留 LDAP 中被禁用用户。",
"ldap_soft_delete_users_description": "启用后,从 LDAP 中移除的用户将会被标记为禁用,而不从系统中删除。",
"login_code_email_success": "登录代码已发送给用户。",
"send_email": "发送电子邮件",
"show_code": "显示登录码",
"callback_url_description": "由您的客户端提供的 URL。支持通配符 (*),但为了更好的安全性最好避免使用。",
"logout_callback_url_description": "URL(s) provided by your client for logout. Wildcards (*) are supported, but best avoided for better security.",
"logout_callback_url_description": "注销功能中由您客户端配置的URL地址。支持通配符 (*),但出于安全考虑,强烈建议避免使用通配符。",
"api_key_expiration": "API 密钥过期",
"send_an_email_to_the_user_when_their_api_key_is_about_to_expire": "当用户的 API 密钥即将过期时,向其发送电子邮件。",
"authorize_device": "Authorize Device",
"the_device_has_been_authorized": "The device has been authorized.",
"enter_code_displayed_in_previous_step": "Enter the code that was displayed in the previous step.",
"authorize": "Authorize",
"oidc_allowed_group_count": "Allowed Group Count",
"unrestricted": "Unrestricted"
"send_an_email_to_the_user_when_their_api_key_is_about_to_expire": "当用户的 API 密钥即将过期时,发送电子邮件通知用户。",
"authorize_device": "授权设备",
"the_device_has_been_authorized": "设备已授权",
"enter_code_displayed_in_previous_step": "输入在上一步中显示的代码",
"authorize": "授权",
"federated_client_credentials": "联合身份",
"federated_client_credentials_description": "您可以使用联合身份,通过第三方授权机构签发的 JWT 令牌,对 OIDC 客户端进行认证。",
"add_federated_client_credential": "添加联合身份",
"add_another_federated_client_credential": "添加另一个联合身份",
"oidc_allowed_group_count": "允许的群组数量",
"unrestricted": "不受限制",
"show_advanced_options": "显示高级选项",
"hide_advanced_options": "隐藏高级选项",
"oidc_data_preview": "OIDC Data Preview",
"preview_the_oidc_data_that_would_be_sent_for_different_users": "Preview the OIDC data that would be sent for different users",
"id_token": "ID Token",
"access_token": "Access Token",
"userinfo": "Userinfo",
"id_token_payload": "ID Token Payload",
"access_token_payload": "Access Token Payload",
"userinfo_endpoint_response": "Userinfo Endpoint Response",
"copy": "Copy",
"no_preview_data_available": "No preview data available",
"copy_all": "Copy All",
"preview": "Preview",
"preview_for_user": "Preview for {name} ({email})",
"preview_the_oidc_data_that_would_be_sent_for_this_user": "Preview the OIDC data that would be sent for this user",
"show": "Show",
"select_an_option": "Select an option",
"select_user": "Select User",
"error": "Error"
}

View File

@@ -0,0 +1,375 @@
{
"$schema": "https://inlang.com/schema/inlang-message-format",
"my_account": "我的帳號",
"logout": "登出",
"confirm": "確認",
"key": "Key",
"value": "Value",
"remove_custom_claim": "移除自定義 claim",
"add_custom_claim": "添加自定義 claim",
"add_another": "新增另一個",
"select_a_date": "選擇日期",
"select_file": "選擇檔案",
"profile_picture": "個人資料圖片",
"profile_picture_is_managed_by_ldap_server": "這張個人資料圖片是由 LDAP 伺服器管理,無法在此變更。",
"click_profile_picture_to_upload_custom": "點擊個人資料圖片,從您的檔案中上傳自訂圖片。",
"image_should_be_in_format": "圖片應為 PNG 或 JPEG 格式。",
"items_per_page": "每頁項目數",
"no_items_found": "找不到任何項目",
"search": "搜尋...",
"expand_card": "展開卡片",
"copied": "已複製",
"click_to_copy": "點擊以複製",
"something_went_wrong": "出了點問題",
"go_back_to_home": "返回首頁",
"dont_have_access_to_your_passkey": "無法存取您的密碼金鑰嗎?",
"login_background": "登入背景",
"logo": "標誌",
"login_code": "登入代碼",
"create_a_login_code_to_sign_in_without_a_passkey_once": "建立一個登入代碼,讓使用者可以在不使用密碼金鑰的情況下登入一次。",
"one_hour": "1 小時",
"twelve_hours": "12 小時",
"one_day": "1 天",
"one_week": "1 周",
"one_month": "1 個月",
"expiration": "到期時間",
"generate_code": "產生代碼",
"name": "名稱",
"browser_unsupported": "瀏覽器不支援",
"this_browser_does_not_support_passkeys": "此瀏覽器不支援密碼金鑰。請使用其他登入方式。",
"an_unknown_error_occurred": "發生未知的錯誤",
"authentication_process_was_aborted": "驗證程序已中止",
"error_occurred_with_authenticator": "驗證器發生錯誤",
"authenticator_does_not_support_discoverable_credentials": "此驗證器不支援可被發現的憑證",
"authenticator_does_not_support_resident_keys": "此驗證器不支援常駐金鑰",
"passkey_was_previously_registered": "這個密碼金鑰先前已註冊",
"authenticator_does_not_support_any_of_the_requested_algorithms": "驗證器不支援任何一種所要求的演算法",
"authenticator_timed_out": "驗證器逾時",
"critical_error_occurred_contact_administrator": "發生嚴重錯誤,請聯絡您的管理員。",
"sign_in_to": "登入 {name}",
"client_not_found": "找不到客戶端",
"client_wants_to_access_the_following_information": "<b>{client}</b> 想要存取下列資訊:",
"do_you_want_to_sign_in_to_client_with_your_app_name_account": "您想要使用您的 {appName} 帳號登入至 <b>{client}</b> 嗎?",
"email": "電子郵件",
"view_your_email_address": "檢視您的電子郵件地址",
"profile": "個人資料",
"view_your_profile_information": "檢視您的個人資料",
"groups": "群組",
"view_the_groups_you_are_a_member_of": "檢視您所屬的群組",
"cancel": "取消",
"sign_in": "登入",
"try_again": "再試一次",
"client_logo": "客戶端圖標",
"sign_out": "登出",
"do_you_want_to_sign_out_of_pocketid_with_the_account": "您確定要使用帳號 <b>{username}</b> 登出 {appName} 嗎?",
"sign_in_to_appname": "登入 {appName}",
"please_try_to_sign_in_again": "請嘗試重新登入。",
"authenticate_yourself_with_your_passkey_to_access_the_admin_panel": "請使用您的密碼金鑰進行驗證以存取管理面板。",
"authenticate": "驗證",
"appname_setup": "{appName} 設定",
"please_try_again": "請再試一次。",
"you_are_about_to_sign_in_to_the_initial_admin_account": "您即將登入初始管理員帳號。在新增密碼金鑰之前,任何擁有此連結的人都可以存取該帳號。為避免未經授權的存取,請儘快設定密碼金鑰。",
"continue": "繼續",
"alternative_sign_in": "替代登入方式",
"if_you_do_not_have_access_to_your_passkey_you_can_sign_in_using_one_of_the_following_methods": "如果您無法使用您的密碼金鑰,可以改用下列其中一種方式登入。",
"use_your_passkey_instead": "改為使用您的密碼金鑰?",
"email_login": "電子郵件登入",
"enter_a_login_code_to_sign_in": "輸入登入代碼以登入。",
"request_a_login_code_via_email": "透過電子郵件取得登入代碼。",
"go_back": "返回",
"an_email_has_been_sent_to_the_provided_email_if_it_exists_in_the_system": "如果該電子郵件地址存在於系統中,我們會發送信件至您所提供的電子信箱。",
"enter_code": "輸入代碼",
"enter_your_email_address_to_receive_an_email_with_a_login_code": "請輸入您的電子郵件地址以接收登入代碼。",
"your_email": "您的電子信箱",
"submit": "送出",
"enter_the_code_you_received_to_sign_in": "輸入您收到的代碼以登入。",
"code": "代碼",
"invalid_redirect_url": "無效的重新導向 URL",
"audit_log": "稽核日誌",
"users": "使用者",
"user_groups": "使用者群組",
"oidc_clients": "OIDC 客戶端",
"api_keys": "API 金鑰",
"application_configuration": "應用程式設定",
"settings": "設定",
"update_pocket_id": "更新 Pocket ID",
"powered_by": "技術支援",
"see_your_account_activities_from_the_last_3_months": "查看您過去 3 個月的帳戶活動。",
"time": "時間",
"event": "事件",
"approximate_location": "概略位置",
"ip_address": "IP 地址",
"device": "裝置",
"client": "客戶端",
"unknown": "未知",
"account_details_updated_successfully": "帳號資訊更新成功",
"profile_picture_updated_successfully": "個人資料圖片更新成功。 這可能會花幾分鐘更新。",
"account_settings": "帳戶設定",
"passkey_missing": "沒有密碼金鑰",
"please_provide_a_passkey_to_prevent_losing_access_to_your_account": "請新增密碼金鑰以避免日後無法存取您的帳戶。",
"single_passkey_configured": "已設定一組密碼金鑰",
"it_is_recommended_to_add_more_than_one_passkey": "建議您新增多組密碼金鑰,以避免日後無法存取帳戶。",
"account_details": "帳戶詳細資料",
"passkeys": "密碼金鑰",
"manage_your_passkeys_that_you_can_use_to_authenticate_yourself": "管理可用於驗證身分的密碼金鑰。",
"add_passkey": "新增密碼金鑰",
"create_a_one_time_login_code_to_sign_in_from_a_different_device_without_a_passkey": "建立一次性登入代碼,以便從其他裝置登入而不需使用密碼金鑰。",
"create": "建立",
"first_name": "名字",
"last_name": "姓氏",
"username": "使用者名稱",
"save": "儲存",
"username_can_only_contain": "使用者名稱僅能包含小寫英文字母、數字、底線_、句點.)、連字號(-)與 @ 符號",
"sign_in_using_the_following_code_the_code_will_expire_in_minutes": "使用以下代碼登入。 這個代碼將於 15 分鐘後到期。",
"or_visit": "或造訪",
"added_on": "新增於",
"rename": "重新命名",
"delete": "刪除",
"are_you_sure_you_want_to_delete_this_passkey": "您確定您要刪除此密碼金鑰嗎?",
"passkey_deleted_successfully": "密碼金鑰刪除成功",
"delete_passkey_name": "刪除 {passkeyName}",
"passkey_name_updated_successfully": "密碼金鑰名稱更新成功",
"name_passkey": "命名密碼金鑰",
"name_your_passkey_to_easily_identify_it_later": "命名您的密碼金鑰以便日後辨識。",
"create_api_key": "建立 API 金鑰",
"add_a_new_api_key_for_programmatic_access": "新增 API 金鑰以供程式化存取。",
"add_api_key": "新增 API 金鑰",
"manage_api_keys": "管理 API 金鑰",
"api_key_created": "已建立 API 金鑰",
"for_security_reasons_this_key_will_only_be_shown_once": "為了安全起見,此金鑰僅會顯示一次。請妥善保存。",
"description": "描述",
"api_key": "API 金鑰",
"close": "關閉",
"name_to_identify_this_api_key": "辨識此 API 金鑰的名稱。",
"expires_at": "到期於",
"when_this_api_key_will_expire": "此 API 金鑰何時到期。",
"optional_description_to_help_identify_this_keys_purpose": "可選填描述以說明此金鑰用途。",
"expiration_date_must_be_in_the_future": "到期日期必須位於未來",
"revoke_api_key": "撤銷 API 金鑰",
"never": "從未",
"revoke": "撤銷",
"api_key_revoked_successfully": "API 金鑰撤銷成功",
"are_you_sure_you_want_to_revoke_the_api_key_apikeyname": "您確定要撤銷 API 金鑰「{apiKeyName}」嗎?這將會中斷所有使用此金鑰的整合。",
"last_used": "上次使用",
"actions": "操作",
"images_updated_successfully": "圖片更新成功",
"general": "一般",
"configure_smtp_to_send_emails": "啟用電子郵件通知以提醒使用者有來自新裝置或位置的登入。",
"ldap": "LDAP",
"configure_ldap_settings_to_sync_users_and_groups_from_an_ldap_server": "設定 LDAP 以從 LDAP 伺服器同步使用者與群組。",
"images": "圖像",
"update": "更新",
"email_configuration_updated_successfully": "電子郵件設定更新完成",
"save_changes_question": "是否儲存更改?",
"you_have_to_save_the_changes_before_sending_a_test_email_do_you_want_to_save_now": "您必須先儲存變更才能傳送測試郵件,是否立即儲存?",
"save_and_send": "儲存並傳送",
"test_email_sent_successfully": "測試信件已成功發送至您的電子郵件。",
"failed_to_send_test_email": "測試信件傳送失敗,請查看伺服器日誌以了解詳情。",
"smtp_configuration": "SMTP 設定",
"smtp_host": "SMTP 主機",
"smtp_port": "SMTP 連接埠",
"smtp_user": "SMTP 使用者",
"smtp_password": "SMTP 密碼",
"smtp_from": "SMTP 發送者",
"smtp_tls_option": "SMTP TLS 選項",
"email_tls_option": "Email TLS 選項",
"skip_certificate_verification": "略過憑證檢查",
"this_can_be_useful_for_selfsigned_certificates": "這在使用自簽憑證時可能會很有用。",
"enabled_emails": "啟用電子郵件",
"email_login_notification": "電子郵件登入通知",
"send_an_email_to_the_user_when_they_log_in_from_a_new_device": "使用者從新裝置登入時寄送電子郵件通知。",
"emai_login_code_requested_by_user": "使用者請求電子郵件登入代碼",
"allow_users_to_sign_in_with_a_login_code_sent_to_their_email": "允許使用者請求一組登入代碼並透過電子郵件接收,藉此繞過密碼金鑰驗證。 這將大幅降低安全性,因為只要取得使用者的信箱存取權,就可能登入系統。",
"email_login_code_from_admin": "來自管理員的使用者登入代碼",
"allows_an_admin_to_send_a_login_code_to_the_user": "允許管理員透過電子郵件向使用者發送登入代碼。",
"send_test_email": "發送測試郵件",
"application_configuration_updated_successfully": "應用程式設定更新成功",
"application_name": "應用程式名稱",
"session_duration": "登入階段有效時長",
"the_duration_of_a_session_in_minutes_before_the_user_has_to_sign_in_again": "使用者需重新登入前的階段時長(以分鐘為單位)。",
"enable_self_account_editing": "允許使用者自行編輯帳戶資訊",
"whether_the_users_should_be_able_to_edit_their_own_account_details": "是否允許使用者編輯自己的帳號資料。",
"emails_verified": "已驗證的電子郵件",
"whether_the_users_email_should_be_marked_as_verified_for_the_oidc_clients": "是否應將使用者的電子郵件標記為已驗證,以供 OIDC 客戶端使用。",
"ldap_configuration_updated_successfully": "LDAP 設定更新成功",
"ldap_disabled_successfully": "LDAP 已成功停用",
"ldap_sync_finished": "LDAP 同步完成",
"client_configuration": "客戶端設定",
"ldap_url": "LDAP 網址",
"ldap_bind_dn": "LDAP Bind DN",
"ldap_bind_password": "LDAP Bind 密碼",
"ldap_base_dn": "LDAP Base DN",
"user_search_filter": "使用者搜尋篩選器",
"the_search_filter_to_use_to_search_or_sync_users": "使用搜尋篩選器以搜尋/同步使用者。",
"groups_search_filter": "群組搜尋篩選器",
"the_search_filter_to_use_to_search_or_sync_groups": "使用搜尋篩選器以搜尋/同步群組。",
"attribute_mapping": "屬性對應",
"user_unique_identifier_attribute": "使用者唯一識別屬性",
"the_value_of_this_attribute_should_never_change": "此屬性的值不應被變更。",
"username_attribute": "使用者名稱屬性",
"user_mail_attribute": "使用者電子郵件屬性",
"user_first_name_attribute": "使用者名字屬性",
"user_last_name_attribute": "使用者姓氏屬性",
"user_profile_picture_attribute": "使用者個人資料圖片屬性",
"the_value_of_this_attribute_can_either_be_a_url_binary_or_base64_encoded_image": "此屬性的值可以是 URL、二進位資料或 base64 編碼的圖片。",
"group_members_attribute": "群組成員屬性",
"the_attribute_to_use_for_querying_members_of_a_group": "用於查詢群組成員的屬性。",
"group_unique_identifier_attribute": "群組唯一識別屬性",
"group_name_attribute": "群組名稱屬性",
"admin_group_name": "管理員群組名稱",
"members_of_this_group_will_have_admin_privileges_in_pocketid": "此群組的成員將擁有 Pocket ID 的管理權限。",
"disable": "停用",
"sync_now": "立即同步",
"enable": "啟用",
"user_created_successfully": "使用者建立成功",
"create_user": "建立使用者",
"add_a_new_user_to_appname": "新增新使用者至 {appName}",
"add_user": "新增使用者",
"manage_users": "管理使用者",
"admin_privileges": "管理員權限",
"admins_have_full_access_to_the_admin_panel": "管理員擁有存取管理面板的權限。",
"delete_firstname_lastname": "刪除 {lastName} {firstName}",
"are_you_sure_you_want_to_delete_this_user": "您確定要刪除該使用者嗎?",
"user_deleted_successfully": "使用者刪除成功",
"role": "角色",
"source": "來源",
"admin": "管理員",
"user": "使用者",
"local": "本地",
"toggle_menu": "切換選單",
"edit": "編輯",
"user_groups_updated_successfully": "使用者群組更新成功",
"user_updated_successfully": "使用者更新成功",
"custom_claims_updated_successfully": "自定義 claims 更新成功",
"back": "返回",
"user_details_firstname_lastname": "使用者詳細資料 {lastName} {firstName}",
"manage_which_groups_this_user_belongs_to": "管理此使用者所屬的群組。",
"custom_claims": "自定義 Claims",
"custom_claims_are_key_value_pairs_that_can_be_used_to_store_additional_information_about_a_user": "自定義宣告 (claim) 是可用來儲存使用者額外資訊的鍵值對。若請求的範圍中包含 'profile',這些宣告將會被加入至 ID token 中。",
"user_group_created_successfully": "使用者群組建立成功",
"create_user_group": "建立使用者群組",
"create_a_new_group_that_can_be_assigned_to_users": "建立可指派給使用者的新群組。",
"add_group": "新增群組",
"manage_user_groups": "管理使用者群組",
"friendly_name": "易記名稱",
"name_that_will_be_displayed_in_the_ui": "會顯示在 UI 的名稱",
"name_that_will_be_in_the_groups_claim": "會顯示在 \"groups\" claim 的名稱",
"delete_name": "刪除 {name}",
"are_you_sure_you_want_to_delete_this_user_group": "您確定要刪除該使用者群組嗎?",
"user_group_deleted_successfully": "使用者群組刪除成功",
"user_count": "使用者數量",
"user_group_updated_successfully": "使用者群組更新成功",
"users_updated_successfully": "使用者更新成功",
"user_group_details_name": "使用者群組詳細資料 {name}",
"assign_users_to_this_group": "指派使用者至此群組。",
"custom_claims_are_key_value_pairs_that_can_be_used_to_store_additional_information_about_a_user_prioritized": "自定義宣告 (claim) 是可用來儲存使用者額外資訊的鍵值對。若請求的範圍中包含 'profile',這些宣告將會被加入至 ID token 中。若宣告有衝突,將優先使用定義於使用者上的自定義宣告。",
"oidc_client_created_successfully": "OIDC 客戶端建立成功",
"create_oidc_client": "建立 OIDC 客戶端",
"add_a_new_oidc_client_to_appname": "建立新 OIDC 客戶端至 {appName}。",
"add_oidc_client": "新增 OIDC 客戶端",
"manage_oidc_clients": "管理 OIDC 客戶端",
"one_time_link": "一次性連結",
"use_this_link_to_sign_in_once": "使用此連結可進行一次性登入。適用於尚未新增密碼金鑰或已遺失密碼金鑰的使用者。",
"add": "新增",
"callback_urls": "Callback URLs",
"logout_callback_urls": "登出 Callback URLs",
"public_client": "公開客戶端",
"public_clients_description": "公開客戶端 (Public Client) 不包含 client secret。這類客戶端是為了行動裝置、網頁以及無法安全儲存 secret 的原生應用程式所設計。",
"pkce": "PKCE",
"public_key_code_exchange_is_a_security_feature_to_prevent_csrf_and_authorization_code_interception_attacks": "PKCE公開金鑰碼交換是一項安全機制用於防止 CSRF 與授權碼攔截攻擊。",
"name_logo": "{name} 標誌",
"change_logo": "更改標誌",
"upload_logo": "上傳標誌",
"remove_logo": "移除標誌",
"are_you_sure_you_want_to_delete_this_oidc_client": "您確定要刪除這個 OIDC 客戶端嗎?",
"oidc_client_deleted_successfully": "OIDC 客戶端刪除成功",
"authorization_url": "Authorization URL",
"oidc_discovery_url": "OIDC Discovery URL",
"token_url": "Token URL",
"userinfo_url": "Userinfo URL",
"logout_url": "登出 URL",
"certificate_url": "Certificate URL",
"enabled": "啟用",
"disabled": "停用",
"oidc_client_updated_successfully": "OIDC 客戶端更新成功",
"create_new_client_secret": "建立新 client secret",
"are_you_sure_you_want_to_create_a_new_client_secret": "確定要建立新的 client secret 嗎?舊的將會失效。",
"generate": "產生",
"new_client_secret_created_successfully": "新的 client secret 建立成功",
"allowed_user_groups_updated_successfully": "允許的使用者群組已成功更新",
"oidc_client_name": "OIDC 客戶端 {name}",
"client_id": "Client ID",
"client_secret": "Client secret",
"show_more_details": "顯示更多資訊",
"allowed_user_groups": "允許的使用者群組",
"add_user_groups_to_this_client_to_restrict_access_to_users_in_these_groups": "將使用者群組新增至此客戶端,以限制只有這些群組中的使用者可以存取。若未選擇任何群組,所有使用者都將能存取此客戶端。",
"favicon": "Favicon",
"light_mode_logo": "亮色模式標誌",
"dark_mode_logo": "暗色模式標誌",
"background_image": "背景圖片",
"language": "語言",
"reset_profile_picture_question": "重設個人資料圖片?",
"this_will_remove_the_uploaded_image_and_reset_the_profile_picture_to_default": "這將會移除已上傳的圖片,並將個人資料圖片重設為預設圖像。是否繼續?",
"reset": "重設",
"reset_to_default": "重設至預設值",
"profile_picture_has_been_reset": "個人資料圖片已經重設。 這可能會花幾分鐘更新。",
"select_the_language_you_want_to_use": "請選擇您想使用的語言,部分語言可能尚未完整翻譯。",
"personal": "個人",
"global": "全域",
"all_users": "所有使用者",
"all_events": "所有事件",
"all_clients": "所有客戶端",
"global_audit_log": "全域稽核日誌",
"see_all_account_activities_from_the_last_3_months": "查看過去 3 個月的所有使用者活動。",
"token_sign_in": "Token 登入",
"client_authorization": "客戶端授權",
"new_client_authorization": "新客戶端授權",
"disable_animations": "停用動畫",
"turn_off_ui_animations": "關閉整個系統中的所有動畫效果。",
"user_disabled": "帳戶已停用",
"disabled_users_cannot_log_in_or_use_services": "已停用的使用者不能登入或使用服務。",
"user_disabled_successfully": "使用者已成功停用。",
"user_enabled_successfully": "使用者已成功啟用。",
"status": "狀態",
"disable_firstname_lastname": "停用 {lastName} {firstName}",
"are_you_sure_you_want_to_disable_this_user": "您確定要停用此使用者嗎?他們將無法登入或使用任何服務。",
"ldap_soft_delete_users": "保留來自 LDAP 的停用使用者。",
"ldap_soft_delete_users_description": "啟用此選項後,從 LDAP 移除的使用者將會被停用,而不是從系統中刪除。",
"login_code_email_success": "登入代碼已傳送給使用者。",
"send_email": "發送郵件",
"show_code": "顯示代碼",
"callback_url_description": "由客戶端提供的 URL。如留空系統將自動填入。支援萬用字元 (*),但為了安全性建議避免使用。",
"logout_callback_url_description": "由客戶端提供的 URL。支援萬用字元 (*),但為了安全性建議避免使用。",
"api_key_expiration": "API 金鑰到期通知",
"send_an_email_to_the_user_when_their_api_key_is_about_to_expire": "當使用者的 API 金鑰即將到期時,發送電子郵件通知。",
"authorize_device": "授權裝置",
"the_device_has_been_authorized": "裝置已獲授權。",
"enter_code_displayed_in_previous_step": "請輸入上一步顯示的代碼。",
"authorize": "授權",
"federated_client_credentials": "聯邦身分",
"federated_client_credentials_description": "使用聯邦身分,您可以透過由第三方授權機構簽發的 JWT 權杖來驗證 OIDC 客戶端。",
"add_federated_client_credential": "增加聯邦身分",
"add_another_federated_client_credential": "新增另一組聯邦身分",
"oidc_allowed_group_count": "允許的群組數量",
"unrestricted": "未受限制",
"show_advanced_options": "顯示進階選項",
"hide_advanced_options": "隱藏進階選項",
"oidc_data_preview": "OIDC Data Preview",
"preview_the_oidc_data_that_would_be_sent_for_different_users": "Preview the OIDC data that would be sent for different users",
"id_token": "ID Token",
"access_token": "Access Token",
"userinfo": "Userinfo",
"id_token_payload": "ID Token Payload",
"access_token_payload": "Access Token Payload",
"userinfo_endpoint_response": "Userinfo Endpoint Response",
"copy": "Copy",
"no_preview_data_available": "No preview data available",
"copy_all": "Copy All",
"preview": "Preview",
"preview_for_user": "Preview for {name} ({email})",
"preview_the_oidc_data_that_would_be_sent_for_this_user": "Preview the OIDC data that would be sent for this user",
"show": "Show",
"select_an_option": "Select an option",
"select_user": "Select User",
"error": "Error"
}

View File

@@ -1,12 +1,12 @@
{
"name": "pocket-id-frontend",
"version": "1.1.0",
"version": "1.2.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "pocket-id-frontend",
"version": "1.1.0",
"version": "1.2.0",
"dependencies": {
"@lucide/svelte": "^0.511.0",
"@simplewebauthn/browser": "^13.1.0",
@@ -16,9 +16,10 @@
"crypto": "^1.0.1",
"jose": "^5.9.6",
"qrcode": "^1.5.4",
"rollup-plugin-visualizer": "^6.0.1",
"sveltekit-superforms": "^2.23.1",
"tailwind-merge": "^3.3.0",
"zod": "^3.24.1"
"zod": "^3.25.55"
},
"devDependencies": {
"@inlang/paraglide-js": "^2.0.13",
@@ -2412,6 +2413,15 @@
"node": ">=0.10.0"
}
},
"node_modules/define-lazy-prop": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-2.0.0.tgz",
"integrity": "sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/delayed-stream": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
@@ -2536,6 +2546,15 @@
"integrity": "sha512-d6xOpEDfsi2CZVlPQzGeux8XMwLT9hssAsaPYExaQMuYskwb+x1x7J371tWlbBdWHroy99KnVB6qIkUbs5X3UQ==",
"optional": true
},
"node_modules/escalade": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
"integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/escape-string-regexp": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
@@ -3156,6 +3175,21 @@
"dev": true,
"license": "MIT"
},
"node_modules/is-docker": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz",
"integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==",
"license": "MIT",
"bin": {
"is-docker": "cli.js"
},
"engines": {
"node": ">=8"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/is-extglob": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
@@ -3195,6 +3229,18 @@
"node": ">=0.12.0"
}
},
"node_modules/is-wsl": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz",
"integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==",
"license": "MIT",
"dependencies": {
"is-docker": "^2.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/isexe": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
@@ -3791,6 +3837,23 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/open": {
"version": "8.4.2",
"resolved": "https://registry.npmjs.org/open/-/open-8.4.2.tgz",
"integrity": "sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ==",
"license": "MIT",
"dependencies": {
"define-lazy-prop": "^2.0.0",
"is-docker": "^2.1.1",
"is-wsl": "^2.2.0"
},
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/optionator": {
"version": "0.9.4",
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
@@ -4356,6 +4419,112 @@
"fsevents": "~2.3.2"
}
},
"node_modules/rollup-plugin-visualizer": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/rollup-plugin-visualizer/-/rollup-plugin-visualizer-6.0.1.tgz",
"integrity": "sha512-NjlGElvLXCSZSAi3gNRZbfX3qlQbQcJ9TW97c5JpqfVwMhttj9YwEdPwcvbKj91RnMX2PWAjonvSEv6UEYtnRQ==",
"license": "MIT",
"dependencies": {
"open": "^8.0.0",
"picomatch": "^4.0.2",
"source-map": "^0.7.4",
"yargs": "^17.5.1"
},
"bin": {
"rollup-plugin-visualizer": "dist/bin/cli.js"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"rolldown": "1.x",
"rollup": "2.x || 3.x || 4.x"
},
"peerDependenciesMeta": {
"rolldown": {
"optional": true
},
"rollup": {
"optional": true
}
}
},
"node_modules/rollup-plugin-visualizer/node_modules/cliui": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz",
"integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==",
"license": "ISC",
"dependencies": {
"string-width": "^4.2.0",
"strip-ansi": "^6.0.1",
"wrap-ansi": "^7.0.0"
},
"engines": {
"node": ">=12"
}
},
"node_modules/rollup-plugin-visualizer/node_modules/source-map": {
"version": "0.7.4",
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.4.tgz",
"integrity": "sha512-l3BikUxvPOcn5E74dZiq5BGsTb5yEwhaTSzccU6t4sDOH8NWJCstKO5QT2CvtFoK6F0saL7p9xHAqHOlCPJygA==",
"license": "BSD-3-Clause",
"engines": {
"node": ">= 8"
}
},
"node_modules/rollup-plugin-visualizer/node_modules/wrap-ansi": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
"integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
"license": "MIT",
"dependencies": {
"ansi-styles": "^4.0.0",
"string-width": "^4.1.0",
"strip-ansi": "^6.0.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
}
},
"node_modules/rollup-plugin-visualizer/node_modules/y18n": {
"version": "5.0.8",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
"integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==",
"license": "ISC",
"engines": {
"node": ">=10"
}
},
"node_modules/rollup-plugin-visualizer/node_modules/yargs": {
"version": "17.7.2",
"resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz",
"integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==",
"license": "MIT",
"dependencies": {
"cliui": "^8.0.1",
"escalade": "^3.1.1",
"get-caller-file": "^2.0.5",
"require-directory": "^2.1.1",
"string-width": "^4.2.3",
"y18n": "^5.0.5",
"yargs-parser": "^21.1.1"
},
"engines": {
"node": ">=12"
}
},
"node_modules/rollup-plugin-visualizer/node_modules/yargs-parser": {
"version": "21.1.1",
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz",
"integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/run-parallel": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
@@ -5458,9 +5627,10 @@
"integrity": "sha512-rAbqEGa8ovJy4pyBxZM70hg4pE6gDgaQ0Sl9M3enG3I0d6H4XSAM3GeNGLKnsBpuijUow064sf7ww1nutC5/3w=="
},
"node_modules/zod": {
"version": "3.24.1",
"resolved": "https://registry.npmjs.org/zod/-/zod-3.24.1.tgz",
"integrity": "sha512-muH7gBL9sI1nciMZV67X5fTKKBLtwpZ5VBp1vsOQzj1MhrBZ4wlVCm3gedKZWLp0Oyel8sIGfeiz54Su+OVT+A==",
"version": "3.25.55",
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.55.tgz",
"integrity": "sha512-219huNnkSLQnLsQ3uaRjXsxMrVm5C9W3OOpEVt2k5tvMKuA8nBSu38e0B//a+he9Iq2dvmk2VyYVlHqiHa4YBA==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}

View File

@@ -1,6 +1,6 @@
{
"name": "pocket-id-frontend",
"version": "1.2.0",
"version": "1.3.1",
"private": true,
"type": "module",
"scripts": {
@@ -23,7 +23,7 @@
"qrcode": "^1.5.4",
"sveltekit-superforms": "^2.23.1",
"tailwind-merge": "^3.3.0",
"zod": "^3.24.1"
"zod": "^3.25.55"
},
"devDependencies": {
"@inlang/paraglide-js": "^2.0.13",

View File

@@ -1,7 +1,21 @@
{
"$schema": "https://inlang.com/schema/project-settings",
"baseLocale": "en",
"locales": ["cs", "de", "en", "es", "fr", "it", "nl", "pl", "pt-BR", "ru", "zh-CN"],
"locales": [
"cs",
"da",
"de",
"en",
"es",
"fr",
"it",
"nl",
"pl",
"pt-BR",
"ru",
"zh-CN",
"zh-TW"
],
"modules": [
"./node_modules/@inlang/plugin-message-format/dist/index.js",
"./node_modules/@inlang/plugin-m-function-matcher/dist/index.js"

View File

@@ -20,67 +20,72 @@
}
:root {
--background: hsl(0 0% 100%);
--foreground: hsl(240 10% 3.9%);
--muted: hsl(240 4.8% 95.9%);
--muted-foreground: hsl(240 3.8% 46.1%);
--popover: hsl(0 0% 100%);
--popover-foreground: hsl(240 10% 3.9%);
--card: hsl(0 0% 100%);
--card-foreground: hsl(240 10% 3.9%);
--border: hsl(240 5.9% 90%);
--input: hsl(240 5.9% 90%);
--primary: hsl(240 5.9% 10%);
--primary-foreground: hsl(0 0% 98%);
--secondary: hsl(240 4.8% 95.9%);
--secondary-foreground: hsl(240 5.9% 10%);
--accent: hsl(240 4.8% 95.9%);
--accent-foreground: hsl(240 5.9% 10%);
--destructive: hsl(0 72.2% 50.6%);
--destructive-foreground: hsl(0 0% 98%);
--ring: hsl(240 10% 3.9%);
--radius: 0.5rem;
--radius: 0.625rem;
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
}
.dark {
--background: hsl(240 10% 3.9%);
--foreground: hsl(0 0% 98%);
--muted: hsl(240 3.7% 15.9%);
--muted-foreground: hsl(240 5% 64.9%);
--popover: hsl(240 10% 3.9%);
--popover-foreground: hsl(0 0% 98%);
--card: hsl(240 10% 3.9%);
--card-foreground: hsl(0 0% 98%);
--border: hsl(240 3.7% 15.9%);
--input: hsl(240 3.7% 15.9%);
--primary: hsl(0 0% 98%);
--primary-foreground: hsl(240 5.9% 10%);
--secondary: hsl(240 3.7% 15.9%);
--secondary-foreground: hsl(0 0% 98%);
--accent: hsl(240 3.7% 15.9%);
--accent-foreground: hsl(0 0% 98%);
--destructive: hsl(0 62.8% 30.6%);
--destructive-foreground: hsl(0 0% 98%);
--ring: hsl(240 4.9% 83.9%);
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
}
@theme inline {

View File

@@ -1,7 +1,7 @@
import type { HandleServerError } from '@sveltejs/kit';
import type { HandleClientError } from '@sveltejs/kit';
import { AxiosError } from 'axios';
export const handleError: HandleServerError = async ({ error, message, status }) => {
export const handleError: HandleClientError = async ({ error, message, status }) => {
if (error instanceof AxiosError) {
message = error.response?.data.error || message;
status = error.response?.status || status;

View File

@@ -2,7 +2,9 @@
import DatePicker from '$lib/components/form/date-picker.svelte';
import { Input, type FormInputEvent } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label';
import { m } from '$lib/paraglide/messages';
import type { FormInput } from '$lib/utils/form-util';
import { LucideExternalLink } from '@lucide/svelte';
import type { Snippet } from 'svelte';
import type { HTMLAttributes } from 'svelte/elements';
@@ -10,6 +12,7 @@
input = $bindable(),
label,
description,
docsLink,
placeholder,
disabled = false,
type = 'text',
@@ -20,6 +23,7 @@
input?: FormInput<string | boolean | number | Date | undefined>;
label?: string;
description?: string;
docsLink?: string;
placeholder?: string;
disabled?: boolean;
type?: 'text' | 'password' | 'email' | 'number' | 'checkbox' | 'date';
@@ -35,7 +39,19 @@
<Label class="mb-0" for={id}>{label}</Label>
{/if}
{#if description}
<p class="text-muted-foreground mt-1 text-xs">{description}</p>
<p class="text-muted-foreground mt-1 text-xs">
{description}
{#if docsLink}
<a
class="relative text-white after:absolute after:bottom-0 after:left-0 after:h-px after:w-full after:translate-y-[-1px] after:bg-white"
href={docsLink}
target="_blank"
>
{m.docs()}
<LucideExternalLink class="inline size-3 align-text-top" />
</a>
{/if}
</p>
{/if}
<div class={label || description ? 'mt-2' : ''}>
{#if children}

View File

@@ -0,0 +1,56 @@
<script lang="ts">
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
import { LucideChevronDown } from '@lucide/svelte';
import { Badge } from '../ui/badge';
import { Button } from '../ui/button';
let {
items,
selectedItems = $bindable(),
onSelect,
autoClose = false
}: {
items: {
value: string;
label: string;
}[];
selectedItems: string[];
onSelect?: (value: string) => void;
autoClose?: boolean;
} = $props();
function handleItemSelect(value: string) {
if (selectedItems.includes(value)) {
selectedItems = selectedItems.filter((item) => item !== value);
} else {
selectedItems = [...selectedItems, value];
}
onSelect?.(value);
}
</script>
<DropdownMenu.Root>
<DropdownMenu.Trigger>
{#snippet child({ props })}
<Button {...props} variant="outline">
{#each items.filter((item) => selectedItems.includes(item.value)) as item}
<Badge variant="secondary">
{item.label}
</Badge>
{/each}
<LucideChevronDown class="text-muted-foreground ml-2 size-4" />
</Button>
{/snippet}
</DropdownMenu.Trigger>
<DropdownMenu.Content align="start" class="w-[var(--bits-dropdown-menu-anchor-width)]">
{#each items as item}
<DropdownMenu.CheckboxItem
checked={selectedItems.includes(item.value)}
onCheckedChange={() => handleItemSelect(item.value)}
closeOnSelect={autoClose}
>
{item.label}
</DropdownMenu.CheckboxItem>
{/each}
</DropdownMenu.Content>
</DropdownMenu.Root>

View File

@@ -2,15 +2,19 @@
import { Button } from '$lib/components/ui/button';
import * as Command from '$lib/components/ui/command';
import * as Popover from '$lib/components/ui/popover';
import { m } from '$lib/paraglide/messages';
import { cn } from '$lib/utils/style';
import { LucideCheck, LucideChevronDown } from '@lucide/svelte';
import { LoaderCircle, LucideCheck, LucideChevronDown } from '@lucide/svelte';
import { tick } from 'svelte';
import type { HTMLAttributes } from 'svelte/elements';
import type { FormEventHandler, HTMLAttributes } from 'svelte/elements';
let {
items,
value = $bindable(),
onSelect,
oninput,
isLoading,
selectText = m.select_an_option(),
...restProps
}: HTMLAttributes<HTMLButtonElement> & {
items: {
@@ -18,7 +22,10 @@
label: string;
}[];
value: string;
oninput?: FormEventHandler<HTMLInputElement>;
onSelect?: (value: string) => void;
isLoading?: boolean;
selectText?: string;
} = $props();
let open = $state(false);
@@ -53,21 +60,38 @@
</script>
<Popover.Root bind:open {...restProps}>
<Popover.Trigger class="w-full">
<Button
variant="outline"
role="combobox"
aria-expanded={open}
class={cn('justify-between', restProps.class)}
>
{items.find((item) => item.value === value)?.label || 'Select an option'}
<LucideChevronDown class="ml-2 size-4 shrink-0 opacity-50" />
</Button>
<Popover.Trigger>
{#snippet child({ props })}
<Button
variant="outline"
role="combobox"
aria-expanded={open}
{...props}
class={cn('justify-between', restProps.class)}
>
{items.find((item) => item.value === value)?.label || selectText}
<LucideChevronDown class="ml-2 size-4 shrink-0 opacity-50" />
</Button>
{/snippet}
</Popover.Trigger>
<Popover.Content class="p-0">
<Popover.Content class="p-0" sameWidth>
<Command.Root shouldFilter={false}>
<Command.Input placeholder="Search..." oninput={(e: any) => filterItems(e.target.value)} />
<Command.Empty>No results found.</Command.Empty>
<Command.Input
placeholder={m.search()}
oninput={(e) => {
filterItems(e.currentTarget.value);
oninput?.(e);
}}
/>
<Command.Empty>
{#if isLoading}
<div class="flex w-full justify-center">
<LoaderCircle class="size-4 animate-spin" />
</div>
{:else}
{m.no_items_found()}
{/if}
</Command.Empty>
<Command.Group>
{#each filteredItems as item}
<Command.Item

View File

@@ -103,6 +103,13 @@ class OidcService extends APIService {
const response = await this.api.get(`/oidc/device/info?code=${userCode}`);
return response.data;
}
async getClientPreview(id: string, userId: string, scopes: string) {
const response = await this.api.get(`/oidc/clients/${id}/preview/${userId}`, {
params: { scopes }
});
return response.data;
}
}
export default OidcService;

View File

@@ -1,12 +1,12 @@
import { setLocale } from '$lib/paraglide/runtime';
import type { User } from '$lib/types/user.type';
import { setLocale } from '$lib/utils/locale.util';
import { writable } from 'svelte/store';
const userStore = writable<User | null>(null);
const setUser = (user: User) => {
if (user.locale) {
setLocale(user.locale, { reload: false });
setLocale(user.locale, false);
}
userStore.set(user);
};

View File

@@ -6,11 +6,23 @@ export type OidcClientMetaData = {
hasLogo: boolean;
};
export type OidcClientFederatedIdentity = {
issuer: string;
subject?: string;
audience?: string;
jwks: string | undefined;
};
export type OidcClientCredentials = {
federatedIdentities: OidcClientFederatedIdentity[];
};
export type OidcClient = OidcClientMetaData & {
callbackURLs: string[]; // No longer requires at least one URL
callbackURLs: string[];
logoutCallbackURLs: string[];
isPublic: boolean;
pkceEnabled: boolean;
credentials?: OidcClientCredentials;
};
export type OidcClientWithAllowedUserGroups = OidcClient & {

View File

@@ -1,4 +1,8 @@
export function debounced<T extends (...args: any[]) => void>(func: T, delay: number) {
export function debounced<T extends (...args: any[]) => any>(
func: T,
delay: number,
onLoadingChange?: (loading: boolean) => void
) {
let debounceTimeout: ReturnType<typeof setTimeout>;
return (...args: Parameters<T>) => {
@@ -6,8 +10,14 @@ export function debounced<T extends (...args: any[]) => void>(func: T, delay: nu
clearTimeout(debounceTimeout);
}
debounceTimeout = setTimeout(() => {
func(...args);
onLoadingChange?.(true);
debounceTimeout = setTimeout(async () => {
try {
await func(...args);
} finally {
onLoadingChange?.(false);
}
}, delay);
};
}

View File

@@ -1,5 +1,5 @@
import { writable } from 'svelte/store';
import { z } from 'zod';
import { get, writable } from 'svelte/store';
import { z } from 'zod/v4';
export type FormInput<T> = {
value: T;
@@ -13,6 +13,7 @@ type FormInputs<T> = {
export function createForm<T extends z.ZodType<any, any>>(schema: T, initialValues: z.infer<T>) {
// Create a writable store for the inputs
const inputsStore = writable<FormInputs<z.infer<T>>>(initializeInputs(initialValues));
const errorsStore = writable<z.ZodError<any> | undefined>();
function initializeInputs(initialValues: z.infer<T>): FormInputs<z.infer<T>> {
const inputs: FormInputs<z.infer<T>> = {} as FormInputs<z.infer<T>>;
@@ -36,11 +37,12 @@ export function createForm<T extends z.ZodType<any, any>>(schema: T, initialValu
);
const result = schema.safeParse(values);
errorsStore.set(result.error);
if (!result.success) {
success = false;
for (const input of Object.keys(inputs)) {
const error = result.error.errors.find((e) => e.path[0] === input);
const error = result.error.issues.find((e) => e.path[0] === input);
if (error) {
inputs[input as keyof z.infer<T>].error = error.message;
} else {
@@ -58,15 +60,14 @@ export function createForm<T extends z.ZodType<any, any>>(schema: T, initialValu
}
function data() {
let values: z.infer<T> | null = null;
inputsStore.subscribe((inputs) => {
values = Object.fromEntries(
Object.entries(inputs).map(([key, input]) => {
input.value = trimValue(input.value);
return [key, input.value];
})
) as z.infer<T>;
})();
const inputs = get(inputsStore);
const values = Object.fromEntries(
Object.entries(inputs).map(([key, input]) => {
input.value = trimValue(input.value);
return [key, input.value];
})
) as z.infer<T>;
return values;
}
@@ -108,6 +109,7 @@ export function createForm<T extends z.ZodType<any, any>>(schema: T, initialValu
return {
schema,
inputs: inputsStore,
errors: errorsStore,
data,
validate,
setValue,

View File

@@ -0,0 +1,10 @@
import { setLocale as setParaglideLocale, type Locale } from '$lib/paraglide/runtime';
import { z } from 'zod/v4';
export function setLocale(locale: Locale, reload = true) {
import(`../../../node_modules/zod/dist/esm/v4/locales/${locale}.js`)
.then((zodLocale) => z.config(zodLocale.default()))
.finally(() => {
setParaglideLocale(locale, { reload });
});
}

View File

@@ -0,0 +1,25 @@
import type { User } from '$lib/types/user.type';
// Returns the path to redirect to based on the current path and user authentication status
// If no redirect is needed, it returns null
export function getAuthRedirectPath(path: string, user: User | null) {
const isSignedIn = !!user;
const isAdmin = user?.isAdmin;
const isUnauthenticatedOnlyPath =
path == '/login' || path.startsWith('/login/') || path == '/lc' || path.startsWith('/lc/');
const isPublicPath = ['/authorize', '/device', '/health', '/healthz'].includes(path);
const isAdminPath = path == '/settings/admin' || path.startsWith('/settings/admin/');
if (!isUnauthenticatedOnlyPath && !isPublicPath && !isSignedIn) {
return '/login';
}
if (isUnauthenticatedOnlyPath && isSignedIn) {
return '/settings';
}
if (isAdminPath && !isAdmin) {
return '/settings';
}
}

View File

@@ -1,66 +1,13 @@
import { type ClassValue, clsx } from 'clsx';
import { twMerge } from 'tailwind-merge';
import { cubicOut } from 'svelte/easing';
import type { TransitionConfig } from 'svelte/transition';
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export type WithoutChild<T> = T extends { child?: any } ? Omit<T, 'child'> : T;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export type WithoutChildren<T> = T extends { children?: any } ? Omit<T, 'children'> : T;
export type WithoutChildrenOrChild<T> = WithoutChildren<WithoutChild<T>>;
export type WithElementRef<T, U extends HTMLElement = HTMLElement> = T & {
ref?: U | null;
};
type FlyAndScaleParams = {
y?: number;
x?: number;
start?: number;
duration?: number;
};
//DEPRECATED NEEDS TO BE REPLACED
export const flyAndScale = (
node: Element,
params: FlyAndScaleParams = { y: -8, x: 0, start: 0.95, duration: 150 }
): TransitionConfig => {
const style = getComputedStyle(node);
const transform = style.transform === 'none' ? '' : style.transform;
const scaleConversion = (valueA: number, scaleA: [number, number], scaleB: [number, number]) => {
const [minA, maxA] = scaleA;
const [minB, maxB] = scaleB;
const percentage = (valueA - minA) / (maxA - minA);
const valueB = percentage * (maxB - minB) + minB;
return valueB;
};
const styleToString = (style: Record<string, number | string | undefined>): string => {
return Object.keys(style).reduce((str, key) => {
if (style[key] === undefined) return str;
return str + `${key}:${style[key]};`;
}, '');
};
return {
duration: params.duration ?? 200,
delay: 0,
css: (t) => {
const y = scaleConversion(t, [0, 1], [params.y ?? 5, 0]);
const x = scaleConversion(t, [0, 1], [params.x ?? 0, 0]);
const scale = scaleConversion(t, [0, 1], [params.start ?? 0.95, 1]);
return styleToString({
transform: `${transform} translate3d(${x}px, ${y}px, 0) scale(${scale})`,
opacity: t
});
},
easing: cubicOut
};
};

View File

@@ -1,4 +1,6 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { page } from '$app/state';
import ConfirmDialog from '$lib/components/confirm-dialog/confirm-dialog.svelte';
import Error from '$lib/components/error.svelte';
import Header from '$lib/components/header/header.svelte';
@@ -6,6 +8,7 @@
import { m } from '$lib/paraglide/messages';
import appConfigStore from '$lib/stores/application-configuration-store';
import userStore from '$lib/stores/user-store';
import { getAuthRedirectPath } from '$lib/utils/redirection-util';
import { ModeWatcher } from 'mode-watcher';
import type { Snippet } from 'svelte';
import '../app.css';
@@ -21,6 +24,11 @@
const { user, appConfig } = data;
const redirectPath = getAuthRedirectPath(page.url.pathname, user);
if (redirectPath) {
goto(redirectPath);
}
if (user) {
userStore.setUser(user);
}

View File

@@ -1,12 +1,10 @@
import { goto } from '$app/navigation';
import AppConfigService from '$lib/services/app-config-service';
import UserService from '$lib/services/user-service';
import type { User } from '$lib/types/user.type';
import type { LayoutLoad } from './$types';
export const ssr = false;
export const load: LayoutLoad = async ({ url }) => {
export const load: LayoutLoad = async () => {
const userService = new UserService();
const appConfigService = new AppConfigService();
@@ -21,35 +19,8 @@ export const load: LayoutLoad = async ({ url }) => {
const [user, appConfig] = await Promise.all([userPromise, appConfigPromise]);
const redirectPath = await getRedirectPath(url.pathname, user);
if (redirectPath) {
goto(redirectPath);
}
return {
user,
appConfig
};
};
const getRedirectPath = async (path: string, user: User | null) => {
const isSignedIn = !!user;
const isAdmin = user?.isAdmin;
const isUnauthenticatedOnlyPath =
path == '/login' || path.startsWith('/login/') || path == '/lc' || path.startsWith('/lc/');
const isPublicPath = ['/authorize', '/device', '/health', '/healthz'].includes(path);
const isAdminPath = path == '/settings/admin' || path.startsWith('/settings/admin/');
if (!isUnauthenticatedOnlyPath && !isPublicPath && !isSignedIn) {
return '/login';
}
if (isUnauthenticatedOnlyPath && isSignedIn) {
return '/settings';
}
if (isAdminPath && !isAdmin) {
return '/settings';
}
};

View File

@@ -39,7 +39,9 @@
</script>
<section>
<div class="bg-muted/40 flex min-h-[calc(100vh-64px)] w-full flex-col justify-between">
<div
class="bg-muted/40 dark:bg-background flex min-h-[calc(100vh-64px)] w-full flex-col justify-between"
>
<main
in:fade={{ duration: 200 }}
class="mx-auto flex w-full max-w-[1640px] flex-col gap-x-8 gap-y-8 overflow-hidden p-4 md:p-8 lg:flex-row"

View File

@@ -9,7 +9,7 @@
import { preventDefault } from '$lib/utils/event-util';
import { createForm } from '$lib/utils/form-util';
import { toast } from 'svelte-sonner';
import { z } from 'zod';
import { z } from 'zod/v4';
let {
callback,
@@ -35,7 +35,7 @@
.min(2)
.max(30)
.regex(/^[a-z0-9_@.-]+$/, m.username_can_only_contain()),
email: z.string().email(),
email: z.email(),
isAdmin: z.boolean()
});
type FormSchema = typeof formSchema;

View File

@@ -1,14 +1,16 @@
<script lang="ts">
import * as Select from '$lib/components/ui/select';
import { getLocale, setLocale, type Locale } from '$lib/paraglide/runtime';
import { getLocale, type Locale } from '$lib/paraglide/runtime';
import UserService from '$lib/services/user-service';
import userStore from '$lib/stores/user-store';
import { setLocale } from '$lib/utils/locale.util';
const userService = new UserService();
const currentLocale = getLocale();
const locales = {
cs: 'Čeština',
da: 'Dansk',
de: 'Deutsch',
en: 'English',
es: 'Español',
@@ -18,7 +20,8 @@
pl: 'Polski',
'pt-BR': 'Português brasileiro',
ru: 'Русский',
'zh-CN': '简体中文'
'zh-CN': '简体中文',
'zh-TW': '繁體中文(臺灣)'
};
async function updateLocale(locale: Locale) {

View File

@@ -5,7 +5,7 @@
import type { ApiKeyCreate } from '$lib/types/api-key.type';
import { preventDefault } from '$lib/utils/event-util';
import { createForm } from '$lib/utils/form-util';
import { z } from 'zod';
import { z } from 'zod/v4';
let {
callback
@@ -28,8 +28,8 @@
const formSchema = z.object({
name: z
.string()
.min(3, m.name_must_be_at_least_3_characters())
.max(50, m.name_cannot_exceed_50_characters()),
.min(3)
.max(50),
description: z.string().default(''),
expiresAt: z.date().min(new Date(), m.expiration_date_must_be_in_the_future())
});

View File

@@ -12,7 +12,7 @@
import { preventDefault } from '$lib/utils/event-util';
import { createForm } from '$lib/utils/form-util';
import { toast } from 'svelte-sonner';
import { z } from 'zod';
import { z } from 'zod/v4';
let {
callback,
@@ -36,7 +36,7 @@
smtpPort: z.number().min(1),
smtpUser: z.string(),
smtpPassword: z.string(),
smtpFrom: z.string().email(),
smtpFrom: z.email(),
smtpTls: z.enum(['none', 'starttls', 'tls']),
smtpSkipCertVerify: z.boolean(),
emailOneTimeAccessAsUnauthenticatedEnabled: z.boolean(),

View File

@@ -8,7 +8,7 @@
import { preventDefault } from '$lib/utils/event-util';
import { createForm } from '$lib/utils/form-util';
import { toast } from 'svelte-sonner';
import { z } from 'zod';
import { z } from 'zod/v4';
let {
callback,
@@ -71,7 +71,7 @@
<CheckboxWithLabel
id="disable-animations"
label={m.disable_animations()}
description={m.turn_off_all_animations_throughout_the_admin_ui()}
description={m.turn_off_ui_animations()}
bind:checked={$inputs.disableAnimations.value}
/>
</div>

View File

@@ -10,7 +10,7 @@
import { preventDefault } from '$lib/utils/event-util';
import { createForm } from '$lib/utils/form-util';
import { toast } from 'svelte-sonner';
import { z } from 'zod';
import { z } from 'zod/v4';
let {
callback,
@@ -48,7 +48,7 @@
};
const formSchema = z.object({
ldapUrl: z.string().url(),
ldapUrl: z.url(),
ldapBindDn: z.string().min(1),
ldapBindPassword: z.string().min(1),
ldapBase: z.string().min(1),

View File

@@ -8,15 +8,16 @@
import * as Card from '$lib/components/ui/card';
import Label from '$lib/components/ui/label/label.svelte';
import UserGroupSelection from '$lib/components/user-group-selection.svelte';
import { m } from '$lib/paraglide/messages';
import OidcService from '$lib/services/oidc-service';
import clientSecretStore from '$lib/stores/client-secret-store';
import type { OidcClientCreateWithLogo } from '$lib/types/oidc.type';
import { axiosErrorToast } from '$lib/utils/error-util';
import { LucideChevronLeft, LucideRefreshCcw } from '@lucide/svelte';
import { LucideChevronLeft, LucideRefreshCcw, RectangleEllipsis } from '@lucide/svelte';
import { toast } from 'svelte-sonner';
import { slide } from 'svelte/transition';
import OidcForm from '../oidc-client-form.svelte';
import { m } from '$lib/paraglide/messages';
import OidcClientPreviewModal from '../oidc-client-preview-modal.svelte';
let { data } = $props();
let client = $state({
@@ -24,6 +25,7 @@
allowedUserGroupIds: data.allowedUserGroups.map((g) => g.id)
});
let showAllDetails = $state(false);
let showPreview = $state(false);
const oidcService = new OidcService();
@@ -91,6 +93,12 @@
});
}
let previewUserId = $state<string | null>(null);
function handlePreview(userId: string) {
previewUserId = userId;
}
beforeNavigate(() => {
clientSecretStore.clear();
});
@@ -166,7 +174,7 @@
</Card.Content>
</Card.Root>
<Card.Root>
<Card.Content class="p-5">
<Card.Content>
<OidcForm existingClient={client} callback={updateClient} />
</Card.Content>
</Card.Root>
@@ -180,3 +188,22 @@
<Button onclick={() => updateUserGroupClients(client.allowedUserGroupIds)}>{m.save()}</Button>
</div>
</CollapsibleCard>
<Card.Root>
<Card.Header>
<div class="flex flex-col items-start justify-between gap-3 sm:flex-row sm:items-center">
<div>
<Card.Title>
{m.oidc_data_preview()}
</Card.Title>
<Card.Description>
{m.preview_the_oidc_data_that_would_be_sent_for_different_users()}
</Card.Description>
</div>
<Button variant="outline" onclick={() => (showPreview = true)}>
{m.show()}
</Button>
</div>
</Card.Header>
</Card.Root>
<OidcClientPreviewModal bind:open={showPreview} clientId={client.id} />

View File

@@ -0,0 +1,152 @@
<script lang="ts">
import FormInput from '$lib/components/form/form-input.svelte';
import { Button } from '$lib/components/ui/button';
import { Input } from '$lib/components/ui/input';
import Label from '$lib/components/ui/label/label.svelte';
import { m } from '$lib/paraglide/messages';
import type { OidcClient, OidcClientFederatedIdentity } from '$lib/types/oidc.type';
import { LucideMinus, LucidePlus } from '@lucide/svelte';
import type { Snippet } from 'svelte';
import type { HTMLAttributes } from 'svelte/elements';
import { z } from 'zod/v4';
let {
client,
federatedIdentities = $bindable([]),
errors,
...restProps
}: HTMLAttributes<HTMLDivElement> & {
client?: OidcClient;
federatedIdentities: OidcClientFederatedIdentity[];
errors?: z.core.$ZodIssue[];
children?: Snippet;
} = $props();
function addFederatedIdentity() {
federatedIdentities = [
...federatedIdentities,
{
issuer: '',
subject: '',
audience: '',
jwks: ''
}
];
}
function removeFederatedIdentity(index: number) {
federatedIdentities = federatedIdentities.filter((_, i) => i !== index);
}
function updateFederatedIdentity(
index: number,
field: keyof OidcClientFederatedIdentity,
value: string
) {
federatedIdentities[index] = {
...federatedIdentities[index],
[field]: value
};
}
function getFieldError(index: number, field: keyof OidcClientFederatedIdentity): string | null {
console.log(federatedIdentities);
if (!errors) return null;
const path = [index, field];
return errors?.filter((e) => e.path[0] == path[0] && e.path[1] == path[1])[0]?.message;
}
</script>
<div {...restProps}>
<FormInput
label={m.federated_client_credentials()}
description={m.federated_client_credentials_description()}
docsLink="https://pocket-id.org/docs/guides/oidc-client-authentication"
>
<div class="space-y-4">
{#each federatedIdentities as identity, i}
<div class="space-y-3 rounded-lg border p-4">
<div class="flex items-center justify-between">
<Label class="text-sm font-medium">Identity {i + 1}</Label>
{#if federatedIdentities.length > 0}
<Button
variant="outline"
size="sm"
onclick={() => removeFederatedIdentity(i)}
aria-label="Remove federated identity"
>
<LucideMinus class="size-4" />
</Button>
{/if}
</div>
<div class="grid grid-cols-1 gap-3 md:grid-cols-2">
<div>
<Label for="issuer-{i}" class="text-xs">Issuer (Required)</Label>
<Input
id="issuer-{i}"
placeholder="https://example.com/"
value={identity.issuer}
oninput={(e) => updateFederatedIdentity(i, 'issuer', e.currentTarget.value)}
aria-invalid={!!getFieldError(i, 'issuer')}
/>
{#if getFieldError(i, 'issuer')}
<p class="text-destructive mt-1 text-xs">{getFieldError(i, 'issuer')}</p>
{/if}
</div>
<div>
<Label for="subject-{i}" class="text-xs">Subject (Optional)</Label>
<Input
id="subject-{i}"
placeholder="Defaults to the client ID: {client?.id}"
value={identity.subject || ''}
oninput={(e) => updateFederatedIdentity(i, 'subject', e.currentTarget.value)}
aria-invalid={!!getFieldError(i, 'subject')}
/>
{#if getFieldError(i, 'subject')}
<p class="text-destructive mt-1 text-xs">{getFieldError(i, 'subject')}</p>
{/if}
</div>
<div>
<Label for="audience-{i}" class="text-xs">Audience (Optional)</Label>
<Input
id="audience-{i}"
placeholder="Defaults to the Pocket ID URL"
value={identity.audience || ''}
oninput={(e) => updateFederatedIdentity(i, 'audience', e.currentTarget.value)}
aria-invalid={!!getFieldError(i, 'audience')}
/>
{#if getFieldError(i, 'audience')}
<p class="text-destructive mt-1 text-xs">{getFieldError(i, 'audience')}</p>
{/if}
</div>
<div>
<Label for="jwks-{i}" class="text-xs">JWKS URL (Optional)</Label>
<Input
id="jwks-{i}"
placeholder="Defaults to {identity.issuer || '<issuer>'}/.well-known/jwks.json"
value={identity.jwks || ''}
oninput={(e) => updateFederatedIdentity(i, 'jwks', e.currentTarget.value)}
aria-invalid={!!getFieldError(i, 'jwks')}
/>
{#if getFieldError(i, 'jwks')}
<p class="text-destructive mt-1 text-xs">{getFieldError(i, 'jwks')}</p>
{/if}
</div>
</div>
</div>
{/each}
</div>
</FormInput>
<Button class="mt-3" variant="secondary" size="sm" onclick={addFederatedIdentity} type="button">
<LucidePlus class="mr-1 size-4" />
{federatedIdentities.length === 0
? m.add_federated_client_credential()
: m.add_another_federated_client_credential()}
</Button>
</div>

View File

@@ -5,14 +5,14 @@
import { Button } from '$lib/components/ui/button';
import Label from '$lib/components/ui/label/label.svelte';
import { m } from '$lib/paraglide/messages';
import type {
OidcClient,
OidcClientCreate,
OidcClientCreateWithLogo
} from '$lib/types/oidc.type';
import type { OidcClient, OidcClientCreateWithLogo } from '$lib/types/oidc.type';
import { preventDefault } from '$lib/utils/event-util';
import { createForm } from '$lib/utils/form-util';
import { z } from 'zod';
import { cn } from '$lib/utils/style';
import { LucideChevronDown } from '@lucide/svelte';
import { slide } from 'svelte/transition';
import { z } from 'zod/v4';
import FederatedIdentitiesInput from './federated-identities-input.svelte';
import OidcCallbackUrlInput from './oidc-callback-url-input.svelte';
let {
@@ -24,17 +24,21 @@
} = $props();
let isLoading = $state(false);
let showAdvancedOptions = $state(false);
let logo = $state<File | null | undefined>();
let logoDataURL: string | null = $state(
existingClient?.hasLogo ? `/api/oidc/clients/${existingClient!.id}/logo` : null
);
const client: OidcClientCreate = {
const client = {
name: existingClient?.name || '',
callbackURLs: existingClient?.callbackURLs || [],
logoutCallbackURLs: existingClient?.logoutCallbackURLs || [],
isPublic: existingClient?.isPublic || false,
pkceEnabled: existingClient?.pkceEnabled || false
pkceEnabled: existingClient?.pkceEnabled || false,
credentials: {
federatedIdentities: existingClient?.credentials?.federatedIdentities || []
}
};
const formSchema = z.object({
@@ -42,11 +46,21 @@
callbackURLs: z.array(z.string().nonempty()).default([]),
logoutCallbackURLs: z.array(z.string().nonempty()),
isPublic: z.boolean(),
pkceEnabled: z.boolean()
pkceEnabled: z.boolean(),
credentials: z.object({
federatedIdentities: z.array(
z.object({
issuer: z.url(),
subject: z.string().optional(),
audience: z.string().optional(),
jwks: z.url().optional().or(z.literal(''))
})
)
})
});
type FormSchema = typeof formSchema;
const { inputs, ...form } = createForm<FormSchema>(formSchema, client);
const { inputs, errors, ...form } = createForm<FormSchema>(formSchema, client);
async function onSubmit() {
const data = form.validate();
@@ -77,6 +91,15 @@
logo = null;
logoDataURL = null;
}
function getFederatedIdentityErrors(errors: z.ZodError<any> | undefined) {
return errors?.issues
.filter((e) => e.path[0] == 'credentials' && e.path[1] == 'federatedIdentities')
.map((e) => {
e.path.splice(0, 2);
return e;
});
}
</script>
<form onsubmit={preventDefault(onSubmit)}>
@@ -139,8 +162,31 @@
</div>
</div>
</div>
<div class="w-full"></div>
<div class="mt-5 flex justify-end">
<Button {isLoading} type="submit">{m.save()}</Button>
{#if showAdvancedOptions}
<div class="mt-5 md:col-span-2" transition:slide={{ duration: 200 }}>
<FederatedIdentitiesInput
client={existingClient}
bind:federatedIdentities={$inputs.credentials.value.federatedIdentities}
errors={getFederatedIdentityErrors($errors)}
/>
</div>
{/if}
<div class="relative mt-5 flex justify-center">
<Button
variant="ghost"
class="text-muted-foregroun"
onclick={() => (showAdvancedOptions = !showAdvancedOptions)}
>
{showAdvancedOptions ? m.hide_advanced_options() : m.show_advanced_options()}
<LucideChevronDown
class={cn(
'size-5 transition-transform duration-200',
showAdvancedOptions && 'rotate-180 transform'
)}
/>
</Button>
<Button {isLoading} type="submit" class="absolute right-0">{m.save()}</Button>
</div>
</form>

View File

@@ -0,0 +1,211 @@
<script lang="ts">
import CopyToClipboard from '$lib/components/copy-to-clipboard.svelte';
import MultiSelect from '$lib/components/form/multi-select.svelte';
import SearchableSelect from '$lib/components/form/searchable-select.svelte';
import * as Alert from '$lib/components/ui/alert';
import { Button } from '$lib/components/ui/button';
import * as Dialog from '$lib/components/ui/dialog';
import Label from '$lib/components/ui/label/label.svelte';
import * as Tabs from '$lib/components/ui/tabs';
import { m } from '$lib/paraglide/messages';
import OidcService from '$lib/services/oidc-service';
import UserService from '$lib/services/user-service';
import type { User } from '$lib/types/user.type';
import { debounced } from '$lib/utils/debounce-util';
import { getAxiosErrorMessage } from '$lib/utils/error-util';
import { LucideAlertTriangle } from '@lucide/svelte';
import { onMount } from 'svelte';
let {
open = $bindable(),
clientId
}: {
open: boolean;
clientId: string;
} = $props();
const oidcService = new OidcService();
const userService = new UserService();
let previewData = $state<{
idToken?: any;
accessToken?: any;
userInfo?: any;
} | null>(null);
let loadingPreview = $state(false);
let isUserSearchLoading = $state(false);
let user: User | null = $state(null);
let users: User[] = $state([]);
let scopes: string[] = $state(['openid', 'email', 'profile']);
let errorMessage: string | null = $state(null);
async function loadPreviewData() {
errorMessage = null;
try {
previewData = await oidcService.getClientPreview(clientId, user!.id, scopes.join(' '));
} catch (e) {
const error = getAxiosErrorMessage(e);
errorMessage = error;
previewData = null;
} finally {
loadingPreview = false;
}
}
async function loadUsers(search?: string) {
users = (
await userService.list({
search,
pagination: { limit: 10, page: 1 }
})
).data;
if (!user) {
user = users[0];
}
}
async function onOpenChange(open: boolean) {
if (!open) {
previewData = null;
errorMessage = null;
} else {
loadingPreview = true;
await loadPreviewData().finally(() => {
loadingPreview = false;
});
}
}
const onUserSearch = debounced(
async (search: string) => await loadUsers(search),
300,
(loading) => (isUserSearchLoading = loading)
);
$effect(() => {
if (open) {
loadPreviewData();
}
});
onMount(() => {
loadUsers();
});
</script>
<Dialog.Root bind:open {onOpenChange}>
<Dialog.Content class="sm-min-w[500px] max-h-[90vh] min-w-[90vw] overflow-auto lg:min-w-[1000px]">
<Dialog.Header>
<Dialog.Title>{m.oidc_data_preview()}</Dialog.Title>
<Dialog.Description>
{#if user}
{m.preview_for_user({ name: user.firstName + ' ' + user.lastName, email: user.email })}
{:else}
{m.preview_the_oidc_data_that_would_be_sent_for_this_user()}
{/if}
</Dialog.Description>
</Dialog.Header>
<div class="overflow-auto px-4">
{#if loadingPreview}
<div class="flex items-center justify-center py-12">
<div class="h-8 w-8 animate-spin rounded-full border-b-2 border-gray-900"></div>
</div>
{/if}
<div class="flex justify-start gap-3">
<div>
<Label class="text-sm font-medium">{m.users()}</Label>
<div>
<SearchableSelect
class="w-48"
selectText={m.select_user()}
isLoading={isUserSearchLoading}
items={Object.values(users).map((user) => ({
value: user.id,
label: user.username
}))}
value={user?.id || ''}
oninput={(e) => onUserSearch(e.currentTarget.value)}
onSelect={(value) => {
user = users.find((u) => u.id === value) || null;
loadPreviewData();
}}
/>
</div>
</div>
<div>
<Label class="text-sm font-medium">Scopes</Label>
<MultiSelect
items={[
{ value: 'openid', label: 'openid' },
{ value: 'email', label: 'email' },
{ value: 'profile', label: 'profile' },
{ value: 'groups', label: 'groups' }
]}
bind:selectedItems={scopes}
/>
</div>
</div>
{#if errorMessage && !loadingPreview}
<Alert.Root variant="destructive" class="mt-5 mb-6">
<LucideAlertTriangle class="h-4 w-4" />
<Alert.Title>{m.error()}</Alert.Title>
<Alert.Description>
{errorMessage}
</Alert.Description>
</Alert.Root>
{/if}
{#if previewData && !loadingPreview}
<Tabs.Root value="id-token" class="mt-5 w-full">
<Tabs.List class="mb-6 grid w-full grid-cols-3">
<Tabs.Trigger value="id-token">{m.id_token()}</Tabs.Trigger>
<Tabs.Trigger value="access-token">{m.access_token()}</Tabs.Trigger>
<Tabs.Trigger value="userinfo">{m.userinfo()}</Tabs.Trigger>
</Tabs.List>
<Tabs.Content value="id-token">
{@render tabContent(previewData.idToken, m.id_token_payload())}
</Tabs.Content>
<Tabs.Content value="access-token" class="mt-4">
{@render tabContent(previewData.accessToken, m.access_token_payload())}
</Tabs.Content>
<Tabs.Content value="userinfo" class="mt-4">
{@render tabContent(previewData.userInfo, m.userinfo_endpoint_response())}
</Tabs.Content>
</Tabs.Root>
{/if}
</div>
</Dialog.Content>
</Dialog.Root>
{#snippet tabContent(data: any, title: string)}
<div class="space-y-4">
<div class="mb-6 flex items-center justify-between">
<Label class="text-lg font-semibold">{title}</Label>
<CopyToClipboard value={JSON.stringify(data, null, 2)}>
<Button size="sm" variant="outline">{m.copy_all()}</Button>
</CopyToClipboard>
</div>
<div class="space-y-3">
{#each Object.entries(data || {}) as [key, value]}
<div class="grid grid-cols-1 items-start gap-4 border-b pb-3 md:grid-cols-[200px_1fr]">
<Label class="pt-1 text-sm font-medium">{key}</Label>
<div class="min-w-0">
<CopyToClipboard value={typeof value === 'string' ? value : JSON.stringify(value)}>
<div
class="text-muted-foreground bg-muted/30 hover:bg-muted/50 cursor-pointer rounded px-3 py-2 font-mono text-sm"
>
{typeof value === 'object' ? JSON.stringify(value, null, 2) : value}
</div>
</CopyToClipboard>
</div>
</div>
{/each}
</div>
</div>
{/snippet}

View File

@@ -6,7 +6,7 @@
import type { UserGroupCreate } from '$lib/types/user-group.type';
import { preventDefault } from '$lib/utils/event-util';
import { createForm } from '$lib/utils/form-util';
import { z } from 'zod';
import { z } from 'zod/v4';
let {
callback,

View File

@@ -7,7 +7,7 @@
import type { User, UserCreate } from '$lib/types/user.type';
import { preventDefault } from '$lib/utils/event-util';
import { createForm } from '$lib/utils/form-util';
import { z } from 'zod';
import { z } from 'zod/v4';
let {
callback,
@@ -37,7 +37,7 @@
.min(2)
.max(30)
.regex(/^[a-z0-9_@.-]+$/, m.username_can_only_contain()),
email: z.string().email(),
email: z.email(),
isAdmin: z.boolean(),
disabled: z.boolean()
});

View File

@@ -4,6 +4,29 @@ if [ ! -f .version ] || [ ! -f frontend/package.json ] || [ ! -f CHANGELOG.md ];
exit 1
fi
# Check if conventional-changelog is installed, if not install it
if ! command -v conventional-changelog &>/dev/null; then
echo "conventional-changelog not found, installing..."
npm install -g conventional-changelog-cli
# Verify installation was successful
if ! command -v conventional-changelog &>/dev/null; then
echo "Error: Failed to install conventional-changelog-cli."
exit 1
fi
fi
# Check if GitHub CLI is installed
if ! command -v gh &>/dev/null; then
echo "Error: GitHub CLI (gh) is not installed. Please install it and authenticate using 'gh auth login'."
exit 1
fi
# Check if we're on the main branch
if [ "$(git rev-parse --abbrev-ref HEAD)" != "main" ]; then
echo "Error: This script must be run on the main branch."
exit 1
fi
# Read the current version from .version
VERSION=$(cat .version)
@@ -88,12 +111,6 @@ git add .version
jq --arg new_version "$NEW_VERSION" '.version = $new_version' frontend/package.json >frontend/package_tmp.json && mv frontend/package_tmp.json frontend/package.json
git add frontend/package.json
# Check if conventional-changelog is installed, if not install it
if ! command -v conventional-changelog &>/dev/null; then
echo "conventional-changelog not found, installing..."
npm install -g conventional-changelog-cli
fi
# Generate changelog
echo "Generating changelog..."
conventional-changelog -p conventionalcommits -i CHANGELOG.md -s
@@ -109,12 +126,6 @@ git tag "v$NEW_VERSION"
git push
git push --tags
# Check if GitHub CLI is installed
if ! command -v gh &>/dev/null; then
echo "GitHub CLI (gh) is not installed. Please install it and authenticate using 'gh auth login'."
exit 1
fi
# Extract the changelog content for the latest release
echo "Extracting changelog content for version $NEW_VERSION..."
CHANGELOG=$(awk '/^## / {if (NR > 1) exit} NR > 1 {print}' CHANGELOG.md | awk 'NR > 2 || NF {print}')

View File

@@ -35,6 +35,17 @@ export const oidcClients = {
callbackUrl: 'http://immich/auth/callback',
secret: 'PYjrE9u4v9GVqXKi52eur0eb2Ci4kc0x'
},
federated: {
id: "c48232ff-ff65-45ed-ae96-7afa8a9b443b",
name: 'Federated',
callbackUrl: 'http://federated/auth/callback',
federatedJWT: {
issuer: 'https://external-idp.local',
audience: 'api://PocketID',
subject: 'c48232ff-ff65-45ed-ae96-7afa8a9b443b',
},
accessCodes: ['federated']
},
pingvinShare: {
name: 'Pingvin Share',
callbackUrl: 'http://pingvin.share/auth/callback',
@@ -76,11 +87,13 @@ export const refreshTokens = [
{
token: 'ou87UDg249r1StBLYkMEqy9TXDbV5HmGuDpMcZDo',
clientId: oidcClients.nextcloud.id,
userId: 'f4b89dc2-62fb-46bf-9f5f-c34f4eafe93e',
expired: false
},
{
token: 'X4vqwtRyCUaq51UafHea4Fsg8Km6CAns6vp3tuX4',
clientId: oidcClients.nextcloud.id,
userId: 'f4b89dc2-62fb-46bf-9f5f-c34f4eafe93e',
expired: true
}
];

View File

@@ -7,6 +7,7 @@
"devDependencies": {
"@playwright/test": "^1.52.0",
"@types/node": "^22.15.21",
"dotenv": "^16.5.0",
"jose": "^6.0.11"
}
},
@@ -36,6 +37,19 @@
"undici-types": "~6.21.0"
}
},
"node_modules/dotenv": {
"version": "16.5.0",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.5.0.tgz",
"integrity": "sha512-m/C+AwOAr9/W1UOIZUo232ejMNnJAJtYQjUbHoNTBNTJSvqzzDh7vnrei3o3r3m9blf6ZoDkvcw0VmozNRFJxg==",
"dev": true,
"license": "BSD-2-Clause",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://dotenvx.com"
}
},
"node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",

View File

@@ -3,6 +3,7 @@
"devDependencies": {
"@playwright/test": "^1.52.0",
"@types/node": "^22.15.21",
"jose": "^6.0.11"
"jose": "^6.0.11",
"dotenv": "^16.5.0"
}
}

View File

@@ -1,30 +1,31 @@
import { defineConfig, devices } from '@playwright/test';
import { defineConfig, devices } from "@playwright/test";
import "dotenv/config";
/**
* See https://playwright.dev/docs/test-configuration.
*/
export default defineConfig({
outputDir: './.output',
timeout: 10000,
testDir: './specs',
fullyParallel: false,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 1 : 0,
workers: 1,
reporter: process.env.CI
? [['html', { outputFolder: '.report' }], ['github']]
: [['line'], ['html', { open: 'never', outputFolder: '.report' }]],
use: {
baseURL: process.env.APP_URL ?? 'http://localhost:1411',
video: 'retain-on-failure',
trace: 'on-first-retry'
},
projects: [
{ name: 'setup', testMatch: /.*\.setup\.ts/ },
{
name: 'chromium',
use: { ...devices['Desktop Chrome'], storageState: '.auth/user.json' },
dependencies: ['setup']
}
]
outputDir: "./.output",
timeout: 10000,
testDir: "./specs",
fullyParallel: false,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 1 : 0,
workers: 1,
reporter: process.env.CI
? [["html", { outputFolder: ".report" }], ["github"]]
: [["line"], ["html", { open: "never", outputFolder: ".report" }]],
use: {
baseURL: process.env.APP_URL ?? "http://localhost:1411",
video: "retain-on-failure",
trace: "on-first-retry",
},
projects: [
{ name: "setup", testMatch: /.*\.setup\.ts/ },
{
name: "chromium",
use: { ...devices["Desktop Chrome"], storageState: ".auth/user.json" },
dependencies: ["setup"],
},
],
});

View File

@@ -57,11 +57,20 @@ test("Change Locale", async ({ page }) => {
// Check if th language heading now says 'Taal' instead of 'Language'
await expect(page.getByText("Taal", { exact: true })).toBeVisible();
// Check if the validation messages are translated because they are provided by Zod
await page.getByRole("textbox", { name: "Voornaam" }).fill("");
await page.getByRole("button", { name: "Opslaan" }).click();
await expect(page.getByText("Te kort: verwacht dat string")).toBeVisible();
// Clear all cookies and sign in again to check if the language is still set to Dutch
await page.context().clearCookies();
await authUtil.authenticate(page);
await expect(page.getByText("Taal", { exact: true })).toBeVisible();
await page.getByRole("textbox", { name: "Voornaam" }).fill("");
await page.getByRole("button", { name: "Opslaan" }).click();
await expect(page.getByText("Te kort: verwacht dat string")).toBeVisible();
});
test("Add passkey to an account", async ({ page }) => {

View File

@@ -4,6 +4,8 @@ import { cleanupBackend } from '../utils/cleanup.util';
test.beforeEach(cleanupBackend);
test.describe('LDAP Integration', () => {
test.skip(process.env.SKIP_LDAP_TESTS === "true", 'Skipping LDAP tests due to SKIP_LDAP_TESTS environment variable');
test('LDAP configuration is working properly', async ({ page }) => {
await page.goto('/settings/admin/application-configuration');

View File

@@ -2,7 +2,7 @@ import test, { expect } from "@playwright/test";
import { oidcClients, refreshTokens, users } from "../data";
import { cleanupBackend } from "../utils/cleanup.util";
import { generateIdToken, generateOauthAccessToken } from "../utils/jwt.util";
import oidcUtil from "../utils/oidc.util";
import * as oidcUtil from "../utils/oidc.util";
import passkeyUtil from "../utils/passkey.util";
test.beforeEach(cleanupBackend);
@@ -156,11 +156,21 @@ test("End session with id token hint redirects to callback URL", async ({
test("Successfully refresh tokens with valid refresh token", async ({
request,
}) => {
const { token, clientId } = refreshTokens.filter(
const { token, clientId, userId } = refreshTokens.filter(
(token) => !token.expired
)[0];
const clientSecret = "w2mUeZISmEvIDMEDvpY0PnxQIpj1m3zY";
// Sign the refresh token
const refreshToken = await request.post("/api/test/refreshtoken", {
data: {
rt: token,
client: clientId,
user: userId,
}
}).then((r) => r.text())
// Perform the exchange
const refreshResponse = await request.post("/api/oidc/token", {
headers: {
"Content-Type": "application/x-www-form-urlencoded",
@@ -168,7 +178,7 @@ test("Successfully refresh tokens with valid refresh token", async ({
form: {
grant_type: "refresh_token",
client_id: clientId,
refresh_token: token,
refresh_token: refreshToken,
client_secret: clientSecret,
},
});
@@ -184,26 +194,25 @@ test("Successfully refresh tokens with valid refresh token", async ({
expect(tokenData.refresh_token).not.toBe(token);
});
test("Using refresh token invalidates it for future use", async ({
test("Refresh token fails when used for the wrong client", async ({
request,
}) => {
const { token, clientId } = refreshTokens.filter(
const { token, clientId, userId } = refreshTokens.filter(
(token) => !token.expired
)[0];
const clientSecret = "w2mUeZISmEvIDMEDvpY0PnxQIpj1m3zY";
await request.post("/api/oidc/token", {
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
form: {
grant_type: "refresh_token",
client_id: clientId,
refresh_token: token,
client_secret: clientSecret,
},
});
// Sign the refresh token
const refreshToken = await request.post("/api/test/refreshtoken", {
data: {
rt: token,
client: 'bad-client',
user: userId,
}
}).then((r) => r.text())
// Perform the exchange
const refreshResponse = await request.post("/api/oidc/token", {
headers: {
"Content-Type": "application/x-www-form-urlencoded",
@@ -211,7 +220,86 @@ test("Using refresh token invalidates it for future use", async ({
form: {
grant_type: "refresh_token",
client_id: clientId,
refresh_token: token,
refresh_token: refreshToken,
client_secret: clientSecret,
},
});
expect(refreshResponse.status()).toBe(400);
});
test("Refresh token fails when used for the wrong user", async ({
request,
}) => {
const { token, clientId } = refreshTokens.filter(
(token) => !token.expired
)[0];
const clientSecret = "w2mUeZISmEvIDMEDvpY0PnxQIpj1m3zY";
// Sign the refresh token
const refreshToken = await request.post("/api/test/refreshtoken", {
data: {
rt: token,
client: clientId,
user: 'bad-user',
}
}).then((r) => r.text())
// Perform the exchange
const refreshResponse = await request.post("/api/oidc/token", {
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
form: {
grant_type: "refresh_token",
client_id: clientId,
refresh_token: refreshToken,
client_secret: clientSecret,
},
});
expect(refreshResponse.status()).toBe(400);
});
test("Using refresh token invalidates it for future use", async ({
request,
}) => {
const { token, clientId, userId } = refreshTokens.filter(
(token) => !token.expired
)[0];
const clientSecret = "w2mUeZISmEvIDMEDvpY0PnxQIpj1m3zY";
// Sign the refresh token
const refreshToken = await request.post("/api/test/refreshtoken", {
data: {
rt: token,
client: clientId,
user: userId,
}
}).then((r) => r.text())
// Perform the exchange
await request.post("/api/oidc/token", {
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
form: {
grant_type: "refresh_token",
client_id: clientId,
refresh_token: refreshToken,
client_secret: clientSecret,
},
});
// Try again
const refreshResponse = await request.post("/api/oidc/token", {
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
form: {
grant_type: "refresh_token",
client_id: clientId,
refresh_token: refreshToken,
client_secret: clientSecret,
},
});
@@ -219,11 +307,10 @@ test("Using refresh token invalidates it for future use", async ({
});
test.describe("Introspection endpoint", () => {
const client = oidcClients.nextcloud;
test("without client_id and client_secret fails", async ({ request }) => {
test("fails without client credentials", async ({ request }) => {
const validAccessToken = await generateOauthAccessToken(
users.tim,
client.id
oidcClients.nextcloud.id
);
const introspectionResponse = await request.post("/api/oidc/introspect", {
headers: {
@@ -237,20 +324,20 @@ test.describe("Introspection endpoint", () => {
expect(introspectionResponse.status()).toBe(400);
});
test("with client_id and client_secret succeeds", async ({
test("succeeds with client credentials", async ({
request,
baseURL,
}) => {
const validAccessToken = await generateOauthAccessToken(
users.tim,
client.id
oidcClients.nextcloud.id
);
const introspectionResponse = await request.post("/api/oidc/introspect", {
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Authorization:
"Basic " +
Buffer.from(`${client.id}:${client.secret}`).toString("base64"),
Buffer.from(`${oidcClients.nextcloud.id}:${oidcClients.nextcloud.secret}`).toString("base64"),
},
form: {
token: validAccessToken,
@@ -266,18 +353,102 @@ test.describe("Introspection endpoint", () => {
expect(introspectionBody.aud).toStrictEqual([oidcClients.nextcloud.id]);
});
test("succeeds with federated client credentials", async ({
page,
request,
baseURL,
}) => {
const validAccessToken = await generateOauthAccessToken(
users.tim,
oidcClients.federated.id
);
const clientAssertion = await oidcUtil.getClientAssertion(page, oidcClients.federated.federatedJWT);
const introspectionResponse = await request.post("/api/oidc/introspect", {
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Authorization: "Bearer " + clientAssertion,
},
form: {
token: validAccessToken,
},
});
expect(introspectionResponse.status()).toBe(200);
const introspectionBody = await introspectionResponse.json();
expect(introspectionBody.active).toBe(true);
expect(introspectionBody.token_type).toBe("access_token");
expect(introspectionBody.iss).toBe(baseURL);
expect(introspectionBody.sub).toBe(users.tim.id);
expect(introspectionBody.aud).toStrictEqual([oidcClients.federated.id]);
});
test("fails with client credentials for wrong app", async ({
request,
}) => {
const validAccessToken = await generateOauthAccessToken(
users.tim,
oidcClients.nextcloud.id
);
const introspectionResponse = await request.post("/api/oidc/introspect", {
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Authorization:
"Basic " +
Buffer.from(`${oidcClients.immich.id}:${oidcClients.immich.secret}`).toString("base64"),
},
form: {
token: validAccessToken,
},
});
expect(introspectionResponse.status()).toBe(400);
});
test("fails with federated credentials for wrong app", async ({
page,
request,
}) => {
const validAccessToken = await generateOauthAccessToken(
users.tim,
oidcClients.nextcloud.id
);
const clientAssertion = await oidcUtil.getClientAssertion(page, oidcClients.federated.federatedJWT);
const introspectionResponse = await request.post("/api/oidc/introspect", {
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Authorization: "Bearer " + clientAssertion,
},
form: {
token: validAccessToken,
},
});
expect(introspectionResponse.status()).toBe(400);
});
test("non-expired refresh_token can be verified", async ({ request }) => {
const { token } = refreshTokens.filter((token) => !token.expired)[0];
const { token, clientId, userId } = refreshTokens.filter(
(token) => !token.expired
)[0];
// Sign the refresh token
const refreshToken = await request.post("/api/test/refreshtoken", {
data: {
rt: token,
client: clientId,
user: userId,
}
}).then((r) => r.text())
const introspectionResponse = await request.post("/api/oidc/introspect", {
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Authorization:
"Basic " +
Buffer.from(`${client.id}:${client.secret}`).toString("base64"),
Buffer.from(`${oidcClients.nextcloud.id}:${oidcClients.nextcloud.secret}`).toString("base64"),
},
form: {
token: token,
token: refreshToken,
},
});
@@ -288,17 +459,28 @@ test.describe("Introspection endpoint", () => {
});
test("expired refresh_token can be verified", async ({ request }) => {
const { token } = refreshTokens.filter((token) => token.expired)[0];
const { token, clientId, userId } = refreshTokens.filter(
(token) => token.expired
)[0];
// Sign the refresh token
const refreshToken = await request.post("/api/test/refreshtoken", {
data: {
rt: token,
client: clientId,
user: userId,
}
}).then((r) => r.text())
const introspectionResponse = await request.post("/api/oidc/introspect", {
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Authorization:
"Basic " +
Buffer.from(`${client.id}:${client.secret}`).toString("base64"),
Buffer.from(`${oidcClients.nextcloud.id}:${oidcClients.nextcloud.secret}`).toString("base64"),
},
form: {
token: token,
token: refreshToken,
},
});
@@ -310,7 +492,7 @@ test.describe("Introspection endpoint", () => {
test("expired access_token can't be verified", async ({ request }) => {
const expiredAccessToken = await generateOauthAccessToken(
users.tim,
client.id,
oidcClients.nextcloud.id,
true
);
const introspectionResponse = await request.post("/api/oidc/introspect", {
@@ -449,3 +631,40 @@ test("Authorize new client with device authorization with user group not allowed
.filter({ hasText: "You're not allowed to access this service." })
).toBeVisible();
});
test("Federated identity fails with invalid client assertion", async ({
page,
}) => {
const client = oidcClients.federated;
const res = await oidcUtil.exchangeCode(page, {
client_assertion_type: 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer',
grant_type: 'authorization_code',
redirect_uri: client.callbackUrl,
code: client.accessCodes[0],
client_id: client.id,
client_assertion:'not-an-assertion',
});
expect(res?.error).toBe('Invalid client assertion');
});
test("Authorize existing client with federated identity", async ({
page,
}) => {
const client = oidcClients.federated;
const clientAssertion = await oidcUtil.getClientAssertion(page, client.federatedJWT);
const res = await oidcUtil.exchangeCode(page, {
client_assertion_type: 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer',
grant_type: 'authorization_code',
redirect_uri: client.callbackUrl,
code: client.accessCodes[0],
client_id: client.id,
client_assertion: clientAssertion,
});
expect(res.access_token).not.toBeNull;
expect(res.expires_in).not.toBeNull;
expect(res.token_type).toBe('Bearer');
});

View File

@@ -1,5 +1,6 @@
{
"compilerOptions": {
"baseUrl": "."
"baseUrl": ".",
"lib": ["ES2022"]
}
}

View File

@@ -1,12 +1,15 @@
import playwrightConfig from "../playwright.config";
export async function cleanupBackend() {
const response = await fetch(
playwrightConfig.use!.baseURL + "/api/test/reset",
{
method: "POST",
}
);
const url = new URL("/api/test/reset", playwrightConfig.use!.baseURL);
if (process.env.SKIP_LDAP_TESTS === "true") {
url.searchParams.append("skip-ldap", "true");
}
const response = await fetch(url, {
method: "POST",
});
if (!response.ok) {
throw new Error(

View File

@@ -1,7 +1,7 @@
import type { Page } from '@playwright/test';
async function getUserCode(page: Page, clientId: string, clientSecret: string) {
const response = await page.request
export async function getUserCode(page: Page, clientId: string, clientSecret: string): Promise<string> {
return page.request
.post('/api/oidc/device/authorize', {
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
@@ -12,11 +12,29 @@ async function getUserCode(page: Page, clientId: string, clientSecret: string) {
scope: 'openid profile email'
}
})
.then((r) => r.json());
return response.user_code;
.then((r) => r.json())
.then((r) => r.user_code);
}
export default {
getUserCode
};
export async function exchangeCode(page: Page, params: Record<string,string>): Promise<{access_token?: string, token_type?: string, expires_in?: number, error?: string}> {
return page.request
.post('/api/oidc/token', {
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
},
form: params,
})
.then((r) => r.json());
}
export async function getClientAssertion(page: Page, data: {issuer: string, audience: string, subject: string}): Promise<string> {
return page.request
.post('/api/externalidp/sign', {
data: {
iss: data.issuer,
aud: data.audience,
sub: data.subject,
},
})
.then((r) => r.text());
}