mirror of
https://github.com/pocket-id/pocket-id.git
synced 2025-12-10 23:22:57 +03:00
Compare commits
33 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9ce82fb205 | ||
|
|
2935236ace | ||
|
|
c821b675b8 | ||
|
|
a09d529027 | ||
|
|
b62b61fb01 | ||
|
|
df5c1ed1f8 | ||
|
|
f4af35f86b | ||
|
|
657a51f7ed | ||
|
|
575b2f71e9 | ||
|
|
97f7326da4 | ||
|
|
242d87a54b | ||
|
|
c111b79147 | ||
|
|
61bf14225b | ||
|
|
c1e98411b6 | ||
|
|
b25e95fc4a | ||
|
|
3cc82d8522 | ||
|
|
ea4e48680c | ||
|
|
f403eed12c | ||
|
|
388a874922 | ||
|
|
9a4aab465a | ||
|
|
a052cd6619 | ||
|
|
31a803b243 | ||
|
|
1d2e41c04e | ||
|
|
b650d6d423 | ||
|
|
156aad3057 | ||
|
|
05bfe00924 | ||
|
|
035b2c022b | ||
|
|
61b62d4612 | ||
|
|
dc5d7bb2f3 | ||
|
|
5e9096e328 | ||
|
|
34b4ba514f | ||
|
|
d217083059 | ||
|
|
bdcef60cab |
78
.github/workflows/build-next.yml
vendored
Normal file
78
.github/workflows/build-next.yml
vendored
Normal 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
1
.gitignore
vendored
@@ -10,6 +10,7 @@ node_modules
|
||||
/frontend/build
|
||||
/backend/bin
|
||||
pocket-id
|
||||
/tests/test-results/*.json
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
|
||||
28
CHANGELOG.md
28
CHANGELOG.md
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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)
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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" }
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
365
backend/internal/service/oidc_service_test.go
Normal file
365
backend/internal/service/oidc_service_test.go
Normal 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)
|
||||
})
|
||||
})
|
||||
}
|
||||
97
backend/internal/service/testutils_test.go
Normal file
97
backend/internal/service/testutils_test.go
Normal 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),
|
||||
}
|
||||
}
|
||||
18
backend/internal/utils/http_util.go
Normal file
18
backend/internal/utils/http_util.go
Normal 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
|
||||
}
|
||||
65
backend/internal/utils/http_util_test.go
Normal file
65
backend/internal/utils/http_util_test.go
Normal 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)
|
||||
})
|
||||
}
|
||||
}
|
||||
69
backend/internal/utils/jwk_util.go
Normal file
69
backend/internal/utils/jwk_util.go
Normal 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())
|
||||
}
|
||||
}
|
||||
20
backend/internal/utils/jwt_util.go
Normal file
20
backend/internal/utils/jwt_util.go
Normal 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
|
||||
}
|
||||
@@ -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
@@ -0,0 +1 @@
|
||||
ALTER TABLE oidc_clients DROP COLUMN credentials;
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE oidc_clients ADD COLUMN credentials JSONB NULL;
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE oidc_clients DROP COLUMN credentials;
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE oidc_clients ADD COLUMN credentials TEXT NULL;
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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
375
frontend/messages/da.json
Normal 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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
375
frontend/messages/zh-TW.json
Normal file
375
frontend/messages/zh-TW.json
Normal 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"
|
||||
}
|
||||
182
frontend/package-lock.json
generated
182
frontend/package-lock.json
generated
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
@@ -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}
|
||||
|
||||
56
frontend/src/lib/components/form/multi-select.svelte
Normal file
56
frontend/src/lib/components/form/multi-select.svelte
Normal 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>
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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 & {
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
10
frontend/src/lib/utils/locale.util.ts
Normal file
10
frontend/src/lib/utils/locale.util.ts
Normal 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 });
|
||||
});
|
||||
}
|
||||
25
frontend/src/lib/utils/redirection-util.ts
Normal file
25
frontend/src/lib/utils/redirection-util.ts
Normal 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';
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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())
|
||||
});
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
});
|
||||
|
||||
@@ -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}')
|
||||
|
||||
@@ -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
|
||||
}
|
||||
];
|
||||
|
||||
14
tests/package-lock.json
generated
14
tests/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -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');
|
||||
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"baseUrl": "."
|
||||
"baseUrl": ".",
|
||||
"lib": ["ES2022"]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
Reference in New Issue
Block a user