Compare commits

...

18 Commits

Author SHA1 Message Date
Elias Schneider
5dcf69e974 release: 0.45.0 2025-03-30 00:12:19 +01:00
Alessandro (Ale) Segala
519d58d88c fix: use WAL for SQLite by default and set busy_timeout (#388)
Co-authored-by: Kyle Mendell <kmendell@ofkm.us>
2025-03-29 23:12:48 +01:00
Alessandro (Ale) Segala
b3b43a56af refactor: do not include test controller in production builds (#402)
Co-authored-by: Elias Schneider <login@eliasschneider.com>
2025-03-29 22:11:25 +00:00
Elias Schneider
fc68cf7eb2 chore(translations): add Brazilian Portuguese 2025-03-29 23:03:18 +01:00
Elias Schneider
8ca7873802 chore(translations): update translations via Crowdin (#394)
Co-authored-by: Kyle Mendell <kmendell@ofkm.us>
2025-03-29 22:59:24 +01:00
Elias Schneider
591bf841f5 Merge remote-tracking branch 'origin/main' 2025-03-29 22:56:04 +01:00
Kyle Mendell
8f8884d208 refactor: add swagger title and version info (#399) 2025-03-29 21:55:47 +00:00
Elias Schneider
7e658276f0 fix: ldap users aren't deleted if removed from ldap server 2025-03-29 22:55:44 +01:00
Gutyina Gergő
583a1f8fee chore(deps): install inlang plugins from npm (#401)
Co-authored-by: Kyle Mendell <kmendell@ofkm.us>
2025-03-29 22:50:51 +01:00
Rich
b935a4824a ci/cd: migrate backend linter to v2. fixed unit test workflow (#400) 2025-03-28 04:00:55 -05:00
Elias Schneider
cbd1bbdf74 fix: use value receiver for AuditLogData 2025-03-27 22:41:19 +01:00
Alessandro (Ale) Segala
96876a99c5 feat: add support for ECDSA and EdDSA keys (#359)
Co-authored-by: Elias Schneider <login@eliasschneider.com>
2025-03-27 18:20:39 +01:00
Elias Schneider
5c198c280c refactor: fix code smells 2025-03-27 17:46:10 +01:00
Elias Schneider
c9e0073b63 refactor: fix code smells 2025-03-27 16:48:36 +01:00
Elias Schneider
6fa26c97be ci/cd: run linter only on backend changes 2025-03-27 16:18:15 +01:00
Elias Schneider
6746dbf41e chore(translations): update translations via Crowdin (#386) 2025-03-27 15:15:22 +00:00
Rich
4ac1196d8d ci/cd: add basic static analysis for backend (#389) 2025-03-27 16:13:56 +01:00
Sam
4d049bbe24 docs: update .env.example to reflect the new documentation location (#385) 2025-03-25 21:53:23 +00:00
74 changed files with 2026 additions and 521 deletions

View File

@@ -1,4 +1,4 @@
# See the README for more information: https://github.com/pocket-id/pocket-id?tab=readme-ov-file#environment-variables
# See the documentation for more information: https://pocket-id.org/docs/configuration/environment-variables
PUBLIC_APP_URL=http://localhost
TRUST_PROXY=false
MAXMIND_LICENSE_KEY=

39
.github/workflows/backend-linter.yml vendored Normal file
View File

@@ -0,0 +1,39 @@
name: Run Backend Linter
on:
push:
branches: [main]
paths:
- "backend/**"
pull_request:
branches: [main]
paths:
- "backend/**"
permissions:
# Required: allow read access to the content for analysis.
contents: read
# Optional: allow read access to pull request. Use with `only-new-issues` option.
pull-requests: read
# Optional: allow write access to checks to allow the action to annotate code in the PR.
checks: write
jobs:
golangci-lint:
name: Run Golangci-lint
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v4
with:
go-version-file: backend/go.mod
- name: Run Golangci-lint
uses: golangci/golangci-lint-action@dec74fa03096ff515422f71d18d41307cacde373 # v7.0.0
with:
version: v2.0.2
working-directory: backend
only-new-issues: ${{ github.event_name == 'pull_request' }}

View File

@@ -27,6 +27,7 @@ jobs:
with:
tags: pocket-id/pocket-id:test
outputs: type=docker,dest=/tmp/docker-image.tar
build-args: BUILD_TAGS=e2etest
- name: Upload Docker image artifact
uses: actions/upload-artifact@v4
@@ -51,6 +52,7 @@ jobs:
with:
name: docker-image
path: /tmp
- name: Load Docker Image
run: docker load -i /tmp/docker-image.tar
@@ -69,6 +71,8 @@ jobs:
-e APP_ENV=test \
pocket-id/pocket-id:test
docker logs -f pocket-id-sqlite &> /tmp/backend.log &
- name: Run Playwright tests
working-directory: ./frontend
run: npx playwright test
@@ -81,6 +85,14 @@ jobs:
include-hidden-files: true
retention-days: 15
- uses: actions/upload-artifact@v4
if: always()
with:
name: backend-sqlite
path: /tmp/backend.log
include-hidden-files: true
retention-days: 15
test-postgres:
if: github.event.pull_request.head.ref != 'i18n_crowdin'
runs-on: ubuntu-latest
@@ -140,9 +152,11 @@ jobs:
-p 80:80 \
-e APP_ENV=test \
-e DB_PROVIDER=postgres \
-e POSTGRES_CONNECTION_STRING=postgresql://postgres:postgres@pocket-id-db:5432/pocket-id \
-e DB_CONNECTION_STRING=postgresql://postgres:postgres@pocket-id-db:5432/pocket-id \
pocket-id/pocket-id:test
docker logs -f pocket-id-postgres &> /tmp/backend.log &
- name: Run Playwright tests
working-directory: ./frontend
run: npx playwright test
@@ -154,3 +168,11 @@ jobs:
path: frontend/tests/.report
include-hidden-files: true
retention-days: 15
- uses: actions/upload-artifact@v4
if: always()
with:
name: backend-postgres
path: /tmp/backend.log
include-hidden-files: true
retention-days: 15

View File

@@ -2,11 +2,11 @@ name: Unit Tests
on:
push:
branches: [main]
paths:
paths:
- "backend/**"
pull_request:
branches: [main]
paths:
paths:
- "backend/**"
jobs:
@@ -25,6 +25,7 @@ jobs:
- name: Run backend unit tests
working-directory: backend
run: |
set -e -o pipefail
go test -v ./... | tee /tmp/TestResults.log
- uses: actions/upload-artifact@v4
if: always()

View File

@@ -1 +1 @@
0.44.0
0.45.0

3
.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,3 @@
{
"go.buildTags": "e2etest"
}

View File

@@ -1,3 +1,17 @@
## [](https://github.com/pocket-id/pocket-id/compare/v0.44.0...v) (2025-03-29)
### Features
* add support for ECDSA and EdDSA keys ([#359](https://github.com/pocket-id/pocket-id/issues/359)) ([96876a9](https://github.com/pocket-id/pocket-id/commit/96876a99c586508b72c27669ab200ff6a29db771))
### Bug Fixes
* ldap users aren't deleted if removed from ldap server ([7e65827](https://github.com/pocket-id/pocket-id/commit/7e658276f04d08a1f5117796e55d45e310204dab))
* use value receiver for `AuditLogData` ([cbd1bbd](https://github.com/pocket-id/pocket-id/commit/cbd1bbdf741eedd03e93598d67623c75c74b6212))
* use WAL for SQLite by default and set busy_timeout ([#388](https://github.com/pocket-id/pocket-id/issues/388)) ([519d58d](https://github.com/pocket-id/pocket-id/commit/519d58d88c906abc5139e35933bdeba0396c10a2))
## [](https://github.com/pocket-id/pocket-id/compare/v0.43.1...v) (2025-03-25)

View File

@@ -49,7 +49,7 @@ The backend is built with [Gin](https://gin-gonic.com) and written in Go.
1. Open the `backend` folder
2. Copy the `.env.example` file to `.env` and change the `APP_ENV` to `development`
3. Start the backend with `go run cmd/main.go`
3. Start the backend with `go run -tags e2etest ./cmd`
### Frontend

View File

@@ -1,3 +1,6 @@
# Tags passed to "go build"
ARG BUILD_TAGS=""
# Stage 1: Build Frontend
FROM node:22-alpine AS frontend-builder
WORKDIR /app/frontend
@@ -9,6 +12,7 @@ RUN npm prune --production
# Stage 2: Build Backend
FROM golang:1.23-alpine AS backend-builder
ARG BUILD_TAGS
WORKDIR /app/backend
COPY ./backend/go.mod ./backend/go.sum ./
RUN go mod download
@@ -17,7 +21,12 @@ RUN apk add --no-cache gcc musl-dev
COPY ./backend ./
WORKDIR /app/backend/cmd
RUN CGO_ENABLED=1 GOOS=linux go build -o /app/backend/pocket-id-backend .
RUN CGO_ENABLED=1 \
GOOS=linux \
go build \
-tags "${BUILD_TAGS}" \
-o /app/backend/pocket-id-backend \
.
# Stage 3: Production Image
FROM node:22-alpine
@@ -41,4 +50,4 @@ EXPOSE 80
ENV APP_ENV=production
ENTRYPOINT ["sh", "./scripts/docker/create-user.sh"]
CMD ["sh", "./scripts/docker/entrypoint.sh"]
CMD ["sh", "./scripts/docker/entrypoint.sh"]

64
backend/.golangci.yml Normal file
View File

@@ -0,0 +1,64 @@
version: "2"
run:
tests: true
timeout: 5m
linters:
default: none
enable:
- asasalint
- asciicheck
- bidichk
- bodyclose
- contextcheck
- copyloopvar
- durationcheck
- errcheck
- errchkjson
- errorlint
- exhaustive
- gocheckcompilerdirectives
- gochecksumtype
- gocognit
- gocritic
- gosec
- gosmopolitan
- govet
- ineffassign
- loggercheck
- makezero
- musttag
- nilerr
- nilnesserr
- noctx
- protogetter
- reassign
- recvcheck
- rowserrcheck
- spancheck
- sqlclosecheck
- staticcheck
- testifylint
- unused
- usestdlibvars
- zerologlint
exclusions:
generated: lax
presets:
- comments
- common-false-positives
- legacy
- std-error-handling
paths:
- third_party$
- builtin$
- examples$
- internal/service/test_service.go
formatters:
enable:
- goimports
exclusions:
generated: lax
paths:
- third_party$
- builtin$
- examples$

View File

@@ -1,6 +1,6 @@
module github.com/pocket-id/pocket-id/backend
go 1.23.1
go 1.23.7
require (
github.com/caarlos0/env/v11 v11.3.1
@@ -14,11 +14,10 @@ require (
github.com/go-ldap/ldap/v3 v3.4.10
github.com/go-playground/validator/v10 v10.24.0
github.com/go-webauthn/webauthn v0.11.2
github.com/golang-jwt/jwt/v5 v5.2.2
github.com/golang-migrate/migrate/v4 v4.18.2
github.com/google/uuid v1.6.0
github.com/joho/godotenv v1.5.1
github.com/lestrrat-go/jwx/v3 v3.0.0-alpha3
github.com/lestrrat-go/jwx/v3 v3.0.0-beta1
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
@@ -45,6 +44,7 @@ require (
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-webauthn/x v0.1.16 // indirect
github.com/goccy/go-json v0.10.4 // indirect
github.com/golang-jwt/jwt/v5 v5.2.2 // indirect
github.com/google/go-tpm v0.9.3 // indirect
github.com/hashicorp/errwrap v1.1.0 // indirect
github.com/hashicorp/go-multierror v1.1.1 // indirect

View File

@@ -145,8 +145,8 @@ github.com/lestrrat-go/httpcc v1.0.1 h1:ydWCStUeJLkpYyjLDHihupbn2tYmZ7m22BGkcvZZ
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-alpha3 h1:HHT8iW+UcPBgBr5A3soZQQsL5cBor/u6BkLB+wzY/R0=
github.com/lestrrat-go/jwx/v3 v3.0.0-alpha3/go.mod h1:ak32WoNtHE0aLowVWBcCvXngcAnW4tuC0YhFwOr/kwc=
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/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=

View File

@@ -8,6 +8,8 @@ import (
func Bootstrap() {
initApplicationImages()
migrateConfigDBConnstring()
db := newDatabase()
appConfigService := service.NewAppConfigService(db)

View File

@@ -0,0 +1,34 @@
package bootstrap
import (
"log"
"github.com/pocket-id/pocket-id/backend/internal/common"
)
// Performs the migration of the database connection string
// See: https://github.com/pocket-id/pocket-id/pull/388
func migrateConfigDBConnstring() {
switch common.EnvConfig.DbProvider {
case common.DbProviderSqlite:
// Check if we're using the deprecated SqliteDBPath env var
if common.EnvConfig.SqliteDBPath != "" {
connString := "file:" + common.EnvConfig.SqliteDBPath + "?_journal_mode=WAL&_busy_timeout=2500&_txlock=immediate"
common.EnvConfig.DbConnectionString = connString
common.EnvConfig.SqliteDBPath = ""
log.Printf("[WARN] Env var 'SQLITE_DB_PATH' is deprecated - use 'DB_CONNECTION_STRING' instead with the value: '%s'", connString)
}
case common.DbProviderPostgres:
// Check if we're using the deprecated PostgresConnectionString alias
if common.EnvConfig.PostgresConnectionString != "" {
common.EnvConfig.DbConnectionString = common.EnvConfig.PostgresConnectionString
common.EnvConfig.PostgresConnectionString = ""
log.Print("[WARN] Env var 'POSTGRES_CONNECTION_STRING' is deprecated - use 'DB_CONNECTION_STRING' instead with the same value")
}
default:
// We don't do anything here in the default case
// This is an error, but will be handled later on
}
}

View File

@@ -5,6 +5,7 @@ import (
"fmt"
"log"
"os"
"strings"
"time"
"github.com/golang-migrate/migrate/v4"
@@ -38,6 +39,7 @@ func newDatabase() (db *gorm.DB) {
case common.DbProviderPostgres:
driver, err = postgresMigrate.WithInstance(sqlDb, &postgresMigrate.Config{})
default:
// Should never happen at this point
log.Fatalf("unsupported database provider: %s", common.EnvConfig.DbProvider)
}
if err != nil {
@@ -56,17 +58,17 @@ func migrateDatabase(driver database.Driver) error {
// Use the embedded migrations
source, err := iofs.New(resources.FS, "migrations/"+string(common.EnvConfig.DbProvider))
if err != nil {
return fmt.Errorf("failed to create embedded migration source: %v", err)
return fmt.Errorf("failed to create embedded migration source: %w", err)
}
m, err := migrate.NewWithInstance("iofs", source, "pocket-id", driver)
if err != nil {
return fmt.Errorf("failed to create migration instance: %v", err)
return fmt.Errorf("failed to create migration instance: %w", err)
}
err = m.Up()
if err != nil && !errors.Is(err, migrate.ErrNoChange) {
return fmt.Errorf("failed to apply migrations: %v", err)
return fmt.Errorf("failed to apply migrations: %w", err)
}
return nil
@@ -78,9 +80,18 @@ func connectDatabase() (db *gorm.DB, err error) {
// Choose the correct database provider
switch common.EnvConfig.DbProvider {
case common.DbProviderSqlite:
dialector = sqlite.Open(common.EnvConfig.SqliteDBPath)
if common.EnvConfig.DbConnectionString == "" {
return nil, errors.New("missing required env var 'DB_CONNECTION_STRING' for SQLite database")
}
if !strings.HasPrefix(common.EnvConfig.DbConnectionString, "file:") {
return nil, errors.New("invalid value for env var 'DB_CONNECTION_STRING': does not begin with 'file:'")
}
dialector = sqlite.Open(common.EnvConfig.DbConnectionString)
case common.DbProviderPostgres:
dialector = postgres.Open(common.EnvConfig.PostgresConnectionString)
if common.EnvConfig.DbConnectionString == "" {
return nil, errors.New("missing required env var 'DB_CONNECTION_STRING' for Postgres database")
}
dialector = postgres.Open(common.EnvConfig.DbConnectionString)
default:
return nil, fmt.Errorf("unsupported database provider: %s", common.EnvConfig.DbProvider)
}
@@ -91,14 +102,14 @@ func connectDatabase() (db *gorm.DB, err error) {
Logger: getLogger(),
})
if err == nil {
break
} else {
log.Printf("Attempt %d: Failed to initialize database. Retrying...", i)
time.Sleep(3 * time.Second)
return db, nil
}
log.Printf("Attempt %d: Failed to initialize database. Retrying...", i)
time.Sleep(3 * time.Second)
}
return db, err
return nil, err
}
func getLogger() logger.Interface {

View File

@@ -0,0 +1,21 @@
//go:build e2etest
package bootstrap
import (
"github.com/gin-gonic/gin"
"gorm.io/gorm"
"github.com/pocket-id/pocket-id/backend/internal/controller"
"github.com/pocket-id/pocket-id/backend/internal/service"
)
// When building for E2E tests, add the e2etest controller
func init() {
registerTestControllers = []func(apiGroup *gin.RouterGroup, db *gorm.DB, appConfigService *service.AppConfigService, jwtService *service.JwtService){
func(apiGroup *gin.RouterGroup, db *gorm.DB, appConfigService *service.AppConfigService, jwtService *service.JwtService) {
testService := service.NewTestService(db, appConfigService, jwtService)
controller.NewTestController(apiGroup, testService)
},
}
}

View File

@@ -92,7 +92,10 @@ func loadKeyPEM(path string) (jwk.Key, error) {
if err != nil {
return nil, fmt.Errorf("failed to generate key ID: %w", err)
}
key.Set(jwk.KeyIDKey, keyId)
err = key.Set(jwk.KeyIDKey, keyId)
if err != nil {
return nil, fmt.Errorf("failed to set key ID: %w", err)
}
// Populate other required fields
_ = key.Set(jwk.KeyUsageKey, service.KeyUsageSigning)

View File

@@ -101,25 +101,25 @@ func TestLoadKeyPEM(t *testing.T) {
// Check key ID is set
var keyID string
err = key.Get(jwk.KeyIDKey, &keyID)
assert.NoError(t, err)
require.NoError(t, err)
assert.NotEmpty(t, keyID)
// Check algorithm is set
var alg jwa.SignatureAlgorithm
err = key.Get(jwk.AlgorithmKey, &alg)
assert.NoError(t, err)
require.NoError(t, err)
assert.NotEmpty(t, alg)
// Check key usage is set
var keyUsage string
err = key.Get(jwk.KeyUsageKey, &keyUsage)
assert.NoError(t, err)
require.NoError(t, err)
assert.Equal(t, service.KeyUsageSigning, keyUsage)
})
t.Run("file not found", func(t *testing.T) {
key, err := loadKeyPEM(filepath.Join(tempDir, "nonexistent.pem"))
assert.Error(t, err)
require.Error(t, err)
assert.Nil(t, key)
})
@@ -129,7 +129,7 @@ func TestLoadKeyPEM(t *testing.T) {
require.NoError(t, err)
key, err := loadKeyPEM(invalidPath)
assert.Error(t, err)
require.Error(t, err)
assert.Nil(t, key)
})
}

View File

@@ -16,6 +16,12 @@ import (
"gorm.io/gorm"
)
// This is used to register additional controllers for tests
var registerTestControllers []func(apiGroup *gin.RouterGroup, db *gorm.DB, appConfigService *service.AppConfigService, jwtService *service.JwtService)
// @title Pocket ID API
// @version 1
// @description API for Pocket ID
func initRouter(db *gorm.DB, appConfigService *service.AppConfigService) {
// Set the appropriate Gin mode based on the environment
switch common.EnvConfig.AppEnv {
@@ -43,7 +49,6 @@ func initRouter(db *gorm.DB, appConfigService *service.AppConfigService) {
userService := service.NewUserService(db, jwtService, auditLogService, emailService, appConfigService)
customClaimService := service.NewCustomClaimService(db)
oidcService := service.NewOidcService(db, jwtService, appConfigService, auditLogService, customClaimService)
testService := service.NewTestService(db, appConfigService, jwtService)
userGroupService := service.NewUserGroupService(db, appConfigService)
ldapService := service.NewLdapService(db, appConfigService, userService, userGroupService)
apiKeyService := service.NewApiKeyService(db)
@@ -75,7 +80,9 @@ func initRouter(db *gorm.DB, appConfigService *service.AppConfigService) {
// Add test controller in non-production environments
if common.EnvConfig.AppEnv != "production" {
controller.NewTestController(apiGroup, testService)
for _, f := range registerTestControllers {
f(apiGroup, db, appConfigService, jwtService)
}
}
// Set up base routes

View File

@@ -20,8 +20,9 @@ type EnvConfigSchema struct {
AppEnv string `env:"APP_ENV"`
AppURL string `env:"PUBLIC_APP_URL"`
DbProvider DbProvider `env:"DB_PROVIDER"`
SqliteDBPath string `env:"SQLITE_DB_PATH"`
PostgresConnectionString string `env:"POSTGRES_CONNECTION_STRING"`
DbConnectionString string `env:"DB_CONNECTION_STRING"`
SqliteDBPath string `env:"SQLITE_DB_PATH"` // Deprecated: use "DB_CONNECTION_STRING" instead
PostgresConnectionString string `env:"POSTGRES_CONNECTION_STRING"` // Deprecated: use "DB_CONNECTION_STRING" instead
UploadPath string `env:"UPLOAD_PATH"`
KeysPath string `env:"KEYS_PATH"`
Port string `env:"BACKEND_PORT"`
@@ -35,7 +36,8 @@ type EnvConfigSchema struct {
var EnvConfig = &EnvConfigSchema{
AppEnv: "production",
DbProvider: "sqlite",
SqliteDBPath: "data/pocket-id.db",
DbConnectionString: "file:data/pocket-id.db?_journal_mode=WAL&_busy_timeout=2500&_txlock=immediate",
SqliteDBPath: "",
PostgresConnectionString: "",
UploadPath: "data/uploads",
KeysPath: "data/keys",
@@ -56,12 +58,12 @@ func init() {
// Validate the environment variables
switch EnvConfig.DbProvider {
case DbProviderSqlite:
if EnvConfig.SqliteDBPath == "" {
log.Fatal("Missing SQLITE_DB_PATH environment variable")
if EnvConfig.DbConnectionString == "" {
log.Fatal("Missing required env var 'DB_CONNECTION_STRING' for SQLite database")
}
case DbProviderPostgres:
if EnvConfig.PostgresConnectionString == "" {
log.Fatal("Missing POSTGRES_CONNECTION_STRING environment variable")
if EnvConfig.DbConnectionString == "" {
log.Fatal("Missing required env var 'DB_CONNECTION_STRING' for Postgres database")
}
default:
log.Fatal("Invalid DB_PROVIDER value. Must be 'sqlite' or 'postgres'")

View File

@@ -49,19 +49,19 @@ func (c *ApiKeyController) listApiKeysHandler(ctx *gin.Context) {
var sortedPaginationRequest utils.SortedPaginationRequest
if err := ctx.ShouldBindQuery(&sortedPaginationRequest); err != nil {
ctx.Error(err)
_ = ctx.Error(err)
return
}
apiKeys, pagination, err := c.apiKeyService.ListApiKeys(userID, sortedPaginationRequest)
if err != nil {
ctx.Error(err)
_ = ctx.Error(err)
return
}
var apiKeysDto []dto.ApiKeyDto
if err := dto.MapStructList(apiKeys, &apiKeysDto); err != nil {
ctx.Error(err)
_ = ctx.Error(err)
return
}
@@ -83,19 +83,19 @@ func (c *ApiKeyController) createApiKeyHandler(ctx *gin.Context) {
var input dto.ApiKeyCreateDto
if err := ctx.ShouldBindJSON(&input); err != nil {
ctx.Error(err)
_ = ctx.Error(err)
return
}
apiKey, token, err := c.apiKeyService.CreateApiKey(userID, input)
if err != nil {
ctx.Error(err)
_ = ctx.Error(err)
return
}
var apiKeyDto dto.ApiKeyDto
if err := dto.MapStruct(apiKey, &apiKeyDto); err != nil {
ctx.Error(err)
_ = ctx.Error(err)
return
}
@@ -117,7 +117,7 @@ func (c *ApiKeyController) revokeApiKeyHandler(ctx *gin.Context) {
apiKeyID := ctx.Param("id")
if err := c.apiKeyService.RevokeApiKey(userID, apiKeyID); err != nil {
ctx.Error(err)
_ = ctx.Error(err)
return
}

View File

@@ -3,6 +3,7 @@ package controller
import (
"fmt"
"net/http"
"strconv"
"github.com/gin-gonic/gin"
"github.com/pocket-id/pocket-id/backend/internal/common"
@@ -62,13 +63,13 @@ type AppConfigController struct {
func (acc *AppConfigController) listAppConfigHandler(c *gin.Context) {
configuration, err := acc.appConfigService.ListAppConfig(false)
if err != nil {
c.Error(err)
_ = c.Error(err)
return
}
var configVariablesDto []dto.PublicAppConfigVariableDto
if err := dto.MapStructList(configuration, &configVariablesDto); err != nil {
c.Error(err)
_ = c.Error(err)
return
}
@@ -87,13 +88,13 @@ func (acc *AppConfigController) listAppConfigHandler(c *gin.Context) {
func (acc *AppConfigController) listAllAppConfigHandler(c *gin.Context) {
configuration, err := acc.appConfigService.ListAppConfig(true)
if err != nil {
c.Error(err)
_ = c.Error(err)
return
}
var configVariablesDto []dto.AppConfigVariableDto
if err := dto.MapStructList(configuration, &configVariablesDto); err != nil {
c.Error(err)
_ = c.Error(err)
return
}
@@ -113,19 +114,19 @@ func (acc *AppConfigController) listAllAppConfigHandler(c *gin.Context) {
func (acc *AppConfigController) updateAppConfigHandler(c *gin.Context) {
var input dto.AppConfigUpdateDto
if err := c.ShouldBindJSON(&input); err != nil {
c.Error(err)
_ = c.Error(err)
return
}
savedConfigVariables, err := acc.appConfigService.UpdateAppConfig(input)
if err != nil {
c.Error(err)
_ = c.Error(err)
return
}
var configVariablesDto []dto.AppConfigVariableDto
if err := dto.MapStructList(savedConfigVariables, &configVariablesDto); err != nil {
c.Error(err)
_ = c.Error(err)
return
}
@@ -143,7 +144,7 @@ func (acc *AppConfigController) updateAppConfigHandler(c *gin.Context) {
// @Success 200 {file} binary "Logo image"
// @Router /api/application-configuration/logo [get]
func (acc *AppConfigController) getLogoHandler(c *gin.Context) {
lightLogo := c.DefaultQuery("light", "true") == "true"
lightLogo, _ := strconv.ParseBool(c.DefaultQuery("light", "true"))
var imageName string
var imageType string
@@ -196,7 +197,7 @@ func (acc *AppConfigController) getBackgroundImageHandler(c *gin.Context) {
// @Security BearerAuth
// @Router /api/application-configuration/logo [put]
func (acc *AppConfigController) updateLogoHandler(c *gin.Context) {
lightLogo := c.DefaultQuery("light", "true") == "true"
lightLogo, _ := strconv.ParseBool(c.DefaultQuery("light", "true"))
var imageName string
var imageType string
@@ -224,13 +225,13 @@ func (acc *AppConfigController) updateLogoHandler(c *gin.Context) {
func (acc *AppConfigController) updateFaviconHandler(c *gin.Context) {
file, err := c.FormFile("file")
if err != nil {
c.Error(err)
_ = c.Error(err)
return
}
fileType := utils.GetFileExtension(file.Filename)
if fileType != "ico" {
c.Error(&common.WrongFileTypeError{ExpectedFileType: ".ico"})
_ = c.Error(&common.WrongFileTypeError{ExpectedFileType: ".ico"})
return
}
acc.updateImage(c, "favicon", "ico")
@@ -263,13 +264,13 @@ func (acc *AppConfigController) getImage(c *gin.Context, name string, imageType
func (acc *AppConfigController) updateImage(c *gin.Context, imageName string, oldImageType string) {
file, err := c.FormFile("file")
if err != nil {
c.Error(err)
_ = c.Error(err)
return
}
err = acc.appConfigService.UpdateImage(file, imageName, oldImageType)
if err != nil {
c.Error(err)
_ = c.Error(err)
return
}
@@ -286,7 +287,7 @@ func (acc *AppConfigController) updateImage(c *gin.Context, imageName string, ol
func (acc *AppConfigController) syncLdapHandler(c *gin.Context) {
err := acc.ldapService.SyncAll()
if err != nil {
c.Error(err)
_ = c.Error(err)
return
}
@@ -305,7 +306,7 @@ func (acc *AppConfigController) testEmailHandler(c *gin.Context) {
err := acc.emailService.SendTestEmail(userID)
if err != nil {
c.Error(err)
_ = c.Error(err)
return
}

View File

@@ -40,7 +40,7 @@ type AuditLogController struct {
func (alc *AuditLogController) listAuditLogsForUserHandler(c *gin.Context) {
var sortedPaginationRequest utils.SortedPaginationRequest
if err := c.ShouldBindQuery(&sortedPaginationRequest); err != nil {
c.Error(err)
_ = c.Error(err)
return
}
@@ -49,7 +49,7 @@ func (alc *AuditLogController) listAuditLogsForUserHandler(c *gin.Context) {
// Fetch audit logs for the user
logs, pagination, err := alc.auditLogService.ListAuditLogsForUser(userID, sortedPaginationRequest)
if err != nil {
c.Error(err)
_ = c.Error(err)
return
}
@@ -57,7 +57,7 @@ func (alc *AuditLogController) listAuditLogsForUserHandler(c *gin.Context) {
var logsDtos []dto.AuditLogDto
err = dto.MapStructList(logs, &logsDtos)
if err != nil {
c.Error(err)
_ = c.Error(err)
return
}

View File

@@ -43,7 +43,7 @@ type CustomClaimController struct {
func (ccc *CustomClaimController) getSuggestionsHandler(c *gin.Context) {
claims, err := ccc.customClaimService.GetSuggestions()
if err != nil {
c.Error(err)
_ = c.Error(err)
return
}
@@ -64,20 +64,20 @@ func (ccc *CustomClaimController) UpdateCustomClaimsForUserHandler(c *gin.Contex
var input []dto.CustomClaimCreateDto
if err := c.ShouldBindJSON(&input); err != nil {
c.Error(err)
_ = c.Error(err)
return
}
userId := c.Param("userId")
claims, err := ccc.customClaimService.UpdateCustomClaimsForUser(userId, input)
if err != nil {
c.Error(err)
_ = c.Error(err)
return
}
var customClaimsDto []dto.CustomClaimDto
if err := dto.MapStructList(claims, &customClaimsDto); err != nil {
c.Error(err)
_ = c.Error(err)
return
}
@@ -99,20 +99,20 @@ func (ccc *CustomClaimController) UpdateCustomClaimsForUserGroupHandler(c *gin.C
var input []dto.CustomClaimCreateDto
if err := c.ShouldBindJSON(&input); err != nil {
c.Error(err)
_ = c.Error(err)
return
}
userGroupId := c.Param("userGroupId")
claims, err := ccc.customClaimService.UpdateCustomClaimsForUserGroup(userGroupId, input)
if err != nil {
c.Error(err)
_ = c.Error(err)
return
}
var customClaimsDto []dto.CustomClaimDto
if err := dto.MapStructList(claims, &customClaimsDto); err != nil {
c.Error(err)
_ = c.Error(err)
return
}

View File

@@ -1,9 +1,12 @@
//go:build e2etest
package controller
import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/pocket-id/pocket-id/backend/internal/service"
)
@@ -19,22 +22,22 @@ type TestController struct {
func (tc *TestController) resetAndSeedHandler(c *gin.Context) {
if err := tc.TestService.ResetDatabase(); err != nil {
c.Error(err)
_ = c.Error(err)
return
}
if err := tc.TestService.ResetApplicationImages(); err != nil {
c.Error(err)
_ = c.Error(err)
return
}
if err := tc.TestService.SeedDatabase(); err != nil {
c.Error(err)
_ = c.Error(err)
return
}
if err := tc.TestService.ResetAppConfig(); err != nil {
c.Error(err)
_ = c.Error(err)
return
}

View File

@@ -65,13 +65,13 @@ type OidcController struct {
func (oc *OidcController) authorizeHandler(c *gin.Context) {
var input dto.AuthorizeOidcClientRequestDto
if err := c.ShouldBindJSON(&input); err != nil {
c.Error(err)
_ = c.Error(err)
return
}
code, callbackURL, err := oc.oidcService.Authorize(input, c.GetString("userID"), c.ClientIP(), c.Request.UserAgent())
if err != nil {
c.Error(err)
_ = c.Error(err)
return
}
@@ -96,13 +96,13 @@ func (oc *OidcController) authorizeHandler(c *gin.Context) {
func (oc *OidcController) authorizationConfirmationRequiredHandler(c *gin.Context) {
var input dto.AuthorizationRequiredDto
if err := c.ShouldBindJSON(&input); err != nil {
c.Error(err)
_ = c.Error(err)
return
}
hasAuthorizedClient, err := oc.oidcService.HasAuthorizedClient(input.ClientID, c.GetString("userID"), input.Scope)
if err != nil {
c.Error(err)
_ = c.Error(err)
return
}
@@ -128,19 +128,19 @@ func (oc *OidcController) createTokensHandler(c *gin.Context) {
var input dto.OidcCreateTokensDto
if err := c.ShouldBind(&input); err != nil {
c.Error(err)
_ = c.Error(err)
return
}
// Validate that code is provided for authorization_code grant type
if input.GrantType == "authorization_code" && input.Code == "" {
c.Error(&common.OidcMissingAuthorizationCodeError{})
_ = c.Error(&common.OidcMissingAuthorizationCodeError{})
return
}
// Validate that refresh_token is provided for refresh_token grant type
if input.GrantType == "refresh_token" && input.RefreshToken == "" {
c.Error(&common.OidcMissingRefreshTokenError{})
_ = c.Error(&common.OidcMissingRefreshTokenError{})
return
}
@@ -162,7 +162,7 @@ func (oc *OidcController) createTokensHandler(c *gin.Context) {
)
if err != nil {
c.Error(err)
_ = c.Error(err)
return
}
@@ -195,43 +195,36 @@ func (oc *OidcController) createTokensHandler(c *gin.Context) {
// @Security OAuth2AccessToken
// @Router /api/oidc/userinfo [get]
func (oc *OidcController) userInfoHandler(c *gin.Context) {
authHeaderSplit := strings.Split(c.GetHeader("Authorization"), " ")
if len(authHeaderSplit) != 2 {
c.Error(&common.MissingAccessToken{})
_, authToken, ok := strings.Cut(c.GetHeader("Authorization"), " ")
if !ok || authToken == "" {
_ = c.Error(&common.MissingAccessToken{})
return
}
token := authHeaderSplit[1]
jwtClaims, err := oc.jwtService.VerifyOauthAccessToken(token)
token, err := oc.jwtService.VerifyOauthAccessToken(authToken)
if err != nil {
c.Error(err)
_ = c.Error(err)
return
}
userID := jwtClaims.Subject
clientId := jwtClaims.Audience[0]
claims, err := oc.oidcService.GetUserClaimsForClient(userID, clientId)
userID, ok := token.Subject()
if !ok {
_ = c.Error(&common.TokenInvalidError{})
return
}
clientID, ok := token.Audience()
if !ok || len(clientID) != 1 {
_ = c.Error(&common.TokenInvalidError{})
return
}
claims, err := oc.oidcService.GetUserClaimsForClient(userID, clientID[0])
if err != nil {
c.Error(err)
_ = c.Error(err)
return
}
c.JSON(http.StatusOK, claims)
}
// userInfoHandler godoc (POST method)
// @Summary Get user information (POST method)
// @Description Get user information based on the access token using POST
// @Tags OIDC
// @Accept json
// @Produce json
// @Success 200 {object} object "User claims based on requested scopes"
// @Security OAuth2AccessToken
// @Router /api/oidc/userinfo [post]
func (oc *OidcController) userInfoHandlerPost(c *gin.Context) {
// Implementation is the same as GET
}
// EndSessionHandler godoc
// @Summary End OIDC session
// @Description End user session and handle OIDC logout
@@ -247,15 +240,16 @@ func (oc *OidcController) EndSessionHandler(c *gin.Context) {
var input dto.OidcLogoutDto
// Bind query parameters to the struct
if c.Request.Method == http.MethodGet {
switch c.Request.Method {
case http.MethodGet:
if err := c.ShouldBindQuery(&input); err != nil {
c.Error(err)
_ = c.Error(err)
return
}
} else if c.Request.Method == http.MethodPost {
case http.MethodPost:
// Bind form parameters to the struct
if err := c.ShouldBind(&input); err != nil {
c.Error(err)
_ = c.Error(err)
return
}
}
@@ -308,7 +302,7 @@ func (oc *OidcController) getClientMetaDataHandler(c *gin.Context) {
clientId := c.Param("id")
client, err := oc.oidcService.GetClient(clientId)
if err != nil {
c.Error(err)
_ = c.Error(err)
return
}
@@ -319,7 +313,7 @@ func (oc *OidcController) getClientMetaDataHandler(c *gin.Context) {
return
}
c.Error(err)
_ = c.Error(err)
}
// getClientHandler godoc
@@ -335,7 +329,7 @@ func (oc *OidcController) getClientHandler(c *gin.Context) {
clientId := c.Param("id")
client, err := oc.oidcService.GetClient(clientId)
if err != nil {
c.Error(err)
_ = c.Error(err)
return
}
@@ -346,7 +340,7 @@ func (oc *OidcController) getClientHandler(c *gin.Context) {
return
}
c.Error(err)
_ = c.Error(err)
}
// listClientsHandler godoc
@@ -365,19 +359,19 @@ func (oc *OidcController) listClientsHandler(c *gin.Context) {
searchTerm := c.Query("search")
var sortedPaginationRequest utils.SortedPaginationRequest
if err := c.ShouldBindQuery(&sortedPaginationRequest); err != nil {
c.Error(err)
_ = c.Error(err)
return
}
clients, pagination, err := oc.oidcService.ListClients(searchTerm, sortedPaginationRequest)
if err != nil {
c.Error(err)
_ = c.Error(err)
return
}
var clientsDto []dto.OidcClientDto
if err := dto.MapStructList(clients, &clientsDto); err != nil {
c.Error(err)
_ = c.Error(err)
return
}
@@ -400,19 +394,19 @@ func (oc *OidcController) listClientsHandler(c *gin.Context) {
func (oc *OidcController) createClientHandler(c *gin.Context) {
var input dto.OidcClientCreateDto
if err := c.ShouldBindJSON(&input); err != nil {
c.Error(err)
_ = c.Error(err)
return
}
client, err := oc.oidcService.CreateClient(input, c.GetString("userID"))
if err != nil {
c.Error(err)
_ = c.Error(err)
return
}
var clientDto dto.OidcClientWithAllowedUserGroupsDto
if err := dto.MapStruct(client, &clientDto); err != nil {
c.Error(err)
_ = c.Error(err)
return
}
@@ -430,7 +424,7 @@ func (oc *OidcController) createClientHandler(c *gin.Context) {
func (oc *OidcController) deleteClientHandler(c *gin.Context) {
err := oc.oidcService.DeleteClient(c.Param("id"))
if err != nil {
c.Error(err)
_ = c.Error(err)
return
}
@@ -451,19 +445,19 @@ func (oc *OidcController) deleteClientHandler(c *gin.Context) {
func (oc *OidcController) updateClientHandler(c *gin.Context) {
var input dto.OidcClientCreateDto
if err := c.ShouldBindJSON(&input); err != nil {
c.Error(err)
_ = c.Error(err)
return
}
client, err := oc.oidcService.UpdateClient(c.Param("id"), input)
if err != nil {
c.Error(err)
_ = c.Error(err)
return
}
var clientDto dto.OidcClientWithAllowedUserGroupsDto
if err := dto.MapStruct(client, &clientDto); err != nil {
c.Error(err)
_ = c.Error(err)
return
}
@@ -482,7 +476,7 @@ func (oc *OidcController) updateClientHandler(c *gin.Context) {
func (oc *OidcController) createClientSecretHandler(c *gin.Context) {
secret, err := oc.oidcService.CreateClientSecret(c.Param("id"))
if err != nil {
c.Error(err)
_ = c.Error(err)
return
}
@@ -502,7 +496,7 @@ func (oc *OidcController) createClientSecretHandler(c *gin.Context) {
func (oc *OidcController) getClientLogoHandler(c *gin.Context) {
imagePath, mimeType, err := oc.oidcService.GetClientLogo(c.Param("id"))
if err != nil {
c.Error(err)
_ = c.Error(err)
return
}
@@ -523,13 +517,13 @@ func (oc *OidcController) getClientLogoHandler(c *gin.Context) {
func (oc *OidcController) updateClientLogoHandler(c *gin.Context) {
file, err := c.FormFile("file")
if err != nil {
c.Error(err)
_ = c.Error(err)
return
}
err = oc.oidcService.UpdateClientLogo(c.Param("id"), file)
if err != nil {
c.Error(err)
_ = c.Error(err)
return
}
@@ -547,7 +541,7 @@ func (oc *OidcController) updateClientLogoHandler(c *gin.Context) {
func (oc *OidcController) deleteClientLogoHandler(c *gin.Context) {
err := oc.oidcService.DeleteClientLogo(c.Param("id"))
if err != nil {
c.Error(err)
_ = c.Error(err)
return
}
@@ -568,19 +562,19 @@ func (oc *OidcController) deleteClientLogoHandler(c *gin.Context) {
func (oc *OidcController) updateAllowedUserGroupsHandler(c *gin.Context) {
var input dto.OidcUpdateAllowedUserGroupsDto
if err := c.ShouldBindJSON(&input); err != nil {
c.Error(err)
_ = c.Error(err)
return
}
oidcClient, err := oc.oidcService.UpdateAllowedUserGroups(c.Param("id"), input)
if err != nil {
c.Error(err)
_ = c.Error(err)
return
}
var oidcClientDto dto.OidcClientDto
if err := dto.MapStruct(oidcClient, &oidcClientDto); err != nil {
c.Error(err)
_ = c.Error(err)
return
}

View File

@@ -2,7 +2,6 @@ package controller
import (
"net/http"
"strconv"
"time"
"github.com/pocket-id/pocket-id/backend/internal/utils/cookie"
@@ -68,13 +67,13 @@ func (uc *UserController) getUserGroupsHandler(c *gin.Context) {
userID := c.Param("id")
groups, err := uc.userService.GetUserGroups(userID)
if err != nil {
c.Error(err)
_ = c.Error(err)
return
}
var groupsDto []dto.UserGroupDtoWithUsers
if err := dto.MapStructList(groups, &groupsDto); err != nil {
c.Error(err)
_ = c.Error(err)
return
}
@@ -96,19 +95,19 @@ func (uc *UserController) listUsersHandler(c *gin.Context) {
searchTerm := c.Query("search")
var sortedPaginationRequest utils.SortedPaginationRequest
if err := c.ShouldBindQuery(&sortedPaginationRequest); err != nil {
c.Error(err)
_ = c.Error(err)
return
}
users, pagination, err := uc.userService.ListUsers(searchTerm, sortedPaginationRequest)
if err != nil {
c.Error(err)
_ = c.Error(err)
return
}
var usersDto []dto.UserDto
if err := dto.MapStructList(users, &usersDto); err != nil {
c.Error(err)
_ = c.Error(err)
return
}
@@ -128,13 +127,13 @@ func (uc *UserController) listUsersHandler(c *gin.Context) {
func (uc *UserController) getUserHandler(c *gin.Context) {
user, err := uc.userService.GetUser(c.Param("id"))
if err != nil {
c.Error(err)
_ = c.Error(err)
return
}
var userDto dto.UserDto
if err := dto.MapStruct(user, &userDto); err != nil {
c.Error(err)
_ = c.Error(err)
return
}
@@ -150,13 +149,13 @@ func (uc *UserController) getUserHandler(c *gin.Context) {
func (uc *UserController) getCurrentUserHandler(c *gin.Context) {
user, err := uc.userService.GetUser(c.GetString("userID"))
if err != nil {
c.Error(err)
_ = c.Error(err)
return
}
var userDto dto.UserDto
if err := dto.MapStruct(user, &userDto); err != nil {
c.Error(err)
_ = c.Error(err)
return
}
@@ -171,8 +170,8 @@ func (uc *UserController) getCurrentUserHandler(c *gin.Context) {
// @Success 204 "No Content"
// @Router /api/users/{id} [delete]
func (uc *UserController) deleteUserHandler(c *gin.Context) {
if err := uc.userService.DeleteUser(c.Param("id")); err != nil {
c.Error(err)
if err := uc.userService.DeleteUser(c.Param("id"), false); err != nil {
_ = c.Error(err)
return
}
@@ -189,19 +188,19 @@ func (uc *UserController) deleteUserHandler(c *gin.Context) {
func (uc *UserController) createUserHandler(c *gin.Context) {
var input dto.UserCreateDto
if err := c.ShouldBindJSON(&input); err != nil {
c.Error(err)
_ = c.Error(err)
return
}
user, err := uc.userService.CreateUser(input)
if err != nil {
c.Error(err)
_ = c.Error(err)
return
}
var userDto dto.UserDto
if err := dto.MapStruct(user, &userDto); err != nil {
c.Error(err)
_ = c.Error(err)
return
}
@@ -228,8 +227,8 @@ func (uc *UserController) updateUserHandler(c *gin.Context) {
// @Success 200 {object} dto.UserDto
// @Router /api/users/me [put]
func (uc *UserController) updateCurrentUserHandler(c *gin.Context) {
if uc.appConfigService.DbConfig.AllowOwnAccountEdit.Value != "true" {
c.Error(&common.AccountEditNotAllowedError{})
if !uc.appConfigService.DbConfig.AllowOwnAccountEdit.IsTrue() {
_ = c.Error(&common.AccountEditNotAllowedError{})
return
}
uc.updateUser(c, true)
@@ -248,7 +247,7 @@ func (uc *UserController) getUserProfilePictureHandler(c *gin.Context) {
picture, size, err := uc.userService.GetProfilePicture(userID)
if err != nil {
c.Error(err)
_ = c.Error(err)
return
}
@@ -271,18 +270,18 @@ func (uc *UserController) updateUserProfilePictureHandler(c *gin.Context) {
userID := c.Param("id")
fileHeader, err := c.FormFile("file")
if err != nil {
c.Error(err)
_ = c.Error(err)
return
}
file, err := fileHeader.Open()
if err != nil {
c.Error(err)
_ = c.Error(err)
return
}
defer file.Close()
if err := uc.userService.UpdateProfilePicture(userID, file); err != nil {
c.Error(err)
_ = c.Error(err)
return
}
@@ -302,18 +301,18 @@ func (uc *UserController) updateCurrentUserProfilePictureHandler(c *gin.Context)
userID := c.GetString("userID")
fileHeader, err := c.FormFile("file")
if err != nil {
c.Error(err)
_ = c.Error(err)
return
}
file, err := fileHeader.Open()
if err != nil {
c.Error(err)
_ = c.Error(err)
return
}
defer file.Close()
if err := uc.userService.UpdateProfilePicture(userID, file); err != nil {
c.Error(err)
_ = c.Error(err)
return
}
@@ -323,7 +322,7 @@ func (uc *UserController) updateCurrentUserProfilePictureHandler(c *gin.Context)
func (uc *UserController) createOneTimeAccessTokenHandler(c *gin.Context, own bool) {
var input dto.OneTimeAccessTokenCreateDto
if err := c.ShouldBindJSON(&input); err != nil {
c.Error(err)
_ = c.Error(err)
return
}
@@ -332,7 +331,7 @@ func (uc *UserController) createOneTimeAccessTokenHandler(c *gin.Context, own bo
}
token, err := uc.userService.CreateOneTimeAccessToken(input.UserID, input.ExpiresAt)
if err != nil {
c.Error(err)
_ = c.Error(err)
return
}
@@ -358,13 +357,13 @@ func (uc *UserController) createAdminOneTimeAccessTokenHandler(c *gin.Context) {
func (uc *UserController) requestOneTimeAccessEmailHandler(c *gin.Context) {
var input dto.OneTimeAccessEmailDto
if err := c.ShouldBindJSON(&input); err != nil {
c.Error(err)
_ = c.Error(err)
return
}
err := uc.userService.RequestOneTimeAccessEmail(input.Email, input.RedirectPath)
if err != nil {
c.Error(err)
_ = c.Error(err)
return
}
@@ -381,18 +380,17 @@ func (uc *UserController) requestOneTimeAccessEmailHandler(c *gin.Context) {
func (uc *UserController) exchangeOneTimeAccessTokenHandler(c *gin.Context) {
user, token, err := uc.userService.ExchangeOneTimeAccessToken(c.Param("token"), c.ClientIP(), c.Request.UserAgent())
if err != nil {
c.Error(err)
_ = c.Error(err)
return
}
var userDto dto.UserDto
if err := dto.MapStruct(user, &userDto); err != nil {
c.Error(err)
_ = c.Error(err)
return
}
sessionDurationInMinutesParsed, _ := strconv.Atoi(uc.appConfigService.DbConfig.SessionDuration.Value)
maxAge := sessionDurationInMinutesParsed * 60
maxAge := int(uc.appConfigService.DbConfig.SessionDuration.AsDurationMinutes().Seconds())
cookie.AddAccessTokenCookie(c, maxAge, token)
c.JSON(http.StatusOK, userDto)
@@ -407,18 +405,17 @@ func (uc *UserController) exchangeOneTimeAccessTokenHandler(c *gin.Context) {
func (uc *UserController) getSetupAccessTokenHandler(c *gin.Context) {
user, token, err := uc.userService.SetupInitialAdmin()
if err != nil {
c.Error(err)
_ = c.Error(err)
return
}
var userDto dto.UserDto
if err := dto.MapStruct(user, &userDto); err != nil {
c.Error(err)
_ = c.Error(err)
return
}
sessionDurationInMinutesParsed, _ := strconv.Atoi(uc.appConfigService.DbConfig.SessionDuration.Value)
maxAge := sessionDurationInMinutesParsed * 60
maxAge := int(uc.appConfigService.DbConfig.SessionDuration.AsDurationMinutes().Seconds())
cookie.AddAccessTokenCookie(c, maxAge, token)
c.JSON(http.StatusOK, userDto)
@@ -435,19 +432,19 @@ func (uc *UserController) getSetupAccessTokenHandler(c *gin.Context) {
func (uc *UserController) updateUserGroups(c *gin.Context) {
var input dto.UserUpdateUserGroupDto
if err := c.ShouldBindJSON(&input); err != nil {
c.Error(err)
_ = c.Error(err)
return
}
user, err := uc.userService.UpdateUserGroups(c.Param("id"), input.UserGroupIds)
if err != nil {
c.Error(err)
_ = c.Error(err)
return
}
var userDto dto.UserDto
if err := dto.MapStruct(user, &userDto); err != nil {
c.Error(err)
_ = c.Error(err)
return
}
@@ -458,7 +455,7 @@ func (uc *UserController) updateUserGroups(c *gin.Context) {
func (uc *UserController) updateUser(c *gin.Context, updateOwnUser bool) {
var input dto.UserCreateDto
if err := c.ShouldBindJSON(&input); err != nil {
c.Error(err)
_ = c.Error(err)
return
}
@@ -471,13 +468,13 @@ func (uc *UserController) updateUser(c *gin.Context, updateOwnUser bool) {
user, err := uc.userService.UpdateUser(userID, input, updateOwnUser, false)
if err != nil {
c.Error(err)
_ = c.Error(err)
return
}
var userDto dto.UserDto
if err := dto.MapStruct(user, &userDto); err != nil {
c.Error(err)
_ = c.Error(err)
return
}
@@ -496,7 +493,7 @@ func (uc *UserController) resetUserProfilePictureHandler(c *gin.Context) {
userID := c.Param("id")
if err := uc.userService.ResetProfilePicture(userID); err != nil {
c.Error(err)
_ = c.Error(err)
return
}
@@ -514,7 +511,7 @@ func (uc *UserController) resetCurrentUserProfilePictureHandler(c *gin.Context)
userID := c.GetString("userID")
if err := uc.userService.ResetProfilePicture(userID); err != nil {
c.Error(err)
_ = c.Error(err)
return
}

View File

@@ -50,13 +50,13 @@ func (ugc *UserGroupController) list(c *gin.Context) {
searchTerm := c.Query("search")
var sortedPaginationRequest utils.SortedPaginationRequest
if err := c.ShouldBindQuery(&sortedPaginationRequest); err != nil {
c.Error(err)
_ = c.Error(err)
return
}
groups, pagination, err := ugc.UserGroupService.List(searchTerm, sortedPaginationRequest)
if err != nil {
c.Error(err)
_ = c.Error(err)
return
}
@@ -65,12 +65,12 @@ func (ugc *UserGroupController) list(c *gin.Context) {
for i, group := range groups {
var groupDto dto.UserGroupDtoWithUserCount
if err := dto.MapStruct(group, &groupDto); err != nil {
c.Error(err)
_ = c.Error(err)
return
}
groupDto.UserCount, err = ugc.UserGroupService.GetUserCountOfGroup(group.ID)
if err != nil {
c.Error(err)
_ = c.Error(err)
return
}
groupsDto[i] = groupDto
@@ -95,13 +95,13 @@ func (ugc *UserGroupController) list(c *gin.Context) {
func (ugc *UserGroupController) get(c *gin.Context) {
group, err := ugc.UserGroupService.Get(c.Param("id"))
if err != nil {
c.Error(err)
_ = c.Error(err)
return
}
var groupDto dto.UserGroupDtoWithUsers
if err := dto.MapStruct(group, &groupDto); err != nil {
c.Error(err)
_ = c.Error(err)
return
}
@@ -121,19 +121,19 @@ func (ugc *UserGroupController) get(c *gin.Context) {
func (ugc *UserGroupController) create(c *gin.Context) {
var input dto.UserGroupCreateDto
if err := c.ShouldBindJSON(&input); err != nil {
c.Error(err)
_ = c.Error(err)
return
}
group, err := ugc.UserGroupService.Create(input)
if err != nil {
c.Error(err)
_ = c.Error(err)
return
}
var groupDto dto.UserGroupDtoWithUsers
if err := dto.MapStruct(group, &groupDto); err != nil {
c.Error(err)
_ = c.Error(err)
return
}
@@ -154,19 +154,19 @@ func (ugc *UserGroupController) create(c *gin.Context) {
func (ugc *UserGroupController) update(c *gin.Context) {
var input dto.UserGroupCreateDto
if err := c.ShouldBindJSON(&input); err != nil {
c.Error(err)
_ = c.Error(err)
return
}
group, err := ugc.UserGroupService.Update(c.Param("id"), input, false)
if err != nil {
c.Error(err)
_ = c.Error(err)
return
}
var groupDto dto.UserGroupDtoWithUsers
if err := dto.MapStruct(group, &groupDto); err != nil {
c.Error(err)
_ = c.Error(err)
return
}
@@ -185,7 +185,7 @@ func (ugc *UserGroupController) update(c *gin.Context) {
// @Router /api/user-groups/{id} [delete]
func (ugc *UserGroupController) delete(c *gin.Context) {
if err := ugc.UserGroupService.Delete(c.Param("id")); err != nil {
c.Error(err)
_ = c.Error(err)
return
}
@@ -206,19 +206,19 @@ func (ugc *UserGroupController) delete(c *gin.Context) {
func (ugc *UserGroupController) updateUsers(c *gin.Context) {
var input dto.UserGroupUpdateUsersDto
if err := c.ShouldBindJSON(&input); err != nil {
c.Error(err)
_ = c.Error(err)
return
}
group, err := ugc.UserGroupService.UpdateUsers(c.Param("id"), input.UserIDs)
if err != nil {
c.Error(err)
_ = c.Error(err)
return
}
var groupDto dto.UserGroupDtoWithUsers
if err := dto.MapStruct(group, &groupDto); err != nil {
c.Error(err)
_ = c.Error(err)
return
}

View File

@@ -2,7 +2,6 @@ package controller
import (
"net/http"
"strconv"
"time"
"github.com/go-webauthn/webauthn/protocol"
@@ -40,7 +39,7 @@ func (wc *WebauthnController) beginRegistrationHandler(c *gin.Context) {
userID := c.GetString("userID")
options, err := wc.webAuthnService.BeginRegistration(userID)
if err != nil {
c.Error(err)
_ = c.Error(err)
return
}
@@ -51,20 +50,20 @@ func (wc *WebauthnController) beginRegistrationHandler(c *gin.Context) {
func (wc *WebauthnController) verifyRegistrationHandler(c *gin.Context) {
sessionID, err := c.Cookie(cookie.SessionIdCookieName)
if err != nil {
c.Error(&common.MissingSessionIdError{})
_ = c.Error(&common.MissingSessionIdError{})
return
}
userID := c.GetString("userID")
credential, err := wc.webAuthnService.VerifyRegistration(sessionID, userID, c.Request)
if err != nil {
c.Error(err)
_ = c.Error(err)
return
}
var credentialDto dto.WebauthnCredentialDto
if err := dto.MapStruct(credential, &credentialDto); err != nil {
c.Error(err)
_ = c.Error(err)
return
}
@@ -74,7 +73,7 @@ func (wc *WebauthnController) verifyRegistrationHandler(c *gin.Context) {
func (wc *WebauthnController) beginLoginHandler(c *gin.Context) {
options, err := wc.webAuthnService.BeginLogin()
if err != nil {
c.Error(err)
_ = c.Error(err)
return
}
@@ -85,30 +84,29 @@ func (wc *WebauthnController) beginLoginHandler(c *gin.Context) {
func (wc *WebauthnController) verifyLoginHandler(c *gin.Context) {
sessionID, err := c.Cookie(cookie.SessionIdCookieName)
if err != nil {
c.Error(&common.MissingSessionIdError{})
_ = c.Error(&common.MissingSessionIdError{})
return
}
credentialAssertionData, err := protocol.ParseCredentialRequestResponseBody(c.Request.Body)
if err != nil {
c.Error(err)
_ = c.Error(err)
return
}
user, token, err := wc.webAuthnService.VerifyLogin(sessionID, credentialAssertionData, c.ClientIP(), c.Request.UserAgent())
if err != nil {
c.Error(err)
_ = c.Error(err)
return
}
var userDto dto.UserDto
if err := dto.MapStruct(user, &userDto); err != nil {
c.Error(err)
_ = c.Error(err)
return
}
sessionDurationInMinutesParsed, _ := strconv.Atoi(wc.appConfigService.DbConfig.SessionDuration.Value)
maxAge := sessionDurationInMinutesParsed * 60
maxAge := int(wc.appConfigService.DbConfig.SessionDuration.AsDurationMinutes().Seconds())
cookie.AddAccessTokenCookie(c, maxAge, token)
c.JSON(http.StatusOK, userDto)
@@ -118,13 +116,13 @@ func (wc *WebauthnController) listCredentialsHandler(c *gin.Context) {
userID := c.GetString("userID")
credentials, err := wc.webAuthnService.ListCredentials(userID)
if err != nil {
c.Error(err)
_ = c.Error(err)
return
}
var credentialDtos []dto.WebauthnCredentialDto
if err := dto.MapStructList(credentials, &credentialDtos); err != nil {
c.Error(err)
_ = c.Error(err)
return
}
@@ -137,7 +135,7 @@ func (wc *WebauthnController) deleteCredentialHandler(c *gin.Context) {
err := wc.webAuthnService.DeleteCredential(userID, credentialID)
if err != nil {
c.Error(err)
_ = c.Error(err)
return
}
@@ -150,19 +148,19 @@ func (wc *WebauthnController) updateCredentialHandler(c *gin.Context) {
var input dto.WebauthnCredentialUpdateDto
if err := c.ShouldBindJSON(&input); err != nil {
c.Error(err)
_ = c.Error(err)
return
}
credential, err := wc.webAuthnService.UpdateCredential(userID, credentialID, input.Name)
if err != nil {
c.Error(err)
_ = c.Error(err)
return
}
var credentialDto dto.WebauthnCredentialDto
if err := dto.MapStruct(credential, &credentialDto); err != nil {
c.Error(err)
_ = c.Error(err)
return
}

View File

@@ -1,9 +1,13 @@
package controller
import (
"encoding/json"
"fmt"
"log"
"net/http"
"github.com/gin-gonic/gin"
"github.com/pocket-id/pocket-id/backend/internal/common"
"github.com/pocket-id/pocket-id/backend/internal/service"
)
@@ -14,12 +18,21 @@ import (
// @Tags Well Known
func NewWellKnownController(group *gin.RouterGroup, jwtService *service.JwtService) {
wkc := &WellKnownController{jwtService: jwtService}
// Pre-compute the OIDC configuration document, which is static
var err error
wkc.oidcConfig, err = wkc.computeOIDCConfiguration()
if err != nil {
log.Fatalf("Failed to pre-compute OpenID Connect configuration document: %v", err)
}
group.GET("/.well-known/jwks.json", wkc.jwksHandler)
group.GET("/.well-known/openid-configuration", wkc.openIDConfigurationHandler)
}
type WellKnownController struct {
jwtService *service.JwtService
oidcConfig []byte
}
// jwksHandler godoc
@@ -32,7 +45,7 @@ type WellKnownController struct {
func (wkc *WellKnownController) jwksHandler(c *gin.Context) {
jwks, err := wkc.jwtService.GetPublicJWKSAsJSON()
if err != nil {
c.Error(err)
_ = c.Error(err)
return
}
@@ -46,8 +59,16 @@ func (wkc *WellKnownController) jwksHandler(c *gin.Context) {
// @Success 200 {object} object "OpenID Connect configuration"
// @Router /.well-known/openid-configuration [get]
func (wkc *WellKnownController) openIDConfigurationHandler(c *gin.Context) {
c.Data(http.StatusOK, "application/json; charset=utf-8", wkc.oidcConfig)
}
func (wkc *WellKnownController) computeOIDCConfiguration() ([]byte, error) {
appUrl := common.EnvConfig.AppURL
config := map[string]interface{}{
alg, err := wkc.jwtService.GetKeyAlg()
if err != nil {
return nil, fmt.Errorf("failed to get key algorithm: %w", err)
}
config := map[string]any{
"issuer": appUrl,
"authorization_endpoint": appUrl + "/authorize",
"token_endpoint": appUrl + "/api/oidc/token",
@@ -59,7 +80,7 @@ func (wkc *WellKnownController) openIDConfigurationHandler(c *gin.Context) {
"claims_supported": []string{"sub", "given_name", "family_name", "name", "email", "email_verified", "preferred_username", "picture", "groups"},
"response_types_supported": []string{"code", "id_token"},
"subject_types_supported": []string{"public"},
"id_token_signing_alg_values_supported": []string{"RS256"},
"id_token_signing_alg_values_supported": []string{alg.String()},
}
c.JSON(http.StatusOK, config)
return json.Marshal(config)
}

View File

@@ -40,13 +40,11 @@ func MapStruct[S any, D any](source S, destination *D) error {
}
func mapStructInternal(sourceVal reflect.Value, destVal reflect.Value) error {
// Loop through the fields of the destination struct
for i := 0; i < destVal.NumField(); i++ {
destField := destVal.Field(i)
destFieldType := destVal.Type().Field(i)
if destFieldType.Anonymous {
// Recursively handle embedded structs
if err := mapStructInternal(sourceVal, destField); err != nil {
return err
}
@@ -55,63 +53,57 @@ func mapStructInternal(sourceVal reflect.Value, destVal reflect.Value) error {
sourceField := sourceVal.FieldByName(destFieldType.Name)
// If the source field is valid and can be assigned to the destination field
if sourceField.IsValid() && destField.CanSet() {
// Handle direct assignment for simple types
if sourceField.Type() == destField.Type() {
destField.Set(sourceField)
} else if sourceField.Kind() == reflect.Slice && destField.Kind() == reflect.Slice {
// Handle slices
if sourceField.Type().Elem() == destField.Type().Elem() {
// Direct assignment for slices of primitive types or non-struct elements
newSlice := reflect.MakeSlice(destField.Type(), sourceField.Len(), sourceField.Cap())
for j := 0; j < sourceField.Len(); j++ {
newSlice.Index(j).Set(sourceField.Index(j))
}
destField.Set(newSlice)
} else if sourceField.Type().Elem().Kind() == reflect.Struct && destField.Type().Elem().Kind() == reflect.Struct {
// Recursively map slices of structs
newSlice := reflect.MakeSlice(destField.Type(), sourceField.Len(), sourceField.Cap())
for j := 0; j < sourceField.Len(); j++ {
// Get the element from both source and destination slice
sourceElem := sourceField.Index(j)
destElem := reflect.New(destField.Type().Elem()).Elem()
// Recursively map the struct elements
if err := mapStructInternal(sourceElem, destElem); err != nil {
return err
}
// Set the mapped element in the new slice
newSlice.Index(j).Set(destElem)
}
destField.Set(newSlice)
}
} else if sourceField.Kind() == reflect.Struct && destField.Kind() == reflect.Struct {
// Recursively map nested structs
if err := mapStructInternal(sourceField, destField); err != nil {
return err
}
} else {
// Type switch for specific type conversions
switch sourceField.Interface().(type) {
case datatype.DateTime:
// Convert datatype.DateTime to time.Time
if sourceField.Type() == reflect.TypeOf(datatype.DateTime{}) && destField.Type() == reflect.TypeOf(time.Time{}) {
dateValue := sourceField.Interface().(datatype.DateTime)
destField.Set(reflect.ValueOf(dateValue.ToTime()))
}
}
if err := mapField(sourceField, destField); err != nil {
return err
}
}
}
return nil
}
func mapField(sourceField reflect.Value, destField reflect.Value) error {
switch {
case sourceField.Type() == destField.Type():
destField.Set(sourceField)
case sourceField.Kind() == reflect.Slice && destField.Kind() == reflect.Slice:
return mapSlice(sourceField, destField)
case sourceField.Kind() == reflect.Struct && destField.Kind() == reflect.Struct:
return mapStructInternal(sourceField, destField)
default:
return mapSpecialTypes(sourceField, destField)
}
return nil
}
func mapSlice(sourceField reflect.Value, destField reflect.Value) error {
if sourceField.Type().Elem() == destField.Type().Elem() {
newSlice := reflect.MakeSlice(destField.Type(), sourceField.Len(), sourceField.Cap())
for j := 0; j < sourceField.Len(); j++ {
newSlice.Index(j).Set(sourceField.Index(j))
}
destField.Set(newSlice)
} else if sourceField.Type().Elem().Kind() == reflect.Struct && destField.Type().Elem().Kind() == reflect.Struct {
newSlice := reflect.MakeSlice(destField.Type(), sourceField.Len(), sourceField.Cap())
for j := 0; j < sourceField.Len(); j++ {
sourceElem := sourceField.Index(j)
destElem := reflect.New(destField.Type().Elem()).Elem()
if err := mapStructInternal(sourceElem, destElem); err != nil {
return err
}
newSlice.Index(j).Set(destElem)
}
destField.Set(newSlice)
}
return nil
}
func mapSpecialTypes(sourceField reflect.Value, destField reflect.Value) error {
if _, ok := sourceField.Interface().(datatype.DateTime); ok {
if sourceField.Type() == reflect.TypeOf(datatype.DateTime{}) && destField.Type() == reflect.TypeOf(time.Time{}) {
dateValue := sourceField.Interface().(datatype.DateTime)
destField.Set(reflect.ValueOf(dateValue.ToTime()))
}
}
return nil
}

View File

@@ -1,10 +1,11 @@
package dto
import (
"github.com/gin-gonic/gin/binding"
"github.com/go-playground/validator/v10"
"log"
"regexp"
"github.com/gin-gonic/gin/binding"
"github.com/go-playground/validator/v10"
)
var validateUsername validator.Func = func(fl validator.FieldLevel) bool {

View File

@@ -23,6 +23,7 @@ func RegisterDbCleanupJobs(db *gorm.DB) {
registerJob(scheduler, "ClearOneTimeAccessTokens", "0 3 * * *", jobs.clearOneTimeAccessTokens)
registerJob(scheduler, "ClearOidcAuthorizationCodes", "0 3 * * *", jobs.clearOidcAuthorizationCodes)
registerJob(scheduler, "ClearOidcRefreshTokens", "0 3 * * *", jobs.clearOidcRefreshTokens)
registerJob(scheduler, "ClearAuditLogs", "0 3 * * *", jobs.clearAuditLogs)
scheduler.Start()
}

View File

@@ -32,7 +32,7 @@ func RegisterLdapJobs(ldapService *service.LdapService, appConfigService *servic
}
func (j *LdapJobs) syncLdap() error {
if j.appConfigService.DbConfig.LdapEnabled.Value == "true" {
if j.appConfigService.DbConfig.LdapEnabled.IsTrue() {
return j.ldapService.SyncAll()
}
return nil

View File

@@ -23,7 +23,7 @@ func (m *ApiKeyAuthMiddleware) Add(adminRequired bool) gin.HandlerFunc {
userID, isAdmin, err := m.Verify(c, adminRequired)
if err != nil {
c.Abort()
c.Error(err)
_ = c.Error(err)
return
}

View File

@@ -84,6 +84,6 @@ func (m *AuthMiddleware) Add() gin.HandlerFunc {
// Both JWT and API key auth failed
c.Abort()
c.Error(err)
_ = c.Error(err)
}
}

View File

@@ -1,6 +1,8 @@
package middleware
import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/pocket-id/pocket-id/backend/internal/common"
)
@@ -23,7 +25,7 @@ func (m *CorsMiddleware) Add() gin.HandlerFunc {
c.Writer.Header().Set("Access-Control-Allow-Headers", "*")
c.Writer.Header().Set("Access-Control-Allow-Methods", "POST, OPTIONS, GET, PUT")
if c.Request.Method == "OPTIONS" {
if c.Request.Method == http.MethodOptions {
c.AbortWithStatus(204)
return
}

View File

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

View File

@@ -19,11 +19,10 @@ func NewJwtAuthMiddleware(jwtService *service.JwtService) *JwtAuthMiddleware {
func (m *JwtAuthMiddleware) Add(adminRequired bool) gin.HandlerFunc {
return func(c *gin.Context) {
userID, isAdmin, err := m.Verify(c, adminRequired)
if err != nil {
c.Abort()
c.Error(err)
_ = c.Error(err)
return
}
@@ -33,27 +32,37 @@ func (m *JwtAuthMiddleware) Add(adminRequired bool) gin.HandlerFunc {
}
}
func (m *JwtAuthMiddleware) Verify(c *gin.Context, adminRequired bool) (userID string, isAdmin bool, err error) {
func (m *JwtAuthMiddleware) Verify(c *gin.Context, adminRequired bool) (subject string, isAdmin bool, err error) {
// Extract the token from the cookie
token, err := c.Cookie(cookie.AccessTokenCookieName)
accessToken, err := c.Cookie(cookie.AccessTokenCookieName)
if err != nil {
// Try to extract the token from the Authorization header if it's not in the cookie
authorizationHeaderSplit := strings.Split(c.GetHeader("Authorization"), " ")
if len(authorizationHeaderSplit) != 2 {
var ok bool
_, accessToken, ok = strings.Cut(c.GetHeader("Authorization"), " ")
if !ok || accessToken == "" {
return "", false, &common.NotSignedInError{}
}
token = authorizationHeaderSplit[1]
}
claims, err := m.jwtService.VerifyAccessToken(token)
token, err := m.jwtService.VerifyAccessToken(accessToken)
if err != nil {
return "", false, &common.NotSignedInError{}
}
subject, ok := token.Subject()
if !ok {
_ = c.Error(&common.TokenInvalidError{})
return
}
// Check if the user is an admin
if adminRequired && !claims.IsAdmin {
isAdmin, err = service.GetIsAdmin(token)
if err != nil {
return "", false, &common.TokenInvalidError{}
}
if adminRequired && !isAdmin {
return "", false, &common.MissingPermissionError{}
}
return claims.Subject, claims.IsAdmin, nil
return subject, isAdmin, nil
}

View File

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

View File

@@ -1,8 +1,6 @@
package model
import (
"github.com/pocket-id/pocket-id/backend/internal/model/types"
)
import datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
type ApiKey struct {
Base

View File

@@ -2,6 +2,7 @@ package model
import (
"strconv"
"time"
)
type AppConfigVariable struct {
@@ -13,11 +14,21 @@ type AppConfigVariable struct {
DefaultValue string
}
// IsTrue returns true if the value is a truthy string, such as "true", "t", "yes", "1", etc.
func (a *AppConfigVariable) IsTrue() bool {
ok, _ := strconv.ParseBool(a.Value)
return ok
}
// AsDurationMinutes returns the value as a time.Duration, interpreting the string as a whole number of minutes.
func (a *AppConfigVariable) AsDurationMinutes() time.Duration {
val, err := strconv.Atoi(a.Value)
if err != nil {
return 0
}
return time.Duration(val) * time.Minute
}
type AppConfig struct {
// General
AppName AppConfigVariable

View File

@@ -0,0 +1,60 @@
package model
import (
"testing"
"time"
"github.com/stretchr/testify/assert"
)
func TestAppConfigVariable_AsMinutesDuration(t *testing.T) {
tests := []struct {
name string
value string
expected time.Duration
expectedSeconds int
}{
{
name: "valid positive integer",
value: "60",
expected: 60 * time.Minute,
expectedSeconds: 3600,
},
{
name: "valid zero integer",
value: "0",
expected: 0,
expectedSeconds: 0,
},
{
name: "negative integer",
value: "-30",
expected: -30 * time.Minute,
expectedSeconds: -1800,
},
{
name: "invalid non-integer",
value: "not-a-number",
expected: 0,
expectedSeconds: 0,
},
{
name: "empty string",
value: "",
expected: 0,
expectedSeconds: 0,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
configVar := AppConfigVariable{
Value: tt.value,
}
result := configVar.AsDurationMinutes()
assert.Equal(t, tt.expected, result)
assert.Equal(t, tt.expectedSeconds, int(result.Seconds()))
})
}
}

View File

@@ -18,9 +18,9 @@ type AuditLog struct {
Data AuditLogData
}
type AuditLogData map[string]string
type AuditLogData map[string]string //nolint:recvcheck
type AuditLogEvent string
type AuditLogEvent string //nolint:recvcheck
const (
AuditLogEventSignIn AuditLogEvent = "SIGN_IN"

View File

@@ -4,7 +4,7 @@ import (
"time"
"github.com/google/uuid"
"github.com/pocket-id/pocket-id/backend/internal/model/types"
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
"gorm.io/gorm"
)

View File

@@ -71,7 +71,7 @@ func (c *OidcClient) AfterFind(_ *gorm.DB) (err error) {
return nil
}
type UrlList []string
type UrlList []string //nolint:recvcheck
func (cu *UrlList) Scan(value interface{}) error {
if v, ok := value.([]byte); ok {

View File

@@ -8,7 +8,7 @@ import (
)
// DateTime custom type for time.Time to store date as unix timestamp for sqlite and as date for postgres
type DateTime time.Time
type DateTime time.Time //nolint:recvcheck
func (date *DateTime) Scan(value interface{}) (err error) {
*date = DateTime(value.(time.Time))

View File

@@ -45,7 +45,7 @@ type PublicKeyCredentialRequestOptions struct {
Timeout time.Duration
}
type AuthenticatorTransportList []protocol.AuthenticatorTransport
type AuthenticatorTransportList []protocol.AuthenticatorTransport //nolint:recvcheck
// Scan and Value methods for GORM to handle the custom type
func (atl *AuthenticatorTransportList) Scan(value interface{}) error {

View File

@@ -2,10 +2,11 @@ package service
import (
"errors"
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
"log"
"time"
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
"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"

View File

@@ -60,7 +60,7 @@ func (s *AuditLogService) CreateNewSignInWithEmail(ipAddress, userAgent, userID
}
// If the user hasn't logged in from the same device before and email notifications are enabled, send an email
if s.appConfigService.DbConfig.EmailLoginNotificationEnabled.Value == "true" && count <= 1 {
if s.appConfigService.DbConfig.EmailLoginNotificationEnabled.IsTrue() && count <= 1 {
go func() {
var user model.User
s.db.Where("id = ?", userID).First(&user)

View File

@@ -105,9 +105,10 @@ func (s *CustomClaimService) updateCustomClaims(idType idType, value string, cla
Value: claim.Value,
}
if idType == UserID {
switch idType {
case UserID:
customClaim.UserID = &value
} else if idType == UserGroupID {
case UserGroupID:
customClaim.UserGroupID = &value
}

View File

@@ -1,3 +1,5 @@
//go:build e2etest
package service
import (

View File

@@ -5,21 +5,22 @@ import (
"crypto/tls"
"errors"
"fmt"
"github.com/emersion/go-sasl"
"github.com/emersion/go-smtp"
"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/email"
"gorm.io/gorm"
htemplate "html/template"
"mime/multipart"
"mime/quotedprintable"
"net/textproto"
"os"
"strings"
ttemplate "text/template"
"time"
"github.com/emersion/go-sasl"
"github.com/emersion/go-smtp"
"github.com/google/uuid"
"strings"
"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/email"
"gorm.io/gorm"
)
type EmailService struct {
@@ -107,7 +108,7 @@ func SendEmail[V any](srv *EmailService, toEmail email.Address, template email.T
domain = hostname
}
}
c.AddHeader("Message-ID", "<" + uuid.New().String() + "@" + domain + ">")
c.AddHeader("Message-ID", "<"+uuid.New().String()+"@"+domain+">")
c.Body(body)
@@ -131,7 +132,7 @@ func (srv *EmailService) getSmtpClient() (client *smtp.Client, err error) {
smtpAddress := srv.appConfigService.DbConfig.SmtpHost.Value + ":" + port
tlsConfig := &tls.Config{
InsecureSkipVerify: srv.appConfigService.DbConfig.SmtpSkipCertVerify.Value == "true",
InsecureSkipVerify: srv.appConfigService.DbConfig.SmtpSkipCertVerify.IsTrue(), //nolint:gosec
ServerName: srv.appConfigService.DbConfig.SmtpHost.Value,
}

View File

@@ -3,6 +3,7 @@ package service
import (
"archive/tar"
"compress/gzip"
"context"
"errors"
"fmt"
"io"
@@ -124,8 +125,15 @@ func (s *GeoLiteService) updateDatabase() error {
log.Println("Updating GeoLite2 City database...")
downloadUrl := fmt.Sprintf(common.EnvConfig.GeoLiteDBUrl, common.EnvConfig.MaxMindLicenseKey)
// Download the database tar.gz file
resp, err := http.Get(downloadUrl)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, downloadUrl, nil)
if err != nil {
return fmt.Errorf("failed to create request: %w", err)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return fmt.Errorf("failed to download database: %w", err)
}
@@ -164,6 +172,9 @@ func (s *GeoLiteService) extractDatabase(reader io.Reader) error {
tarReader := tar.NewReader(gzr)
var totalSize int64
const maxTotalSize = 300 * 1024 * 1024 // 300 MB limit for total decompressed size
// Iterate over the files in the tar archive
for {
header, err := tarReader.Next()
@@ -176,6 +187,11 @@ func (s *GeoLiteService) extractDatabase(reader io.Reader) error {
// Check if the file is the GeoLite2-City.mmdb file
if header.Typeflag == tar.TypeReg && filepath.Base(header.Name) == "GeoLite2-City.mmdb" {
totalSize += header.Size
if totalSize > maxTotalSize {
return errors.New("total decompressed size exceeds maximum allowed limit")
}
// extract to a temporary file to avoid having a corrupted db in case of write failure.
baseDir := filepath.Dir(common.EnvConfig.GeoLiteDBPath)
tmpFile, err := os.CreateTemp(baseDir, "geolite.*.mmdb.tmp")
@@ -185,7 +201,7 @@ func (s *GeoLiteService) extractDatabase(reader io.Reader) error {
tempName := tmpFile.Name()
// Write the file contents directly to the target location
if _, err := io.Copy(tmpFile, tarReader); err != nil {
if _, err := io.Copy(tmpFile, tarReader); err != nil { //nolint:gosec
// if fails to write, then cleanup and throw an error
tmpFile.Close()
os.Remove(tempName)

View File

@@ -11,13 +11,11 @@ import (
"log"
"os"
"path/filepath"
"slices"
"strconv"
"time"
"github.com/golang-jwt/jwt/v5"
"github.com/lestrrat-go/jwx/v3/jwa"
"github.com/lestrrat-go/jwx/v3/jwk"
"github.com/lestrrat-go/jwx/v3/jwt"
"github.com/pocket-id/pocket-id/backend/internal/common"
"github.com/pocket-id/pocket-id/backend/internal/model"
@@ -34,6 +32,13 @@ const (
// KeyUsageSigning is the usage for the private keys, for the "use" property
KeyUsageSigning = "sig"
// IsAdminClaim is a boolean claim used in access tokens for admin users
// This may be omitted on non-admin tokens
IsAdminClaim = "isAdmin"
// Acceptable clock skew for verifying tokens
clockSkew = time.Minute
)
type JwtService struct {
@@ -61,11 +66,6 @@ func (s *JwtService) init(appConfigService *AppConfigService, keysPath string) e
return s.loadOrGenerateKey(keysPath)
}
type AccessTokenJWTClaims struct {
jwt.RegisteredClaims
IsAdmin bool `json:"isAdmin,omitempty"`
}
// loadOrGenerateKey loads the private key from the given path or generates it if not existing.
func (s *JwtService) loadOrGenerateKey(keysPath string) error {
var key jwk.Key
@@ -170,133 +170,164 @@ func (s *JwtService) SetKey(privateKey jwk.Key) error {
}
func (s *JwtService) GenerateAccessToken(user model.User) (string, error) {
sessionDurationInMinutes, _ := strconv.Atoi(s.appConfigService.DbConfig.SessionDuration.Value)
claim := AccessTokenJWTClaims{
RegisteredClaims: jwt.RegisteredClaims{
Subject: user.ID,
ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Duration(sessionDurationInMinutes) * time.Minute)),
IssuedAt: jwt.NewNumericDate(time.Now()),
Audience: jwt.ClaimStrings{common.EnvConfig.AppURL},
},
IsAdmin: user.IsAdmin,
}
token := jwt.NewWithClaims(jwt.SigningMethodRS256, claim)
token.Header["kid"] = s.keyId
var privateKeyRaw any
err := jwk.Export(s.privateKey, &privateKeyRaw)
now := time.Now()
token, err := jwt.NewBuilder().
Subject(user.ID).
Expiration(now.Add(s.appConfigService.DbConfig.SessionDuration.AsDurationMinutes())).
IssuedAt(now).
Issuer(common.EnvConfig.AppURL).
Build()
if err != nil {
return "", fmt.Errorf("failed to export private key object: %w", err)
return "", fmt.Errorf("failed to build token: %w", err)
}
signed, err := token.SignedString(privateKeyRaw)
err = SetAudienceString(token, common.EnvConfig.AppURL)
if err != nil {
return "", fmt.Errorf("failed to set 'aud' claim in token: %w", err)
}
err = SetIsAdmin(token, user.IsAdmin)
if err != nil {
return "", fmt.Errorf("failed to set 'isAdmin' 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 signed, nil
return string(signed), nil
}
func (s *JwtService) VerifyAccessToken(tokenString string) (*AccessTokenJWTClaims, error) {
token, err := jwt.ParseWithClaims(tokenString, &AccessTokenJWTClaims{}, func(token *jwt.Token) (any, error) {
return s.getPublicKeyRaw()
})
if err != nil || !token.Valid {
return nil, errors.New("couldn't handle this token")
func (s *JwtService) VerifyAccessToken(tokenString string) (jwt.Token, error) {
alg, _ := s.privateKey.Algorithm()
token, err := jwt.ParseString(
tokenString,
jwt.WithValidate(true),
jwt.WithKey(alg, s.privateKey),
jwt.WithAcceptableSkew(clockSkew),
jwt.WithAudience(common.EnvConfig.AppURL),
jwt.WithIssuer(common.EnvConfig.AppURL),
)
if err != nil {
return nil, fmt.Errorf("failed to parse token: %w", err)
}
claims, isValid := token.Claims.(*AccessTokenJWTClaims)
if !isValid {
return nil, errors.New("can't parse claims")
}
if !slices.Contains(claims.Audience, common.EnvConfig.AppURL) {
return nil, errors.New("audience doesn't match")
}
return claims, nil
return token, nil
}
func (s *JwtService) GenerateIDToken(userClaims map[string]interface{}, clientID string, nonce string) (string, error) {
// Initialize with capacity for userClaims, + 4 fixed claims, + 2 claims which may be set in some cases, to avoid re-allocations
claims := make(jwt.MapClaims, len(userClaims)+6)
claims["aud"] = clientID
claims["exp"] = jwt.NewNumericDate(time.Now().Add(1 * time.Hour))
claims["iat"] = jwt.NewNumericDate(time.Now())
claims["iss"] = common.EnvConfig.AppURL
func (s *JwtService) GenerateIDToken(userClaims map[string]any, clientID string, nonce string) (string, error) {
now := time.Now()
token, err := jwt.NewBuilder().
Expiration(now.Add(1 * time.Hour)).
IssuedAt(now).
Issuer(common.EnvConfig.AppURL).
Build()
if err != nil {
return "", 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)
}
for k, v := range userClaims {
claims[k] = v
err = token.Set(k, v)
if err != nil {
return "", fmt.Errorf("failed to set claim '%s': %w", k, err)
}
}
if nonce != "" {
claims["nonce"] = nonce
err = token.Set("nonce", nonce)
if err != nil {
return "", fmt.Errorf("failed to set claim 'nonce': %w", err)
}
}
token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims)
token.Header["kid"] = s.keyId
var privateKeyRaw any
err := jwk.Export(s.privateKey, &privateKeyRaw)
alg, _ := s.privateKey.Algorithm()
signed, err := jwt.Sign(token, jwt.WithKey(alg, s.privateKey))
if err != nil {
return "", fmt.Errorf("failed to export private key object: %w", err)
return "", fmt.Errorf("failed to sign token: %w", err)
}
return token.SignedString(privateKeyRaw)
return string(signed), nil
}
func (s *JwtService) VerifyIdToken(tokenString string) (*jwt.RegisteredClaims, error) {
token, err := jwt.ParseWithClaims(tokenString, &jwt.RegisteredClaims{}, func(token *jwt.Token) (any, error) {
return s.getPublicKeyRaw()
}, jwt.WithIssuer(common.EnvConfig.AppURL))
func (s *JwtService) VerifyIdToken(tokenString string, acceptExpiredTokens bool) (jwt.Token, error) {
alg, _ := s.privateKey.Algorithm()
if err != nil && !errors.Is(err, jwt.ErrTokenExpired) {
return nil, errors.New("couldn't handle this token")
opts := make([]jwt.ParseOption, 0)
// These options are always present
opts = append(opts,
jwt.WithValidate(true),
jwt.WithKey(alg, s.privateKey),
jwt.WithAcceptableSkew(clockSkew),
jwt.WithIssuer(common.EnvConfig.AppURL),
)
// By default, jwt.Parse includes 3 default validators for "nbf", "iat", and "exp"
// In case we want to accept expired tokens (during logout), we need to set the validators explicitly without validating "exp"
if acceptExpiredTokens {
// This is equivalent to the default validators except it doesn't validate "exp"
opts = append(opts,
jwt.WithResetValidators(true),
jwt.WithValidator(jwt.IsIssuedAtValid()),
jwt.WithValidator(jwt.IsNbfValid()),
)
}
claims, isValid := token.Claims.(*jwt.RegisteredClaims)
if !isValid {
return nil, errors.New("can't parse claims")
token, err := jwt.ParseString(tokenString, opts...)
if err != nil {
return nil, fmt.Errorf("failed to parse token: %w", err)
}
return claims, nil
return token, nil
}
func (s *JwtService) GenerateOauthAccessToken(user model.User, clientID string) (string, error) {
claim := jwt.RegisteredClaims{
Subject: user.ID,
ExpiresAt: jwt.NewNumericDate(time.Now().Add(1 * time.Hour)),
IssuedAt: jwt.NewNumericDate(time.Now()),
Audience: jwt.ClaimStrings{clientID},
Issuer: common.EnvConfig.AppURL,
}
token := jwt.NewWithClaims(jwt.SigningMethodRS256, claim)
token.Header["kid"] = s.keyId
var privateKeyRaw any
err := jwk.Export(s.privateKey, &privateKeyRaw)
now := time.Now()
token, err := jwt.NewBuilder().
Subject(user.ID).
Expiration(now.Add(1 * time.Hour)).
IssuedAt(now).
Issuer(common.EnvConfig.AppURL).
Build()
if err != nil {
return "", fmt.Errorf("failed to export private key object: %w", err)
return "", fmt.Errorf("failed to build token: %w", err)
}
return token.SignedString(privateKeyRaw)
err = SetAudienceString(token, clientID)
if err != nil {
return "", fmt.Errorf("failed to set 'aud' 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) VerifyOauthAccessToken(tokenString string) (*jwt.RegisteredClaims, error) {
token, err := jwt.ParseWithClaims(tokenString, &jwt.RegisteredClaims{}, func(token *jwt.Token) (any, error) {
return s.getPublicKeyRaw()
})
if err != nil || !token.Valid {
return nil, errors.New("couldn't handle this token")
func (s *JwtService) VerifyOauthAccessToken(tokenString string) (jwt.Token, 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),
)
if err != nil {
return nil, fmt.Errorf("failed to parse token: %w", err)
}
claims, isValid := token.Claims.(*jwt.RegisteredClaims)
if !isValid {
return nil, errors.New("can't parse claims")
}
return claims, nil
return token, nil
}
// GetPublicJWK returns the JSON Web Key (JWK) for the public key.
@@ -325,17 +356,18 @@ func (s *JwtService) GetPublicJWKSAsJSON() ([]byte, error) {
return s.jwksEncoded, nil
}
func (s *JwtService) getPublicKeyRaw() (any, error) {
pubKey, err := s.privateKey.PublicKey()
if err != nil {
return nil, fmt.Errorf("failed to get public key: %w", err)
// GetKeyAlg returns the algorithm of the key
func (s *JwtService) GetKeyAlg() (jwa.KeyAlgorithm, error) {
if len(s.jwksEncoded) == 0 {
return nil, errors.New("key is not initialized")
}
var pubKeyRaw any
err = jwk.Export(pubKey, &pubKeyRaw)
if err != nil {
return nil, fmt.Errorf("failed to export raw public key: %w", err)
alg, ok := s.privateKey.Algorithm()
if !ok || alg == nil {
return nil, errors.New("failed to retrieve algorithm for key")
}
return pubKeyRaw, nil
return alg, nil
}
func (s *JwtService) loadKeyJWK(path string) (jwk.Key, error) {
@@ -438,3 +470,28 @@ func generateRandomKeyID() (string, error) {
}
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) {
return false, nil
}
var isAdmin bool
err := token.Get(IsAdminClaim, &isAdmin)
return isAdmin, err
}
// SetIsAdmin sets the "isAdmin" claim in the token
func SetIsAdmin(token jwt.Token, isAdmin bool) error {
// Only set if true
if !isAdmin {
return nil
}
return token.Set(IsAdminClaim, isAdmin)
}
// SetAudienceString sets the "aud" claim with a value that is a string, and not an array
// This is permitted by RFC 7519, and it's done here for backwards-compatibility
func SetAudienceString(token jwt.Token, audience string) error {
return token.Set(jwt.AudienceKey, audience)
}

View File

@@ -2,10 +2,13 @@ package service
import (
"crypto/ecdsa"
"crypto/ed25519"
"crypto/elliptic"
"crypto/rand"
"crypto/rsa"
"os"
"path/filepath"
"sync"
"testing"
"time"
@@ -20,16 +23,19 @@ import (
)
func TestJwtService_Init(t *testing.T) {
mockConfig := &AppConfigService{
DbConfig: &model.AppConfig{
SessionDuration: model.AppConfigVariable{Value: "60"}, // 60 minutes
},
}
t.Run("should generate new key when none exists", func(t *testing.T) {
// Create a temporary directory for the test
tempDir := t.TempDir()
// Create a mock AppConfigService
appConfigService := &AppConfigService{}
// Initialize the JWT service
service := &JwtService{}
err := service.init(appConfigService, tempDir)
err := service.init(mockConfig, tempDir)
require.NoError(t, err, "Failed to initialize JWT service")
// Verify the private key was set
@@ -38,7 +44,7 @@ func TestJwtService_Init(t *testing.T) {
// Verify the key has been saved to disk as JWK
jwkPath := filepath.Join(tempDir, PrivateKeyFile)
_, err = os.Stat(jwkPath)
assert.NoError(t, err, "JWK file should exist")
require.NoError(t, err, "JWK file should exist")
// Verify the generated key is valid
keyData, err := os.ReadFile(jwkPath)
@@ -62,7 +68,7 @@ func TestJwtService_Init(t *testing.T) {
// First create a service to generate a key
firstService := &JwtService{}
err := firstService.init(&AppConfigService{}, tempDir)
err := firstService.init(mockConfig, tempDir)
require.NoError(t, err)
// Get the key ID of the first service
@@ -71,7 +77,7 @@ func TestJwtService_Init(t *testing.T) {
// Now create a new service that should load the existing key
secondService := &JwtService{}
err = secondService.init(&AppConfigService{}, tempDir)
err = secondService.init(mockConfig, tempDir)
require.NoError(t, err)
// Verify the loaded key has the same ID as the original
@@ -80,33 +86,72 @@ func TestJwtService_Init(t *testing.T) {
assert.Equal(t, origKeyID, loadedKeyID, "Loaded key should have the same ID as the original")
})
t.Run("should load existing JWK for EC keys", func(t *testing.T) {
t.Run("should load existing JWK for ECDSA keys", func(t *testing.T) {
// Create a temporary directory for the test
tempDir := t.TempDir()
// Create a new JWK and save it to disk
origKeyID := createECKeyJWK(t, tempDir)
origKeyID := createECDSAKeyJWK(t, tempDir)
// Now create a new service that should load the existing key
svc := &JwtService{}
err := svc.init(&AppConfigService{}, tempDir)
err := svc.init(mockConfig, tempDir)
require.NoError(t, err)
// Ensure loaded key has the right algorithm
alg, ok := svc.privateKey.Algorithm()
_ = assert.True(t, ok) &&
assert.Equal(t, jwa.ES256().String(), alg.String(), "Loaded key has the incorrect algorithm")
// Verify the loaded key has the same ID as the original
loadedKeyID, ok := svc.privateKey.KeyID()
require.True(t, ok)
assert.Equal(t, origKeyID, loadedKeyID, "Loaded key should have the same ID as the original")
_ = assert.True(t, ok) &&
assert.Equal(t, origKeyID, loadedKeyID, "Loaded key should have the same ID as the original")
})
t.Run("should load existing JWK for EdDSA keys", func(t *testing.T) {
// Create a temporary directory for the test
tempDir := t.TempDir()
// Create a new JWK and save it to disk
origKeyID := createEdDSAKeyJWK(t, tempDir)
// Now create a new service that should load the existing key
svc := &JwtService{}
err := svc.init(mockConfig, tempDir)
require.NoError(t, err)
// Ensure loaded key has the right algorithm and curve
alg, ok := svc.privateKey.Algorithm()
_ = assert.True(t, ok) &&
assert.Equal(t, jwa.EdDSA().String(), alg.String(), "Loaded key has the incorrect algorithm")
var curve jwa.EllipticCurveAlgorithm
err = svc.privateKey.Get("crv", &curve)
_ = assert.NoError(t, err, "Failed to get 'crv' claim") &&
assert.Equal(t, jwa.Ed25519().String(), curve.String(), "Curve does not match expected value")
// Verify the loaded key has the same ID as the original
loadedKeyID, ok := svc.privateKey.KeyID()
_ = assert.True(t, ok) &&
assert.Equal(t, origKeyID, loadedKeyID, "Loaded key should have the same ID as the original")
})
}
func TestJwtService_GetPublicJWK(t *testing.T) {
mockConfig := &AppConfigService{
DbConfig: &model.AppConfig{
SessionDuration: model.AppConfigVariable{Value: "60"}, // 60 minutes
},
}
t.Run("returns public key when private key is initialized", func(t *testing.T) {
// Create a temporary directory for the test
tempDir := t.TempDir()
// Create a JWT service with initialized key
service := &JwtService{}
err := service.init(&AppConfigService{}, tempDir)
err := service.init(mockConfig, tempDir)
require.NoError(t, err, "Failed to initialize JWT service")
// Get the JWK (public key)
@@ -136,11 +181,11 @@ func TestJwtService_GetPublicJWK(t *testing.T) {
tempDir := t.TempDir()
// Create an ECDSA key and save it as JWK
originalKeyID := createECKeyJWK(t, tempDir)
originalKeyID := createECDSAKeyJWK(t, tempDir)
// Create a JWT service that loads the ECDSA key
service := &JwtService{}
err := service.init(&AppConfigService{}, tempDir)
err := service.init(mockConfig, tempDir)
require.NoError(t, err, "Failed to initialize JWT service")
// Get the JWK (public key)
@@ -169,6 +214,44 @@ func TestJwtService_GetPublicJWK(t *testing.T) {
assert.Equal(t, "ES256", alg.String(), "Algorithm should be ES256")
})
t.Run("returns public key when EdDSA private key is initialized", func(t *testing.T) {
// Create a temporary directory for the test
tempDir := t.TempDir()
// Create an EdDSA key and save it as JWK
originalKeyID := createEdDSAKeyJWK(t, tempDir)
// Create a JWT service that loads the EdDSA key
service := &JwtService{}
err := service.init(mockConfig, tempDir)
require.NoError(t, err, "Failed to initialize JWT service")
// Get the JWK (public key)
publicKey, err := service.GetPublicJWK()
require.NoError(t, err, "GetPublicJWK should not return an error when private key is initialized")
// Verify the returned key is valid
require.NotNil(t, publicKey, "Public key should not be nil")
// Validate it's actually a public key
isPrivate, err := jwk.IsPrivateKey(publicKey)
require.NoError(t, err)
assert.False(t, isPrivate, "Returned key should be a public key")
// Check that key has required properties
keyID, ok := publicKey.KeyID()
require.True(t, ok, "Public key should have a key ID")
assert.Equal(t, originalKeyID, keyID, "Key ID should match the original key ID")
// Check that the key type is OKP
assert.Equal(t, "OKP", publicKey.KeyType().String(), "Key type should be OKP")
// Check that the algorithm is EdDSA
alg, ok := publicKey.Algorithm()
require.True(t, ok, "Public key should have an algorithm")
assert.Equal(t, "EdDSA", alg.String(), "Algorithm should be EdDSA")
})
t.Run("returns error when private key is not initialized", func(t *testing.T) {
// Create a service with nil private key
service := &JwtService{
@@ -228,15 +311,22 @@ func TestGenerateVerifyAccessToken(t *testing.T) {
require.NoError(t, err, "Failed to verify generated token")
// Check the claims
assert.Equal(t, user.ID, claims.Subject, "Token subject should match user ID")
assert.Equal(t, false, claims.IsAdmin, "IsAdmin should be false")
assert.Contains(t, claims.Audience, "https://test.example.com", "Audience should contain the app URL")
subject, ok := claims.Subject()
_ = assert.True(t, ok, "User ID not found in token") &&
assert.Equal(t, user.ID, subject, "Token subject should match user ID")
isAdmin, err := GetIsAdmin(claims)
_ = assert.NoError(t, err, "Failed to get isAdmin claim") &&
assert.False(t, isAdmin, "isAdmin should be false")
audience, ok := claims.Audience()
_ = assert.True(t, ok, "Audience not found in token") &&
assert.Equal(t, []string{"https://test.example.com"}, audience, "Audience should contain the app URL")
// Check token expiration time is approximately 60 minutes from now
expectedExp := time.Now().Add(60 * time.Minute)
tokenExp := claims.ExpiresAt.Time
timeDiff := expectedExp.Sub(tokenExp).Minutes()
assert.InDelta(t, 0, timeDiff, 1.0, "Token should expire in approximately 60 minutes")
// Check token expiration time is approximately 1 hour from now
expectedExp := time.Now().Add(1 * time.Hour)
expiration, ok := claims.Expiration()
assert.True(t, ok, "Expiration not found in token")
timeDiff := expectedExp.Sub(expiration).Minutes()
assert.InDelta(t, 0, timeDiff, 1.0, "Token should expire in approximately 1 hour")
})
t.Run("generates token for admin user", func(t *testing.T) {
@@ -263,8 +353,12 @@ func TestGenerateVerifyAccessToken(t *testing.T) {
require.NoError(t, err, "Failed to verify generated token")
// Check the IsAdmin claim is true
assert.Equal(t, true, claims.IsAdmin, "IsAdmin should be true for admin users")
assert.Equal(t, adminUser.ID, claims.Subject, "Token subject should match admin ID")
isAdmin, err := GetIsAdmin(claims)
_ = assert.NoError(t, err, "Failed to get isAdmin claim") &&
assert.True(t, isAdmin, "isAdmin should be true")
subject, ok := claims.Subject()
_ = assert.True(t, ok, "User ID not found in token") &&
assert.Equal(t, adminUser.ID, subject, "Token subject should match user ID")
})
t.Run("uses session duration from config", func(t *testing.T) {
@@ -296,10 +390,173 @@ func TestGenerateVerifyAccessToken(t *testing.T) {
// Check token expiration time is approximately 30 minutes from now
expectedExp := time.Now().Add(30 * time.Minute)
tokenExp := claims.ExpiresAt.Time
timeDiff := expectedExp.Sub(tokenExp).Minutes()
expiration, ok := claims.Expiration()
assert.True(t, ok, "Expiration not found in token")
timeDiff := expectedExp.Sub(expiration).Minutes()
assert.InDelta(t, 0, timeDiff, 1.0, "Token should expire in approximately 30 minutes")
})
t.Run("works with Ed25519 keys", func(t *testing.T) {
// Create a temporary directory for the test
tempDir := t.TempDir()
// Create an Ed25519 key and save it as JWK
origKeyID := createEdDSAKeyJWK(t, tempDir)
// Create a JWT service that loads the key
service := &JwtService{}
err := service.init(mockConfig, tempDir)
require.NoError(t, err, "Failed to initialize JWT service")
// Verify it loaded the right key
loadedKeyID, ok := service.privateKey.KeyID()
require.True(t, ok)
assert.Equal(t, origKeyID, loadedKeyID, "Loaded key should have the same ID as the original")
// Create a test user
user := model.User{
Base: model.Base{
ID: "eddsauser123",
},
Email: "eddsauser@example.com",
IsAdmin: true,
}
// Generate a token
tokenString, err := service.GenerateAccessToken(user)
require.NoError(t, err, "Failed to generate access token with Ed25519 key")
assert.NotEmpty(t, tokenString, "Token should not be empty")
// Verify the token
claims, err := service.VerifyAccessToken(tokenString)
require.NoError(t, err, "Failed to verify generated token with Ed25519 key")
// Check the claims
subject, ok := claims.Subject()
_ = assert.True(t, ok, "User ID not found in token") &&
assert.Equal(t, user.ID, subject, "Token subject should match user ID")
isAdmin, err := GetIsAdmin(claims)
_ = assert.NoError(t, err, "Failed to get isAdmin claim") &&
assert.True(t, isAdmin, "isAdmin should be true")
// Verify the key type is OKP
publicKey, err := service.GetPublicJWK()
require.NoError(t, err)
assert.Equal(t, "OKP", publicKey.KeyType().String(), "Key type should be OKP")
// Verify the algorithm is EdDSA
alg, ok := publicKey.Algorithm()
require.True(t, ok)
assert.Equal(t, "EdDSA", alg.String(), "Algorithm should be EdDSA")
})
t.Run("works with P-256 keys", func(t *testing.T) {
// Create a temporary directory for the test
tempDir := t.TempDir()
// Create an ECDSA key and save it as JWK
origKeyID := createECDSAKeyJWK(t, tempDir)
// Create a JWT service that loads the key
service := &JwtService{}
err := service.init(mockConfig, tempDir)
require.NoError(t, err, "Failed to initialize JWT service")
// Verify it loaded the right key
loadedKeyID, ok := service.privateKey.KeyID()
require.True(t, ok)
assert.Equal(t, origKeyID, loadedKeyID, "Loaded key should have the same ID as the original")
// Create a test user
user := model.User{
Base: model.Base{
ID: "ecdsauser123",
},
Email: "ecdsauser@example.com",
IsAdmin: true,
}
// Generate a token
tokenString, err := service.GenerateAccessToken(user)
require.NoError(t, err, "Failed to generate access token with ECDSA key")
assert.NotEmpty(t, tokenString, "Token should not be empty")
// Verify the token
claims, err := service.VerifyAccessToken(tokenString)
require.NoError(t, err, "Failed to verify generated token with ECDSA key")
// Check the claims
subject, ok := claims.Subject()
_ = assert.True(t, ok, "User ID not found in token") &&
assert.Equal(t, user.ID, subject, "Token subject should match user ID")
isAdmin, err := GetIsAdmin(claims)
_ = assert.NoError(t, err, "Failed to get isAdmin claim") &&
assert.True(t, isAdmin, "isAdmin should be true")
// Verify the key type is EC
publicKey, err := service.GetPublicJWK()
require.NoError(t, err)
assert.Equal(t, jwa.EC().String(), publicKey.KeyType().String(), "Key type should be EC")
// Verify the algorithm is ES256
alg, ok := publicKey.Algorithm()
require.True(t, ok)
assert.Equal(t, jwa.ES256().String(), alg.String(), "Algorithm should be ES256")
})
t.Run("works with RSA-4096 keys", func(t *testing.T) {
// Create a temporary directory for the test
tempDir := t.TempDir()
// Create an RSA-4096 key and save it as JWK
origKeyID := createRSA4096KeyJWK(t, tempDir)
// Create a JWT service that loads the key
service := &JwtService{}
err := service.init(mockConfig, tempDir)
require.NoError(t, err, "Failed to initialize JWT service")
// Verify it loaded the right key
loadedKeyID, ok := service.privateKey.KeyID()
require.True(t, ok)
assert.Equal(t, origKeyID, loadedKeyID, "Loaded key should have the same ID as the original")
// Create a test user
user := model.User{
Base: model.Base{
ID: "rsauser123",
},
Email: "rsauser@example.com",
IsAdmin: true,
}
// Generate a token
tokenString, err := service.GenerateAccessToken(user)
require.NoError(t, err, "Failed to generate access token with RSA key")
assert.NotEmpty(t, tokenString, "Token should not be empty")
// Verify the token
claims, err := service.VerifyAccessToken(tokenString)
require.NoError(t, err, "Failed to verify generated token with RSA key")
// Check the claims
subject, ok := claims.Subject()
_ = assert.True(t, ok, "User ID not found in token") &&
assert.Equal(t, user.ID, subject, "Token subject should match user ID")
isAdmin, err := GetIsAdmin(claims)
_ = assert.NoError(t, err, "Failed to get isAdmin claim") &&
assert.True(t, isAdmin, "isAdmin should be true")
// Verify the key type is RSA
publicKey, err := service.GetPublicJWK()
require.NoError(t, err)
assert.Equal(t, jwa.RSA().String(), publicKey.KeyType().String(), "Key type should be RSA")
// Verify the algorithm is RS256
alg, ok := publicKey.Algorithm()
require.True(t, ok)
assert.Equal(t, jwa.RS256().String(), alg.String(), "Algorithm should be RS256")
})
}
func TestGenerateVerifyIdToken(t *testing.T) {
@@ -340,21 +597,83 @@ func TestGenerateVerifyIdToken(t *testing.T) {
assert.NotEmpty(t, tokenString, "Token should not be empty")
// Verify the token
claims, err := service.VerifyIdToken(tokenString)
claims, err := service.VerifyIdToken(tokenString, false)
require.NoError(t, err, "Failed to verify generated ID token")
// Check the claims
assert.Equal(t, "user123", claims.Subject, "Token subject should match user ID")
assert.Contains(t, claims.Audience, clientID, "Audience should contain the client ID")
assert.Equal(t, common.EnvConfig.AppURL, claims.Issuer, "Issuer should match app URL")
subject, ok := claims.Subject()
_ = assert.True(t, ok, "User ID not found in token") &&
assert.Equal(t, "user123", subject, "Token subject should match user ID")
audience, ok := claims.Audience()
_ = assert.True(t, ok, "Audience not found in token") &&
assert.Equal(t, []string{clientID}, audience, "Audience should contain the client ID")
issuer, ok := claims.Issuer()
_ = assert.True(t, ok, "Issuer not found in token") &&
assert.Equal(t, common.EnvConfig.AppURL, issuer, "Issuer should match app URL")
// Check token expiration time is approximately 1 hour from now
expectedExp := time.Now().Add(1 * time.Hour)
tokenExp := claims.ExpiresAt.Time
timeDiff := expectedExp.Sub(tokenExp).Minutes()
expiration, ok := claims.Expiration()
assert.True(t, ok, "Expiration not found in token")
timeDiff := expectedExp.Sub(expiration).Minutes()
assert.InDelta(t, 0, timeDiff, 1.0, "Token should expire in approximately 1 hour")
})
t.Run("can accept expired tokens if told so", 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 test claims
userClaims := map[string]interface{}{
"sub": "user123",
"name": "Test User",
"email": "user@example.com",
}
const clientID = "test-client-123"
// Create a token that's already expired
token, err := jwt.NewBuilder().
Subject(userClaims["sub"].(string)).
Issuer(common.EnvConfig.AppURL).
Audience([]string{clientID}).
IssuedAt(time.Now().Add(-2 * time.Hour)).
Expiration(time.Now().Add(-1 * time.Hour)). // Expired 1 hour ago
Build()
require.NoError(t, err, "Failed to build token")
// Add custom claims
for k, v := range userClaims {
if k != "sub" { // Already set above
err = token.Set(k, v)
require.NoError(t, err, "Failed to set claim")
}
}
// Sign the token
signed, err := jwt.Sign(token, jwt.WithKey(jwa.RS256(), service.privateKey))
require.NoError(t, err, "Failed to sign token")
tokenString := string(signed)
// Verify the token without allowExpired flag - should fail
_, err = service.VerifyIdToken(tokenString, false)
require.Error(t, err, "Verification should fail with expired token when not allowing expired tokens")
assert.Contains(t, err.Error(), `"exp" not satisfied`, "Error message should indicate token verification failure")
// Verify the token with allowExpired flag - should succeed
claims, err := service.VerifyIdToken(tokenString, true)
require.NoError(t, err, "Verification should succeed with expired token when allowing expired tokens")
// Validate the claims
subject, ok := claims.Subject()
_ = assert.True(t, ok, "User ID not found in token") &&
assert.Equal(t, userClaims["sub"], subject, "Token subject should match user ID")
issuer, ok := claims.Issuer()
_ = assert.True(t, ok, "Issuer not found in token") &&
assert.Equal(t, common.EnvConfig.AppURL, issuer, "Issuer should match app URL")
})
t.Run("generates and verifies ID token with nonce", func(t *testing.T) {
// Create a JWT service
service := &JwtService{}
@@ -403,9 +722,168 @@ func TestGenerateVerifyIdToken(t *testing.T) {
common.EnvConfig.AppURL = "https://wrong-issuer.com"
// Verify should fail due to issuer mismatch
_, err = service.VerifyIdToken(tokenString)
assert.Error(t, err, "Verification should fail with incorrect issuer")
assert.Contains(t, err.Error(), "couldn't handle this token", "Error message should indicate token verification failure")
_, err = service.VerifyIdToken(tokenString, false)
require.Error(t, err, "Verification should fail with incorrect issuer")
assert.Contains(t, err.Error(), `"iss" not satisfied`, "Error message should indicate token verification failure")
})
t.Run("works with Ed25519 keys", func(t *testing.T) {
// Create a temporary directory for the test
tempDir := t.TempDir()
// Create an Ed25519 key and save it as JWK
origKeyID := createEdDSAKeyJWK(t, tempDir)
// Create a JWT service that loads the key
service := &JwtService{}
err := service.init(mockConfig, tempDir)
require.NoError(t, err, "Failed to initialize JWT service")
// Verify it loaded the right key
loadedKeyID, ok := service.privateKey.KeyID()
require.True(t, ok)
assert.Equal(t, origKeyID, loadedKeyID, "Loaded key should have the same ID as the original")
// Create test claims
userClaims := map[string]interface{}{
"sub": "eddsauser456",
"name": "EdDSA User",
"email": "eddsauser@example.com",
}
const clientID = "eddsa-client-123"
// Generate a token
tokenString, err := service.GenerateIDToken(userClaims, clientID, "")
require.NoError(t, err, "Failed to generate ID token with key")
assert.NotEmpty(t, tokenString, "Token should not be empty")
// Verify the token
claims, err := service.VerifyIdToken(tokenString, false)
require.NoError(t, err, "Failed to verify generated ID token with key")
// Check the claims
subject, ok := claims.Subject()
_ = assert.True(t, ok, "User ID not found in token") &&
assert.Equal(t, "eddsauser456", subject, "Token subject should match user ID")
issuer, ok := claims.Issuer()
_ = assert.True(t, ok, "Issuer not found in token") &&
assert.Equal(t, common.EnvConfig.AppURL, issuer, "Issuer should match app URL")
// Verify the key type is OKP
publicKey, err := service.GetPublicJWK()
require.NoError(t, err)
assert.Equal(t, jwa.OKP().String(), publicKey.KeyType().String(), "Key type should be OKP")
// Verify the algorithm is EdDSA
alg, ok := publicKey.Algorithm()
require.True(t, ok)
assert.Equal(t, jwa.EdDSA().String(), alg.String(), "Algorithm should be EdDSA")
})
t.Run("works with P-256 keys", func(t *testing.T) {
// Create a temporary directory for the test
tempDir := t.TempDir()
// Create an ECDSA key and save it as JWK
origKeyID := createECDSAKeyJWK(t, tempDir)
// Create a JWT service that loads the key
service := &JwtService{}
err := service.init(mockConfig, tempDir)
require.NoError(t, err, "Failed to initialize JWT service")
// Verify it loaded the right key
loadedKeyID, ok := service.privateKey.KeyID()
require.True(t, ok)
assert.Equal(t, origKeyID, loadedKeyID, "Loaded key should have the same ID as the original")
// Create test claims
userClaims := map[string]interface{}{
"sub": "ecdsauser456",
"name": "ECDSA User",
"email": "ecdsauser@example.com",
}
const clientID = "ecdsa-client-123"
// Generate a token
tokenString, err := service.GenerateIDToken(userClaims, clientID, "")
require.NoError(t, err, "Failed to generate ID token with key")
assert.NotEmpty(t, tokenString, "Token should not be empty")
// Verify the token
claims, err := service.VerifyIdToken(tokenString, false)
require.NoError(t, err, "Failed to verify generated ID token with key")
// Check the claims
subject, ok := claims.Subject()
_ = assert.True(t, ok, "User ID not found in token") &&
assert.Equal(t, "ecdsauser456", subject, "Token subject should match user ID")
issuer, ok := claims.Issuer()
_ = assert.True(t, ok, "Issuer not found in token") &&
assert.Equal(t, common.EnvConfig.AppURL, issuer, "Issuer should match app URL")
// Verify the key type is EC
publicKey, err := service.GetPublicJWK()
require.NoError(t, err)
assert.Equal(t, jwa.EC().String(), publicKey.KeyType().String(), "Key type should be EC")
// Verify the algorithm is ES256
alg, ok := publicKey.Algorithm()
require.True(t, ok)
assert.Equal(t, jwa.ES256().String(), alg.String(), "Algorithm should be ES256")
})
t.Run("works with RSA-4096 keys", func(t *testing.T) {
// Create a temporary directory for the test
tempDir := t.TempDir()
// Create an RSA-4096 key and save it as JWK
origKeyID := createRSA4096KeyJWK(t, tempDir)
// Create a JWT service that loads the key
service := &JwtService{}
err := service.init(mockConfig, tempDir)
require.NoError(t, err, "Failed to initialize JWT service")
// Verify it loaded the right key
loadedKeyID, ok := service.privateKey.KeyID()
require.True(t, ok)
assert.Equal(t, origKeyID, loadedKeyID, "Loaded key should have the same ID as the original")
// Create test claims
userClaims := map[string]interface{}{
"sub": "rsauser456",
"name": "RSA User",
"email": "rsauser@example.com",
}
const clientID = "rsa-client-123"
// Generate a token
tokenString, err := service.GenerateIDToken(userClaims, clientID, "")
require.NoError(t, err, "Failed to generate ID token with key")
assert.NotEmpty(t, tokenString, "Token should not be empty")
// Verify the token
claims, err := service.VerifyIdToken(tokenString, false)
require.NoError(t, err, "Failed to verify generated ID token with key")
// Check the claims
subject, ok := claims.Subject()
_ = assert.True(t, ok, "User ID not found in token") &&
assert.Equal(t, "rsauser456", subject, "Token subject should match user ID")
issuer, ok := claims.Issuer()
_ = assert.True(t, ok, "Issuer not found in token") &&
assert.Equal(t, common.EnvConfig.AppURL, issuer, "Issuer should match app URL")
// Verify the key type is RSA
publicKey, err := service.GetPublicJWK()
require.NoError(t, err)
assert.Equal(t, jwa.RSA().String(), publicKey.KeyType().String(), "Key type should be RSA")
// Verify the algorithm is RS256
alg, ok := publicKey.Algorithm()
require.True(t, ok)
assert.Equal(t, jwa.RS256().String(), alg.String(), "Algorithm should be RS256")
})
}
@@ -452,14 +930,21 @@ func TestGenerateVerifyOauthAccessToken(t *testing.T) {
require.NoError(t, err, "Failed to verify generated OAuth access token")
// Check the claims
assert.Equal(t, user.ID, claims.Subject, "Token subject should match user ID")
assert.Contains(t, claims.Audience, clientID, "Audience should contain the client ID")
assert.Equal(t, common.EnvConfig.AppURL, claims.Issuer, "Issuer should match app URL")
subject, ok := claims.Subject()
_ = assert.True(t, ok, "User ID not found in token") &&
assert.Equal(t, user.ID, subject, "Token subject should match user ID")
audience, ok := claims.Audience()
_ = assert.True(t, ok, "Audience not found in token") &&
assert.Equal(t, []string{clientID}, audience, "Audience should contain the client ID")
issuer, ok := claims.Issuer()
_ = assert.True(t, ok, "Issuer not found in token") &&
assert.Equal(t, common.EnvConfig.AppURL, issuer, "Issuer should match app URL")
// Check token expiration time is approximately 1 hour from now
expectedExp := time.Now().Add(1 * time.Hour)
tokenExp := claims.ExpiresAt.Time
timeDiff := expectedExp.Sub(tokenExp).Minutes()
expiration, ok := claims.Expiration()
assert.True(t, ok, "Expiration not found in token")
timeDiff := expectedExp.Sub(expiration).Minutes()
assert.InDelta(t, 0, timeDiff, 1.0, "Token should expire in approximately 1 hour")
})
@@ -492,8 +977,8 @@ func TestGenerateVerifyOauthAccessToken(t *testing.T) {
// Verify should fail due to expiration
_, err = service.VerifyOauthAccessToken(string(signed))
assert.Error(t, err, "Verification should fail with expired token")
assert.Contains(t, err.Error(), "couldn't handle this token", "Error message should indicate token verification failure")
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) {
@@ -520,19 +1005,176 @@ func TestGenerateVerifyOauthAccessToken(t *testing.T) {
// Verify with the second service should fail due to different keys
_, err = service2.VerifyOauthAccessToken(tokenString)
assert.Error(t, err, "Verification should fail with invalid signature")
assert.Contains(t, err.Error(), "couldn't handle this token", "Error message should indicate token verification failure")
require.Error(t, err, "Verification should fail with invalid signature")
assert.Contains(t, err.Error(), "verification error", "Error message should indicate token verification failure")
})
t.Run("works with Ed25519 keys", func(t *testing.T) {
// Create a temporary directory for the test
tempDir := t.TempDir()
// Create an Ed25519 key and save it as JWK
origKeyID := createEdDSAKeyJWK(t, tempDir)
// Create a JWT service that loads the key
service := &JwtService{}
err := service.init(mockConfig, tempDir)
require.NoError(t, err, "Failed to initialize JWT service")
// Verify it loaded the right key
loadedKeyID, ok := service.privateKey.KeyID()
require.True(t, ok)
assert.Equal(t, origKeyID, loadedKeyID, "Loaded key should have the same ID as the original")
// Create a test user
user := model.User{
Base: model.Base{
ID: "eddsauser789",
},
Email: "eddsaoauth@example.com",
}
const clientID = "eddsa-oauth-client"
// Generate a token
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)
require.NoError(t, err, "Failed to verify generated OAuth access token with key")
// Check the claims
subject, ok := claims.Subject()
_ = assert.True(t, ok, "User ID not found in token") &&
assert.Equal(t, user.ID, subject, "Token subject should match user ID")
audience, ok := claims.Audience()
_ = assert.True(t, ok, "Audience not found in token") &&
assert.Equal(t, []string{clientID}, audience, "Audience should contain the client ID")
// Verify the key type is OKP
publicKey, err := service.GetPublicJWK()
require.NoError(t, err)
assert.Equal(t, jwa.OKP().String(), publicKey.KeyType().String(), "Key type should be OKP")
// Verify the algorithm is EdDSA
alg, ok := publicKey.Algorithm()
require.True(t, ok)
assert.Equal(t, jwa.EdDSA().String(), alg.String(), "Algorithm should be EdDSA")
})
t.Run("works with ECDSA keys", func(t *testing.T) {
// Create a temporary directory for the test
tempDir := t.TempDir()
// Create an ECDSA key and save it as JWK
origKeyID := createECDSAKeyJWK(t, tempDir)
// Create a JWT service that loads the key
service := &JwtService{}
err := service.init(mockConfig, tempDir)
require.NoError(t, err, "Failed to initialize JWT service")
// Verify it loaded the right key
loadedKeyID, ok := service.privateKey.KeyID()
require.True(t, ok)
assert.Equal(t, origKeyID, loadedKeyID, "Loaded key should have the same ID as the original")
// Create a test user
user := model.User{
Base: model.Base{
ID: "ecdsauser789",
},
Email: "ecdsaoauth@example.com",
}
const clientID = "ecdsa-oauth-client"
// Generate a token
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)
require.NoError(t, err, "Failed to verify generated OAuth access token with key")
// Check the claims
subject, ok := claims.Subject()
_ = assert.True(t, ok, "User ID not found in token") &&
assert.Equal(t, user.ID, subject, "Token subject should match user ID")
audience, ok := claims.Audience()
_ = assert.True(t, ok, "Audience not found in token") &&
assert.Equal(t, []string{clientID}, audience, "Audience should contain the client ID")
// Verify the key type is EC
publicKey, err := service.GetPublicJWK()
require.NoError(t, err)
assert.Equal(t, jwa.EC().String(), publicKey.KeyType().String(), "Key type should be EC")
// Verify the algorithm is ES256
alg, ok := publicKey.Algorithm()
require.True(t, ok)
assert.Equal(t, jwa.ES256().String(), alg.String(), "Algorithm should be ES256")
})
t.Run("works with RSA-4096 keys", func(t *testing.T) {
// Create a temporary directory for the test
tempDir := t.TempDir()
// Create an RSA-4096 key and save it as JWK
origKeyID := createRSA4096KeyJWK(t, tempDir)
// Create a JWT service that loads the key
service := &JwtService{}
err := service.init(mockConfig, tempDir)
require.NoError(t, err, "Failed to initialize JWT service")
// Verify it loaded the right key
loadedKeyID, ok := service.privateKey.KeyID()
require.True(t, ok)
assert.Equal(t, origKeyID, loadedKeyID, "Loaded key should have the same ID as the original")
// Create a test user
user := model.User{
Base: model.Base{
ID: "rsauser789",
},
Email: "rsaoauth@example.com",
}
const clientID = "rsa-oauth-client"
// Generate a token
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)
require.NoError(t, err, "Failed to verify generated OAuth access token with key")
// Check the claims
subject, ok := claims.Subject()
_ = assert.True(t, ok, "User ID not found in token") &&
assert.Equal(t, user.ID, subject, "Token subject should match user ID")
audience, ok := claims.Audience()
_ = assert.True(t, ok, "Audience not found in token") &&
assert.Equal(t, []string{clientID}, audience, "Audience should contain the client ID")
// Verify the key type is RSA
publicKey, err := service.GetPublicJWK()
require.NoError(t, err)
assert.Equal(t, jwa.RSA().String(), publicKey.KeyType().String(), "Key type should be RSA")
// Verify the algorithm is RS256
alg, ok := publicKey.Algorithm()
require.True(t, ok)
assert.Equal(t, jwa.RS256().String(), alg.String(), "Algorithm should be RS256")
})
}
func createECKeyJWK(t *testing.T, path string) string {
func importKey(t *testing.T, privateKeyRaw any, path string) string {
t.Helper()
// Generate a new P-256 ECDSA key
privateKeyRaw, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
require.NoError(t, err, "Failed to generate ECDSA key")
// Import as JWK and save to disk
privateKey, err := importRawKey(privateKeyRaw)
require.NoError(t, err, "Failed to import private key")
@@ -544,3 +1186,47 @@ func createECKeyJWK(t *testing.T, path string) string {
return kid
}
// Because generating a RSA-406 key isn't immediate, we pre-compute one
var (
rsaKeyPrecomputed *rsa.PrivateKey
rsaKeyPrecomputeOnce sync.Once
)
func createRSA4096KeyJWK(t *testing.T, path string) string {
t.Helper()
rsaKeyPrecomputeOnce.Do(func() {
var err error
rsaKeyPrecomputed, err = rsa.GenerateKey(rand.Reader, 4096)
if err != nil {
panic("failed to precompute RSA key: " + err.Error())
}
})
// Import as JWK and save to disk
return importKey(t, rsaKeyPrecomputed, path)
}
func createECDSAKeyJWK(t *testing.T, path string) string {
t.Helper()
// Generate a new P-256 ECDSA key
privateKeyRaw, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
require.NoError(t, err, "Failed to generate ECDSA key")
// Import as JWK and save to disk
return importKey(t, privateKeyRaw, path)
}
// Helper function to create an Ed25519 key and save it as JWK
func createEdDSAKeyJWK(t *testing.T, path string) string {
t.Helper()
// Generate a new Ed25519 key pair
_, privateKeyRaw, err := ed25519.GenerateKey(rand.Reader)
require.NoError(t, err, "Failed to generate Ed25519 key")
// Import as JWK and save to disk
return importKey(t, privateKeyRaw, path)
}

View File

@@ -2,6 +2,7 @@ package service
import (
"bytes"
"context"
"crypto/tls"
"encoding/base64"
"errors"
@@ -11,6 +12,7 @@ import (
"net/http"
"net/url"
"strings"
"time"
"github.com/go-ldap/ldap/v3"
"github.com/pocket-id/pocket-id/backend/internal/dto"
@@ -30,13 +32,13 @@ func NewLdapService(db *gorm.DB, appConfigService *AppConfigService, userService
}
func (s *LdapService) createClient() (*ldap.Conn, error) {
if s.appConfigService.DbConfig.LdapEnabled.Value != "true" {
if !s.appConfigService.DbConfig.LdapEnabled.IsTrue() {
return nil, fmt.Errorf("LDAP is not enabled")
}
// Setup LDAP connection
ldapURL := s.appConfigService.DbConfig.LdapUrl.Value
skipTLSVerify := s.appConfigService.DbConfig.LdapSkipCertVerify.Value == "true"
client, err := ldap.DialURL(ldapURL, ldap.DialWithTLSConfig(&tls.Config{InsecureSkipVerify: skipTLSVerify}))
skipTLSVerify := s.appConfigService.DbConfig.LdapSkipCertVerify.IsTrue()
client, err := ldap.DialURL(ldapURL, ldap.DialWithTLSConfig(&tls.Config{InsecureSkipVerify: skipTLSVerify})) //nolint:gosec
if err != nil {
return nil, fmt.Errorf("failed to connect to LDAP: %w", err)
}
@@ -65,6 +67,7 @@ func (s *LdapService) SyncAll() error {
return nil
}
//nolint:gocognit
func (s *LdapService) SyncGroups() error {
// Setup LDAP connection
client, err := s.createClient()
@@ -150,6 +153,9 @@ func (s *LdapService) SyncGroups() error {
}
} else {
_, err = s.groupService.Update(databaseGroup.ID, syncGroup, true)
if err != nil {
log.Printf("Error syncing group %s: %s", syncGroup.Name, err)
}
_, err = s.groupService.UpdateUsers(databaseGroup.ID, membersUserId)
if err != nil {
log.Printf("Error syncing group %s: %s", syncGroup.Name, err)
@@ -163,7 +169,7 @@ func (s *LdapService) SyncGroups() error {
// Get all LDAP groups from the database
var ldapGroupsInDb []model.UserGroup
if err := s.db.Find(&ldapGroupsInDb, "ldap_id IS NOT NULL").Select("ldap_id").Error; err != nil {
fmt.Println(fmt.Errorf("failed to fetch groups from database: %v", err))
fmt.Println(fmt.Errorf("failed to fetch groups from database: %w", err))
}
// Delete groups that no longer exist in LDAP
@@ -180,6 +186,7 @@ func (s *LdapService) SyncGroups() error {
return nil
}
//nolint:gocognit
func (s *LdapService) SyncUsers() error {
// Setup LDAP connection
client, err := s.createClient()
@@ -276,13 +283,13 @@ func (s *LdapService) SyncUsers() error {
// Get all LDAP users from the database
var ldapUsersInDb []model.User
if err := s.db.Find(&ldapUsersInDb, "ldap_id IS NOT NULL").Select("ldap_id").Error; err != nil {
fmt.Println(fmt.Errorf("failed to fetch users from database: %v", err))
fmt.Println(fmt.Errorf("failed to fetch users from database: %w", err))
}
// Delete users that no longer exist in LDAP
for _, user := range ldapUsersInDb {
if _, exists := ldapUserIDs[*user.LdapID]; !exists {
if err := s.userService.DeleteUser(user.ID); err != nil {
if err := s.userService.DeleteUser(user.ID, true); err != nil {
log.Printf("Failed to delete user %s with: %v", user.Username, err)
} else {
log.Printf("Deleted user %s", user.Username)
@@ -296,8 +303,15 @@ func (s *LdapService) SaveProfilePicture(userId string, pictureString string) er
var reader io.Reader
if _, err := url.ParseRequestURI(pictureString); err == nil {
// If the photo is a URL, download it
response, err := http.Get(pictureString)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, pictureString, nil)
if err != nil {
return fmt.Errorf("failed to create request: %w", err)
}
response, err := http.DefaultClient.Do(req)
if err != nil {
return fmt.Errorf("failed to download profile picture: %w", err)
}

View File

@@ -209,6 +209,9 @@ func (s *OidcService) createTokenFromAuthorizationCode(code, clientID, clientSec
}
accessToken, err = s.jwtService.GenerateOauthAccessToken(authorizationCodeMetaData.User, clientID)
if err != nil {
return "", "", "", 0, err
}
s.db.Delete(&authorizationCodeMetaData)
@@ -458,7 +461,7 @@ func (s *OidcService) GetUserClaimsForClient(userID string, clientID string) (ma
if strings.Contains(scope, "email") {
claims["email"] = user.Email
claims["email_verified"] = s.appConfigService.DbConfig.EmailsVerified.Value == "true"
claims["email_verified"] = s.appConfigService.DbConfig.EmailsVerified.IsTrue()
}
if strings.Contains(scope, "groups") {
@@ -492,8 +495,8 @@ func (s *OidcService) GetUserClaimsForClient(userID string, clientID string) (ma
for _, customClaim := range customClaims {
// The value of the custom claim can be a JSON object or a string
var jsonValue interface{}
json.Unmarshal([]byte(customClaim.Value), &jsonValue)
if jsonValue != nil {
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 {
@@ -544,21 +547,24 @@ func (s *OidcService) ValidateEndSession(input dto.OidcLogoutDto, userID string)
}
// If the ID token hint is provided, verify the ID token
claims, err := s.jwtService.VerifyIdToken(input.IdTokenHint)
// Here we also accept expired ID tokens, which are fine per spec
token, err := s.jwtService.VerifyIdToken(input.IdTokenHint, true)
if err != nil {
return "", &common.TokenInvalidError{}
}
// If the client ID is provided check if the client ID in the ID token matches the client ID in the request
if input.ClientId != "" && claims.Audience[0] != input.ClientId {
clientID, ok := token.Audience()
if !ok || len(clientID) == 0 {
return "", &common.TokenInvalidError{}
}
if input.ClientId != "" && clientID[0] != input.ClientId {
return "", &common.OidcClientIdNotMatchingError{}
}
clientId := claims.Audience[0]
// Check if the user has authorized the client before
var userAuthorizedOIDCClient model.UserAuthorizedOidcClient
if err := s.db.Preload("Client").First(&userAuthorizedOIDCClient, "client_id = ? AND user_id = ?", clientId, userID).Error; err != nil {
if err := s.db.Preload("Client").First(&userAuthorizedOIDCClient, "client_id = ? AND user_id = ?", clientID[0], userID).Error; err != nil {
return "", &common.OidcMissingAuthorizationError{}
}

View File

@@ -54,7 +54,7 @@ func (s *UserGroupService) Delete(id string) error {
}
// Disallow deleting the group if it is an LDAP group and LDAP is enabled
if group.LdapID != nil && s.appConfigService.DbConfig.LdapEnabled.Value == "true" {
if group.LdapID != nil && s.appConfigService.DbConfig.LdapEnabled.IsTrue() {
return &common.LdapUserGroupUpdateError{}
}
@@ -87,7 +87,7 @@ func (s *UserGroupService) Update(id string, input dto.UserGroupCreateDto, allow
}
// Disallow updating the group if it is an LDAP group and LDAP is enabled
if !allowLdapUpdate && group.LdapID != nil && s.appConfigService.DbConfig.LdapEnabled.Value == "true" {
if !allowLdapUpdate && group.LdapID != nil && s.appConfigService.DbConfig.LdapEnabled.IsTrue() {
return model.UserGroup{}, &common.LdapUserGroupUpdateError{}
}

View File

@@ -121,14 +121,14 @@ func (s *UserService) UpdateProfilePicture(userID string, file io.Reader) error
return nil
}
func (s *UserService) DeleteUser(userID string) error {
func (s *UserService) DeleteUser(userID string, allowLdapDelete bool) error {
var user model.User
if err := s.db.Where("id = ?", userID).First(&user).Error; err != nil {
return err
}
// Disallow deleting the user if it is an LDAP user and LDAP is enabled
if user.LdapID != nil && s.appConfigService.DbConfig.LdapEnabled.IsTrue() {
if !allowLdapDelete && user.LdapID != nil && s.appConfigService.DbConfig.LdapEnabled.IsTrue() {
return &common.LdapUserUpdateError{}
}
@@ -244,7 +244,7 @@ func (s *UserService) CreateOneTimeAccessToken(userID string, expiresAt time.Tim
tokenLength := 16
// If expires at is less than 15 minutes, use an 6 character token instead of 16
if expiresAt.Sub(time.Now()) <= 15*time.Minute {
if time.Until(expiresAt) <= 15*time.Minute {
tokenLength = 6
}

View File

@@ -47,10 +47,8 @@ func TestFormatAAGUID(t *testing.T) {
func TestGetAuthenticatorName(t *testing.T) {
// Reset the aaguidMap for testing
originalMap := aaguidMap
originalOnce := aaguidMapOnce
defer func() {
aaguidMap = originalMap
aaguidMapOnce = originalOnce
}()
// Inject a test AAGUID map

View File

@@ -170,15 +170,13 @@ func (c *Composer) String() string {
func convertRunes(str string) []string {
var enc = make([]string, 0, len(str))
for _, r := range []rune(str) {
if r == ' ' {
for _, r := range str {
switch {
case r == ' ':
enc = append(enc, "_")
} else if isPrintableASCIIRune(r) &&
r != '=' &&
r != '?' &&
r != '_' {
case isPrintableASCIIRune(r) && r != '=' && r != '?' && r != '_':
enc = append(enc, string(r))
} else {
default:
enc = append(enc, string(toHex([]byte(string(r)))))
}
}
@@ -204,7 +202,7 @@ func hex(n byte) byte {
}
func isPrintableASCII(str string) bool {
for _, r := range []rune(str) {
for _, r := range str {
if !unicode.IsPrint(r) || r >= unicode.MaxASCII {
return false
}

View File

@@ -32,7 +32,7 @@ func CreateProfilePicture(file io.Reader) (io.Reader, error) {
go func() {
err = imaging.Encode(pw, img, imaging.PNG)
if err != nil {
_ = pw.CloseWithError(fmt.Errorf("failed to encode image: %v", err))
_ = pw.CloseWithError(fmt.Errorf("failed to encode image: %w", err))
return
}
pw.Close()

View File

@@ -1,8 +1,10 @@
package utils
import (
"gorm.io/gorm"
"reflect"
"strconv"
"gorm.io/gorm"
)
type PaginationResponse struct {
@@ -30,7 +32,7 @@ func PaginateAndSort(sortedPaginationRequest SortedPaginationRequest, query *gor
capitalizedSortColumn := CapitalizeFirstLetter(sort.Column)
sortField, sortFieldFound := reflect.TypeOf(result).Elem().Elem().FieldByName(capitalizedSortColumn)
isSortable := sortField.Tag.Get("sortable") == "true"
isSortable, _ := strconv.ParseBool(sortField.Tag.Get("sortable"))
isValidSortOrder := sort.Direction == "asc" || sort.Direction == "desc"
if sortFieldFound && isSortable && isValidSortOrder {

BIN
backend/main Executable file

Binary file not shown.

View File

@@ -111,7 +111,7 @@
"it_is_recommended_to_add_more_than_one_passkey": "Doporučujeme přidat více než jeden přístupový klíč, aby nedošlo ke ztrátě přístupu k Vašemu účtu.",
"account_details": "Podrobnosti účtu",
"passkeys": "Přístupové klíče",
"manage_your_passkeys_that_you_can_use_to_authenticate_yourself": "Spravujte své přístupový klíč, které můžete použít pro ověření.",
"manage_your_passkeys_that_you_can_use_to_authenticate_yourself": "Spravujte své přístupové klíče, které můžete použít pro ověření.",
"add_passkey": "Přidat přístupový klíč",
"create_a_one_time_login_code_to_sign_in_from_a_different_device_without_a_passkey": "Vytvořte jednorázový přihlašovací kód pro přihlášení z jiného zařízení bez přístupového klíče.",
"create": "Vytvořit",

View File

@@ -26,14 +26,14 @@
"login_background": "Login Hintergrund",
"logo": "Logo",
"login_code": "Anmeldecode",
"create_a_login_code_to_sign_in_without_a_passkey_once": "Erstelle einen Anmeldecode, mit dem sich der Benutzer einmalig ohne Passkey anmelden kann.",
"create_a_login_code_to_sign_in_without_a_passkey_once": "Erzeuge einen Anmeldecode, mit dem sich der Benutzer einmalig ohne Passkey anmelden kann.",
"one_hour": "1 Stunde",
"twelve_hours": "12 Stunden",
"one_day": "1 Tag",
"one_week": "1 Woche",
"one_month": "1 Monat",
"expiration": "Ablaufdatum",
"generate_code": "Code generieren",
"generate_code": "Code erzeugen",
"name": "Name",
"browser_unsupported": "Browser nicht unterstützt",
"this_browser_does_not_support_passkeys": "Dieser Browser unterstützt keine Passkeys. Bitte verwende eine alternative Anmeldemethode.",
@@ -44,7 +44,7 @@
"authenticator_does_not_support_resident_keys": "Der Authentifikator unterstützt keine residenten Schlüssel",
"passkey_was_previously_registered": "Dieser Passkey wurde bereits registriert",
"authenticator_does_not_support_any_of_the_requested_algorithms": "Der Authentifikator unterstützt keinen der angeforderten Algorithmen",
"authenticator_timed_out": "Timeout für den Authentifikator",
"authenticator_timed_out": "Der Authentifikator hat eine Zeitüberschreitung",
"critical_error_occurred_contact_administrator": "Ein kritischer Fehler ist aufgetreten. Bitte kontaktiere deinen Administrator.",
"sign_in_to": "Bei {name} anmelden",
"client_not_found": "Client nicht gefunden",
@@ -71,10 +71,10 @@
"you_are_about_to_sign_in_to_the_initial_admin_account": "Du bist dabei, dich beim initialen Administratorkonto anzumelden. Jeder, der diesen Link hat, kann auf das Konto zugreifen, bis ein Passkey hinzugefügt wird. Bitte richte so schnell wie möglich einen Passkey ein, um unbefugten Zugriff zu verhindern.",
"continue": "Fortsetzen",
"alternative_sign_in": "Alternative Anmeldemethoden",
"if_you_do_not_have_access_to_your_passkey_you_can_sign_in_using_one_of_the_following_methods": "Wenn du keinen Zugang zu deinen Passkey hast, kannst du dich mit einer der folgenden Methoden anmelden.",
"if_you_do_not_have_access_to_your_passkey_you_can_sign_in_using_one_of_the_following_methods": "Wenn du keinen Zugang zu deinem Passkey hast, kannst du dich mit einer der folgenden Methoden anmelden.",
"use_your_passkey_instead": "Deinen Passkey stattdessen verwenden?",
"email_login": "E-Mail Anmeldung",
"enter_a_login_code_to_sign_in": "Gebe einen Anmeldecode zum Anmelden ein.",
"enter_a_login_code_to_sign_in": "Gib einen Anmeldecode zum Anmelden ein.",
"request_a_login_code_via_email": "Login-Code per E-Mail anfordern.",
"go_back": "Zurück",
"an_email_has_been_sent_to_the_provided_email_if_it_exists_in_the_system": "Eine E-Mail wurde an die angegebene E-Mail gesendet, sofern sie im System vorhanden ist.",
@@ -94,7 +94,7 @@
"settings": "Einstellungen",
"update_pocket_id": "Pocket ID aktualisieren",
"powered_by": "Powered by",
"see_your_account_activities_from_the_last_3_months": "Sehe dir deine Kontoaktivitäten der letzten drei Monate an.",
"see_your_account_activities_from_the_last_3_months": "Sieh dir deine Kontoaktivitäten der letzten drei Monate an.",
"time": "Zeit",
"event": "Ereignis",
"approximate_location": "Ungefährer Standort",
@@ -113,7 +113,7 @@
"passkeys": "Passkeys",
"manage_your_passkeys_that_you_can_use_to_authenticate_yourself": "Verwalte deine Passkeys, mit denen du dich authentifizieren kannst.",
"add_passkey": "Passkey hinzufügen",
"create_a_one_time_login_code_to_sign_in_from_a_different_device_without_a_passkey": "Erstelle einen einmaligen Anmeldecode, um dich ohne Passkey von einem anderen Gerät aus anzumelden.",
"create_a_one_time_login_code_to_sign_in_from_a_different_device_without_a_passkey": "Erzeuge einen einmaligen Anmeldecode, um dich ohne Passkey von einem anderen Gerät aus anzumelden.",
"create": "Erzeugen",
"first_name": "Vorname",
"last_name": "Nachname",
@@ -121,7 +121,7 @@
"save": "Speichern",
"username_can_only_contain": "Der Benutzername darf nur Kleinbuchstaben, Ziffern, Unterstriche, Punkte, Bindestriche und das Symbol „@“ enthalten",
"sign_in_using_the_following_code_the_code_will_expire_in_minutes": "Melde dich mit dem folgenden Code an. Der Code läuft in 15 Minuten ab.",
"or_visit": "oder besuchen",
"or_visit": "oder besuche",
"added_on": "Hinzugefügt am",
"rename": "Umbenennen",
"delete": "Löschen",

View File

@@ -17,7 +17,7 @@
"items_per_page": "Éléments par page",
"no_items_found": "Aucune donnée trouvée",
"search": "Rechercher...",
"expand_card": "Expand card",
"expand_card": "Carte d'expansion",
"copied": "Copié",
"click_to_copy": "Cliquer pour copier",
"something_went_wrong": "Quelque chose n'a pas fonctionné",
@@ -196,7 +196,7 @@
"client_configuration": "Configuration du client",
"ldap_url": "URL du serveur LDAP",
"ldap_bind_dn": "LDAP Bind DN",
"ldap_bind_password": "LDAP Bind Password",
"ldap_bind_password": "Attribuer un mot de passe LDAP",
"ldap_base_dn": "LDAP Base DN",
"user_search_filter": "Filtre de recherche utilisateur",
"the_search_filter_to_use_to_search_or_sync_users": "Le filtre de recherche à utiliser pour rechercher/synchroniser les utilisateurs.",
@@ -243,7 +243,7 @@
"back": "Retour",
"user_details_firstname_lastname": "Détails de l'utilisateur {firstName} {lastName}",
"manage_which_groups_this_user_belongs_to": "Gérer les groupes auxquels cet utilisateur appartient.",
"custom_claims": "Custom Claims",
"custom_claims": "Claim personnaliser",
"custom_claims_are_key_value_pairs_that_can_be_used_to_store_additional_information_about_a_user": "Les revendications personnalisées sont des paires clé-valeur qui permettent de stocker des informations supplémentaires sur un utilisateur. Elles seront incluses dans le jeton d'identité (ID token) si la portée 'profile' est demandée.",
"user_group_created_successfully": "Groupe d'utilisateurs créé avec succès",
"create_user_group": "Créer un groupe d'utilisateurs",
@@ -252,7 +252,7 @@
"manage_user_groups": "Gérer les groupes d'utilisateurs",
"friendly_name": "Nom d'affichage",
"name_that_will_be_displayed_in_the_ui": "Nom qui sera affiché dans l'interface utilisateur",
"name_that_will_be_in_the_groups_claim": "Name that will be in the \"groups\" claim",
"name_that_will_be_in_the_groups_claim": "Nommez ce qui sera dans le \"groupe\" claim",
"delete_name": "Supprimer {name}",
"are_you_sure_you_want_to_delete_this_user_group": "Êtes-vous sûr de vouloir supprimer ce groupe d'utilisateurs?",
"user_group_deleted_successfully": "Groupe d'utilisateurs supprimé avec succès",

View File

@@ -0,0 +1,316 @@
{
"$schema": "https://inlang.com/schema/inlang-message-format",
"my_account": "Minha Conta",
"logout": "Sair",
"confirm": "Confirmar",
"key": "Chave",
"value": "Valor",
"remove_custom_claim": "Remove custom claim",
"add_custom_claim": "Add custom claim",
"add_another": "Adicionar outro",
"select_a_date": "Selecione a data",
"select_file": "Selecionar Arquivo",
"profile_picture": "Foto de Perfil",
"profile_picture_is_managed_by_ldap_server": "A foto de perfil é gerenciada pelo servidor LDAP e não pode ser alterada aqui.",
"click_profile_picture_to_upload_custom": "Clique na foto de perfil para enviar uma imagem personalizada dos seus arquivos.",
"image_should_be_in_format": "A imagem deve estar no formato PNG ou JPEG.",
"items_per_page": "Itens por página",
"no_items_found": "Nenhum item encontrado",
"search": "Pesquisar...",
"expand_card": "Expandir cartão",
"copied": "Copiado",
"click_to_copy": "Clique para copiar",
"something_went_wrong": "Algo deu errado",
"go_back_to_home": "Voltar para o início",
"dont_have_access_to_your_passkey": "Don't have access to your passkey?",
"login_background": "Login background",
"logo": "Logo",
"login_code": "Código de Login:",
"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 hora",
"twelve_hours": "12 horas",
"one_day": "1 dia",
"one_week": "1 semana",
"one_month": "1 mês",
"expiration": "Expiração",
"generate_code": "Gerar Código",
"name": "Nome",
"browser_unsupported": "Navegador não suportado",
"this_browser_does_not_support_passkeys": "This browser doesn't support passkeys. Please or use a alternative sign in method.",
"an_unknown_error_occurred": "Ocorreu um erro desconhecido",
"authentication_process_was_aborted": "O processo de autenticação foi abortado",
"error_occurred_with_authenticator": "An error occurred with the authenticator",
"authenticator_does_not_support_discoverable_credentials": "O autenticador não suporta credenciais detectáveis",
"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": "Tempo limite do autenticador atingido",
"critical_error_occurred_contact_administrator": "A critical error occurred. Please contact your administrator.",
"sign_in_to": "Entrar em {name}",
"client_not_found": "Cliente não encontrado",
"client_wants_to_access_the_following_information": "<b>{client}</b> quer acessar as seguintes informações:",
"do_you_want_to_sign_in_to_client_with_your_app_name_account": "Você quer entrar em <b>{client}</b> com a sua conta <b>{appName}</b>?",
"email": "E-mail",
"view_your_email_address": "Ver seu endereço de e-mail",
"profile": "Profile",
"view_your_profile_information": "View your profile information",
"groups": "Grupos",
"view_the_groups_you_are_a_member_of": "View the groups you are a member of",
"cancel": "Cancelar",
"sign_in": "Sign in",
"try_again": "Tentar novamente",
"client_logo": "Logo do Cliente",
"sign_out": "Sign out",
"do_you_want_to_sign_out_of_pocketid_with_the_account": "Do you want to sign out of Pocket ID with the account <b>{username}</b>?",
"sign_in_to_appname": "Entrar em {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": "Autenticar",
"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": "Continuar",
"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 dont'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": "Email 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": "Voltar",
"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": "Enter code",
"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": "Seu e-mail",
"submit": "Submit",
"enter_the_code_you_received_to_sign_in": "Enter the code you received to sign in.",
"code": "Código",
"invalid_redirect_url": "Invalid redirect URL",
"audit_log": "Registro de Auditoria",
"users": "Usuários",
"user_groups": "Grupo de Usuários",
"oidc_clients": "Clientes OIDC",
"api_keys": "Chave de API",
"application_configuration": "Configuração da Aplicação",
"settings": "Configurações",
"update_pocket_id": "Atualizar Pocket ID",
"powered_by": "Fornecido por",
"see_your_account_activities_from_the_last_3_months": "Veja suas atividades de conta dos últimos 3 meses.",
"time": "Time",
"event": "Evento",
"approximate_location": "Localização Aproximada",
"ip_address": "Endereço de IP",
"device": "Dispositivo",
"client": "Cliente",
"unknown": "Desconhecido",
"account_details_updated_successfully": "Detalhes da conta atualizados com sucesso",
"profile_picture_updated_successfully": "Foto do perfil atualizada com sucesso. Pode demorar alguns minutos para atualizar.",
"account_settings": "Configurações de Conta",
"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": "Detalhes da Conta",
"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": "Criar",
"first_name": "Primeiro nome",
"last_name": "Último nome",
"username": "Nome de usuário",
"save": "Salvar",
"username_can_only_contain": "O nome de usuário só pode conter letras minúsculas, números, underscores, pontos, hífens e símbolos '@'",
"sign_in_using_the_following_code_the_code_will_expire_in_minutes": "Faça o login usando o código a seguir. O código irá expirar em 15 minutos.",
"or_visit": "ou visite",
"added_on": "Adicionado em",
"rename": "Renomear",
"delete": "Apagar",
"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": "Descrição",
"api_key": "API Key",
"close": "Fechar",
"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.",
"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",
"revoke": "Revogar",
"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": "Ações",
"images_updated_successfully": "Imagens atualizadas com sucesso",
"general": "Geral",
"enable_email_notifications_to_alert_users_when_a_login_is_detected_from_a_new_device_or_location": "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": "Imagens",
"update": "Update",
"email_configuration_updated_successfully": "Email configuration updated successfully",
"save_changes_question": "Salvar alterações?",
"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": "Salvar e enviar",
"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.",
"allow_users_to_sign_in_with_a_login_code_sent_to_their_email": "Allows users to sign in with a login code sent to their email. This reduces the security significantly as anyone with access to the user's email can gain entry.",
"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": "Desativar",
"sync_now": "Sincronizar agora",
"enable": "Enable",
"user_created_successfully": "User created successfully",
"create_user": "Criar Usuário",
"add_a_new_user_to_appname": "Adicionar um novo usuário para {appName}",
"add_user": "Adicionar Usuário",
"manage_users": "Gerenciar Usuários",
"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": "Editar",
"user_groups_updated_successfully": "User groups updated successfully",
"user_updated_successfully": "User updated successfully",
"custom_claims_updated_successfully": "Custom claims updated successfully",
"back": "Voltar",
"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": "Adicionar Grupo",
"manage_user_groups": "Manage User Groups",
"friendly_name": "Nome Amigável",
"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\n\t\t\t\thave lost it.",
"add": "Adicionar",
"callback_urls": "Callback URLs",
"logout_callback_urls": "Logout Callback URLs",
"public_client": "Public Client",
"public_clients_do_not_have_a_client_secret_and_use_pkce_instead": "Public clients do not have a client secret and use PKCE instead. Enable this if your client is a SPA or mobile app.",
"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": "Habilitado",
"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": "Idioma",
"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": "Redefinir",
"reset_to_default": "Redefinir para o padrão",
"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."
}

View File

@@ -1,12 +1,12 @@
{
"name": "pocket-id-frontend",
"version": "0.43.1",
"version": "0.44.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "pocket-id-frontend",
"version": "0.43.1",
"version": "0.44.0",
"dependencies": {
"@simplewebauthn/browser": "^13.1.0",
"@tailwindcss/vite": "^4.0.0",
@@ -26,6 +26,8 @@
},
"devDependencies": {
"@inlang/paraglide-js": "^2.0.0",
"@inlang/plugin-m-function-matcher": "^2.0.7",
"@inlang/plugin-message-format": "^4.0.0",
"@internationalized/date": "^3.7.0",
"@playwright/test": "^1.50.0",
"@sveltejs/adapter-auto": "^4.0.0",
@@ -810,6 +812,24 @@
"dev": true,
"license": "MIT"
},
"node_modules/@inlang/plugin-m-function-matcher": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/@inlang/plugin-m-function-matcher/-/plugin-m-function-matcher-2.0.7.tgz",
"integrity": "sha512-o3xGL4BTWOcM/j2WvBcLNHqkHWKWOKdwQED5x3j6+NeFmbkaEioOTPo5FFWZUeWpNnUMn6aJnmfnLLUomO1Jug==",
"dev": true,
"dependencies": {
"@inlang/sdk": "2.4.5"
}
},
"node_modules/@inlang/plugin-message-format": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/@inlang/plugin-message-format/-/plugin-message-format-4.0.0.tgz",
"integrity": "sha512-zNpLxLTt+bDd3JLXj1ONzo+Q6AOzz2MfcgGo8XB6/bweGhFIndK3GU/q0iU4o7VI4KS1+OHNLpKwFcrAifwERQ==",
"dev": true,
"dependencies": {
"flat": "^6.0.1"
}
},
"node_modules/@inlang/recommend-sherlock": {
"version": "0.2.1",
"resolved": "https://registry.npmjs.org/@inlang/recommend-sherlock/-/recommend-sherlock-0.2.1.tgz",
@@ -820,6 +840,30 @@
"comment-json": "^4.2.3"
}
},
"node_modules/@inlang/sdk": {
"version": "2.4.5",
"resolved": "https://registry.npmjs.org/@inlang/sdk/-/sdk-2.4.5.tgz",
"integrity": "sha512-3zlc2llEQGeQALSEz5sZ9MdqDpFiZCxwgqNtt5QA46KD7DIp2bh7VD5kmUKifyNzDxiIk1r4liAxIgCvgC2m5w==",
"dev": true,
"license": "MIT",
"dependencies": {
"@lix-js/sdk": "0.4.5",
"@sinclair/typebox": "^0.31.17",
"kysely": "^0.27.4",
"sqlite-wasm-kysely": "0.3.0",
"uuid": "^10.0.0"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/@inlang/sdk/node_modules/@sinclair/typebox": {
"version": "0.31.28",
"resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.31.28.tgz",
"integrity": "sha512-/s55Jujywdw/Jpan+vsy6JZs1z2ZTGxTmbZTPiuSL2wz9mfzA2gN1zzaqmvfi4pq+uOt7Du85fkiwv5ymW84aQ==",
"dev": true,
"license": "MIT"
},
"node_modules/@internationalized/date": {
"version": "3.7.0",
"resolved": "https://registry.npmjs.org/@internationalized/date/-/date-3.7.0.tgz",
@@ -871,6 +915,25 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@lix-js/sdk": {
"version": "0.4.5",
"resolved": "https://registry.npmjs.org/@lix-js/sdk/-/sdk-0.4.5.tgz",
"integrity": "sha512-H0bu99QlzYArFtyV+5aKHGfgjAvtUYMxatQVXFddG0q+I3GtjR4PyNAjQdh0zeTnMJkSXWo2giSsQpXpFBz4Dw==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@lix-js/server-protocol-schema": "0.1.1",
"dedent": "1.5.1",
"human-id": "^4.1.1",
"js-sha256": "^0.11.0",
"kysely": "^0.27.4",
"sqlite-wasm-kysely": "0.3.0",
"uuid": "^10.0.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@lix-js/server-api-schema": {
"version": "0.1.1",
"resolved": "https://registry.npmjs.org/@lix-js/server-api-schema/-/server-api-schema-0.1.1.tgz",
@@ -878,6 +941,13 @@
"dev": true,
"license": "Apache-2.0"
},
"node_modules/@lix-js/server-protocol-schema": {
"version": "0.1.1",
"resolved": "https://registry.npmjs.org/@lix-js/server-protocol-schema/-/server-protocol-schema-0.1.1.tgz",
"integrity": "sha512-jBeALB6prAbtr5q4vTuxnRZZv1M2rKe8iNqRQhFJ4Tv7150unEa0vKyz0hs8Gl3fUGsWaNJBh3J8++fpbrpRBQ==",
"dev": true,
"license": "Apache-2.0"
},
"node_modules/@nodelib/fs.scandir": {
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
@@ -2847,6 +2917,19 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/flat": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/flat/-/flat-6.0.1.tgz",
"integrity": "sha512-/3FfIa8mbrg3xE7+wAhWeV+bd7L2Mof+xtZb5dRDKZ+wDvYJK4WDYeIOuOhre5Yv5aQObZrlbRmk3RTSiuQBtw==",
"dev": true,
"license": "BSD-3-Clause",
"bin": {
"flat": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/flat-cache": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz",

View File

@@ -1,6 +1,6 @@
{
"name": "pocket-id-frontend",
"version": "0.44.0",
"version": "0.45.0",
"private": true,
"type": "module",
"scripts": {
@@ -31,6 +31,8 @@
},
"devDependencies": {
"@inlang/paraglide-js": "^2.0.0",
"@inlang/plugin-m-function-matcher": "^2.0.7",
"@inlang/plugin-message-format": "^4.0.0",
"@internationalized/date": "^3.7.0",
"@playwright/test": "^1.50.0",
"@sveltejs/adapter-auto": "^4.0.0",

View File

@@ -1,10 +1,10 @@
{
"$schema": "https://inlang.com/schema/project-settings",
"baseLocale": "en-US",
"locales": ["en-US", "nl-NL", "ru-RU", "de-DE", "fr-FR", "cs-CZ"],
"locales": ["en-US", "nl-NL", "ru-RU", "de-DE", "fr-FR", "cs-CZ", "pt-BR"],
"modules": [
"https://cdn.jsdelivr.net/npm/@inlang/plugin-message-format@4/dist/index.js",
"https://cdn.jsdelivr.net/npm/@inlang/plugin-m-function-matcher@2/dist/index.js"
"./node_modules/@inlang/plugin-message-format/dist/index.js",
"./node_modules/@inlang/plugin-m-function-matcher/dist/index.js"
],
"plugin.inlang.messageFormat": {
"pathPattern": "./messages/{locale}.json"

View File

@@ -13,6 +13,7 @@
'en-US': 'English',
'fr-FR': 'Français',
'nl-NL': 'Nederlands',
'pt-BR': 'Português brasileiro',
'ru-RU': 'Русский'
};

View File

@@ -116,6 +116,7 @@ test('End session without id token hint shows confirmation page', async ({ page
test('End session with id token hint redirects to callback URL', async ({ page }) => {
const client = oidcClients.nextcloud;
// Note: this token has expired, but it should be accepted by the logout endpoint anyways, per spec
const idToken =
'eyJhbGciOiJSUzI1NiIsImtpZCI6Ijh1SER3M002cmY4IiwidHlwIjoiSldUIn0.eyJhdWQiOiIzNjU0YTc0Ni0zNWQ0LTQzMjEtYWM2MS0wYmRjZmYyYjQwNTUiLCJlbWFpbCI6InRpbS5jb29rQHRlc3QuY29tIiwiZW1haWxfdmVyaWZpZWQiOnRydWUsImV4cCI6MTY5MDAwMDAwMSwiZmFtaWx5X25hbWUiOiJUaW0iLCJnaXZlbl9uYW1lIjoiQ29vayIsImlhdCI6MTY5MDAwMDAwMCwiaXNzIjoiaHR0cDovL2xvY2FsaG9zdCIsIm5hbWUiOiJUaW0gQ29vayIsIm5vbmNlIjoib1cxQTFPNzhHUTE1RDczT3NIRXg3V1FLajdacXZITFp1XzM3bWRYSXFBUSIsInN1YiI6IjRiODlkYzItNjJmYi00NmJmLTlmNWYtYzM0ZjRlYWZlOTNlIn0.ruYCyjA2BNjROpmLGPNHrhgUNLnpJMEuncvjDYVuv1dAZwvOPfG-Rn-OseAgJDJbV7wJ0qf6ZmBkGWiifwc_B9h--fgd4Vby9fefj0MiHbSDgQyaU5UmpvJU8OlvM-TueD6ICJL0NeT3DwoW5xpIWaHtt3JqJIdP__Q-lTONL2Zokq50kWm0IO-bIw2QrQviSfHNpv8A5rk1RTzpXCPXYNB-eJbm3oBqYQWzerD9HaNrSvrKA7mKG8Te1mI9aMirPpG9FvcAU-I3lY8ky1hJZDu42jHpVEUdWPAmUZPZafoX8iYtlPfkoklDnHj_cdg4aZBGN5bfjM6xf1Oe_rLDWg';