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 PUBLIC_APP_URL=http://localhost
TRUST_PROXY=false TRUST_PROXY=false
MAXMIND_LICENSE_KEY= 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: with:
tags: pocket-id/pocket-id:test tags: pocket-id/pocket-id:test
outputs: type=docker,dest=/tmp/docker-image.tar outputs: type=docker,dest=/tmp/docker-image.tar
build-args: BUILD_TAGS=e2etest
- name: Upload Docker image artifact - name: Upload Docker image artifact
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
@@ -51,6 +52,7 @@ jobs:
with: with:
name: docker-image name: docker-image
path: /tmp path: /tmp
- name: Load Docker Image - name: Load Docker Image
run: docker load -i /tmp/docker-image.tar run: docker load -i /tmp/docker-image.tar
@@ -69,6 +71,8 @@ jobs:
-e APP_ENV=test \ -e APP_ENV=test \
pocket-id/pocket-id:test pocket-id/pocket-id:test
docker logs -f pocket-id-sqlite &> /tmp/backend.log &
- name: Run Playwright tests - name: Run Playwright tests
working-directory: ./frontend working-directory: ./frontend
run: npx playwright test run: npx playwright test
@@ -81,6 +85,14 @@ jobs:
include-hidden-files: true include-hidden-files: true
retention-days: 15 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: test-postgres:
if: github.event.pull_request.head.ref != 'i18n_crowdin' if: github.event.pull_request.head.ref != 'i18n_crowdin'
runs-on: ubuntu-latest runs-on: ubuntu-latest
@@ -140,9 +152,11 @@ jobs:
-p 80:80 \ -p 80:80 \
-e APP_ENV=test \ -e APP_ENV=test \
-e DB_PROVIDER=postgres \ -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 pocket-id/pocket-id:test
docker logs -f pocket-id-postgres &> /tmp/backend.log &
- name: Run Playwright tests - name: Run Playwright tests
working-directory: ./frontend working-directory: ./frontend
run: npx playwright test run: npx playwright test
@@ -154,3 +168,11 @@ jobs:
path: frontend/tests/.report path: frontend/tests/.report
include-hidden-files: true include-hidden-files: true
retention-days: 15 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: on:
push: push:
branches: [main] branches: [main]
paths: paths:
- "backend/**" - "backend/**"
pull_request: pull_request:
branches: [main] branches: [main]
paths: paths:
- "backend/**" - "backend/**"
jobs: jobs:
@@ -25,6 +25,7 @@ jobs:
- name: Run backend unit tests - name: Run backend unit tests
working-directory: backend working-directory: backend
run: | run: |
set -e -o pipefail
go test -v ./... | tee /tmp/TestResults.log go test -v ./... | tee /tmp/TestResults.log
- uses: actions/upload-artifact@v4 - uses: actions/upload-artifact@v4
if: always() 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) ## [](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 1. Open the `backend` folder
2. Copy the `.env.example` file to `.env` and change the `APP_ENV` to `development` 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 ### Frontend

View File

@@ -1,3 +1,6 @@
# Tags passed to "go build"
ARG BUILD_TAGS=""
# Stage 1: Build Frontend # Stage 1: Build Frontend
FROM node:22-alpine AS frontend-builder FROM node:22-alpine AS frontend-builder
WORKDIR /app/frontend WORKDIR /app/frontend
@@ -9,6 +12,7 @@ RUN npm prune --production
# Stage 2: Build Backend # Stage 2: Build Backend
FROM golang:1.23-alpine AS backend-builder FROM golang:1.23-alpine AS backend-builder
ARG BUILD_TAGS
WORKDIR /app/backend WORKDIR /app/backend
COPY ./backend/go.mod ./backend/go.sum ./ COPY ./backend/go.mod ./backend/go.sum ./
RUN go mod download RUN go mod download
@@ -17,7 +21,12 @@ RUN apk add --no-cache gcc musl-dev
COPY ./backend ./ COPY ./backend ./
WORKDIR /app/backend/cmd 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 # Stage 3: Production Image
FROM node:22-alpine FROM node:22-alpine
@@ -41,4 +50,4 @@ EXPOSE 80
ENV APP_ENV=production ENV APP_ENV=production
ENTRYPOINT ["sh", "./scripts/docker/create-user.sh"] 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 module github.com/pocket-id/pocket-id/backend
go 1.23.1 go 1.23.7
require ( require (
github.com/caarlos0/env/v11 v11.3.1 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-ldap/ldap/v3 v3.4.10
github.com/go-playground/validator/v10 v10.24.0 github.com/go-playground/validator/v10 v10.24.0
github.com/go-webauthn/webauthn v0.11.2 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/golang-migrate/migrate/v4 v4.18.2
github.com/google/uuid v1.6.0 github.com/google/uuid v1.6.0
github.com/joho/godotenv v1.5.1 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/mileusna/useragent v1.3.5
github.com/oschwald/maxminddb-golang/v2 v2.0.0-beta.2 github.com/oschwald/maxminddb-golang/v2 v2.0.0-beta.2
github.com/stretchr/testify v1.10.0 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-playground/universal-translator v0.18.1 // indirect
github.com/go-webauthn/x v0.1.16 // indirect github.com/go-webauthn/x v0.1.16 // indirect
github.com/goccy/go-json v0.10.4 // 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/google/go-tpm v0.9.3 // indirect
github.com/hashicorp/errwrap v1.1.0 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect
github.com/hashicorp/go-multierror v1.1.1 // 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/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 h1:pzDjP9dSONCFQC/AE3mWUnHILGiYPiMKzQIS+weKJXA=
github.com/lestrrat-go/httprc/v3 v3.0.0-beta1/go.mod h1:wdsgouffPvWPEYh8t7PRH/PidR5sfVqt0na4Nhj60Ms= 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-beta1 h1:Iqjb8JvWjh34Jv8DeM2wQ1aG5fzFBzwQu7rlqwuJB0I=
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/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 h1:oAzP2fvZGQKWkvHa1/SAcFolBEca1oN+mQ7eooNBEYU=
github.com/lestrrat-go/option v1.0.1/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I= github.com/lestrrat-go/option v1.0.1/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I=
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=

View File

@@ -8,6 +8,8 @@ import (
func Bootstrap() { func Bootstrap() {
initApplicationImages() initApplicationImages()
migrateConfigDBConnstring()
db := newDatabase() db := newDatabase()
appConfigService := service.NewAppConfigService(db) 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" "fmt"
"log" "log"
"os" "os"
"strings"
"time" "time"
"github.com/golang-migrate/migrate/v4" "github.com/golang-migrate/migrate/v4"
@@ -38,6 +39,7 @@ func newDatabase() (db *gorm.DB) {
case common.DbProviderPostgres: case common.DbProviderPostgres:
driver, err = postgresMigrate.WithInstance(sqlDb, &postgresMigrate.Config{}) driver, err = postgresMigrate.WithInstance(sqlDb, &postgresMigrate.Config{})
default: default:
// Should never happen at this point
log.Fatalf("unsupported database provider: %s", common.EnvConfig.DbProvider) log.Fatalf("unsupported database provider: %s", common.EnvConfig.DbProvider)
} }
if err != nil { if err != nil {
@@ -56,17 +58,17 @@ func migrateDatabase(driver database.Driver) error {
// Use the embedded migrations // Use the embedded migrations
source, err := iofs.New(resources.FS, "migrations/"+string(common.EnvConfig.DbProvider)) source, err := iofs.New(resources.FS, "migrations/"+string(common.EnvConfig.DbProvider))
if err != nil { 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) m, err := migrate.NewWithInstance("iofs", source, "pocket-id", driver)
if err != nil { 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() err = m.Up()
if err != nil && !errors.Is(err, migrate.ErrNoChange) { 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 return nil
@@ -78,9 +80,18 @@ func connectDatabase() (db *gorm.DB, err error) {
// Choose the correct database provider // Choose the correct database provider
switch common.EnvConfig.DbProvider { switch common.EnvConfig.DbProvider {
case common.DbProviderSqlite: 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: 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: default:
return nil, fmt.Errorf("unsupported database provider: %s", common.EnvConfig.DbProvider) return nil, fmt.Errorf("unsupported database provider: %s", common.EnvConfig.DbProvider)
} }
@@ -91,14 +102,14 @@ func connectDatabase() (db *gorm.DB, err error) {
Logger: getLogger(), Logger: getLogger(),
}) })
if err == nil { if err == nil {
break return db, nil
} else {
log.Printf("Attempt %d: Failed to initialize database. Retrying...", i)
time.Sleep(3 * time.Second)
} }
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 { 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 { if err != nil {
return nil, fmt.Errorf("failed to generate key ID: %w", err) 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 // Populate other required fields
_ = key.Set(jwk.KeyUsageKey, service.KeyUsageSigning) _ = key.Set(jwk.KeyUsageKey, service.KeyUsageSigning)

View File

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

View File

@@ -16,6 +16,12 @@ import (
"gorm.io/gorm" "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) { func initRouter(db *gorm.DB, appConfigService *service.AppConfigService) {
// Set the appropriate Gin mode based on the environment // Set the appropriate Gin mode based on the environment
switch common.EnvConfig.AppEnv { switch common.EnvConfig.AppEnv {
@@ -43,7 +49,6 @@ func initRouter(db *gorm.DB, appConfigService *service.AppConfigService) {
userService := service.NewUserService(db, jwtService, auditLogService, emailService, appConfigService) userService := service.NewUserService(db, jwtService, auditLogService, emailService, appConfigService)
customClaimService := service.NewCustomClaimService(db) customClaimService := service.NewCustomClaimService(db)
oidcService := service.NewOidcService(db, jwtService, appConfigService, auditLogService, customClaimService) oidcService := service.NewOidcService(db, jwtService, appConfigService, auditLogService, customClaimService)
testService := service.NewTestService(db, appConfigService, jwtService)
userGroupService := service.NewUserGroupService(db, appConfigService) userGroupService := service.NewUserGroupService(db, appConfigService)
ldapService := service.NewLdapService(db, appConfigService, userService, userGroupService) ldapService := service.NewLdapService(db, appConfigService, userService, userGroupService)
apiKeyService := service.NewApiKeyService(db) apiKeyService := service.NewApiKeyService(db)
@@ -75,7 +80,9 @@ func initRouter(db *gorm.DB, appConfigService *service.AppConfigService) {
// Add test controller in non-production environments // Add test controller in non-production environments
if common.EnvConfig.AppEnv != "production" { if common.EnvConfig.AppEnv != "production" {
controller.NewTestController(apiGroup, testService) for _, f := range registerTestControllers {
f(apiGroup, db, appConfigService, jwtService)
}
} }
// Set up base routes // Set up base routes

View File

@@ -20,8 +20,9 @@ type EnvConfigSchema struct {
AppEnv string `env:"APP_ENV"` AppEnv string `env:"APP_ENV"`
AppURL string `env:"PUBLIC_APP_URL"` AppURL string `env:"PUBLIC_APP_URL"`
DbProvider DbProvider `env:"DB_PROVIDER"` DbProvider DbProvider `env:"DB_PROVIDER"`
SqliteDBPath string `env:"SQLITE_DB_PATH"` DbConnectionString string `env:"DB_CONNECTION_STRING"`
PostgresConnectionString string `env:"POSTGRES_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"` UploadPath string `env:"UPLOAD_PATH"`
KeysPath string `env:"KEYS_PATH"` KeysPath string `env:"KEYS_PATH"`
Port string `env:"BACKEND_PORT"` Port string `env:"BACKEND_PORT"`
@@ -35,7 +36,8 @@ type EnvConfigSchema struct {
var EnvConfig = &EnvConfigSchema{ var EnvConfig = &EnvConfigSchema{
AppEnv: "production", AppEnv: "production",
DbProvider: "sqlite", DbProvider: "sqlite",
SqliteDBPath: "data/pocket-id.db", DbConnectionString: "file:data/pocket-id.db?_journal_mode=WAL&_busy_timeout=2500&_txlock=immediate",
SqliteDBPath: "",
PostgresConnectionString: "", PostgresConnectionString: "",
UploadPath: "data/uploads", UploadPath: "data/uploads",
KeysPath: "data/keys", KeysPath: "data/keys",
@@ -56,12 +58,12 @@ func init() {
// Validate the environment variables // Validate the environment variables
switch EnvConfig.DbProvider { switch EnvConfig.DbProvider {
case DbProviderSqlite: case DbProviderSqlite:
if EnvConfig.SqliteDBPath == "" { if EnvConfig.DbConnectionString == "" {
log.Fatal("Missing SQLITE_DB_PATH environment variable") log.Fatal("Missing required env var 'DB_CONNECTION_STRING' for SQLite database")
} }
case DbProviderPostgres: case DbProviderPostgres:
if EnvConfig.PostgresConnectionString == "" { if EnvConfig.DbConnectionString == "" {
log.Fatal("Missing POSTGRES_CONNECTION_STRING environment variable") log.Fatal("Missing required env var 'DB_CONNECTION_STRING' for Postgres database")
} }
default: default:
log.Fatal("Invalid DB_PROVIDER value. Must be 'sqlite' or 'postgres'") 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 var sortedPaginationRequest utils.SortedPaginationRequest
if err := ctx.ShouldBindQuery(&sortedPaginationRequest); err != nil { if err := ctx.ShouldBindQuery(&sortedPaginationRequest); err != nil {
ctx.Error(err) _ = ctx.Error(err)
return return
} }
apiKeys, pagination, err := c.apiKeyService.ListApiKeys(userID, sortedPaginationRequest) apiKeys, pagination, err := c.apiKeyService.ListApiKeys(userID, sortedPaginationRequest)
if err != nil { if err != nil {
ctx.Error(err) _ = ctx.Error(err)
return return
} }
var apiKeysDto []dto.ApiKeyDto var apiKeysDto []dto.ApiKeyDto
if err := dto.MapStructList(apiKeys, &apiKeysDto); err != nil { if err := dto.MapStructList(apiKeys, &apiKeysDto); err != nil {
ctx.Error(err) _ = ctx.Error(err)
return return
} }
@@ -83,19 +83,19 @@ func (c *ApiKeyController) createApiKeyHandler(ctx *gin.Context) {
var input dto.ApiKeyCreateDto var input dto.ApiKeyCreateDto
if err := ctx.ShouldBindJSON(&input); err != nil { if err := ctx.ShouldBindJSON(&input); err != nil {
ctx.Error(err) _ = ctx.Error(err)
return return
} }
apiKey, token, err := c.apiKeyService.CreateApiKey(userID, input) apiKey, token, err := c.apiKeyService.CreateApiKey(userID, input)
if err != nil { if err != nil {
ctx.Error(err) _ = ctx.Error(err)
return return
} }
var apiKeyDto dto.ApiKeyDto var apiKeyDto dto.ApiKeyDto
if err := dto.MapStruct(apiKey, &apiKeyDto); err != nil { if err := dto.MapStruct(apiKey, &apiKeyDto); err != nil {
ctx.Error(err) _ = ctx.Error(err)
return return
} }
@@ -117,7 +117,7 @@ func (c *ApiKeyController) revokeApiKeyHandler(ctx *gin.Context) {
apiKeyID := ctx.Param("id") apiKeyID := ctx.Param("id")
if err := c.apiKeyService.RevokeApiKey(userID, apiKeyID); err != nil { if err := c.apiKeyService.RevokeApiKey(userID, apiKeyID); err != nil {
ctx.Error(err) _ = ctx.Error(err)
return return
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,9 +1,13 @@
package controller package controller
import ( import (
"encoding/json"
"fmt"
"log"
"net/http" "net/http"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/pocket-id/pocket-id/backend/internal/common" "github.com/pocket-id/pocket-id/backend/internal/common"
"github.com/pocket-id/pocket-id/backend/internal/service" "github.com/pocket-id/pocket-id/backend/internal/service"
) )
@@ -14,12 +18,21 @@ import (
// @Tags Well Known // @Tags Well Known
func NewWellKnownController(group *gin.RouterGroup, jwtService *service.JwtService) { func NewWellKnownController(group *gin.RouterGroup, jwtService *service.JwtService) {
wkc := &WellKnownController{jwtService: 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/jwks.json", wkc.jwksHandler)
group.GET("/.well-known/openid-configuration", wkc.openIDConfigurationHandler) group.GET("/.well-known/openid-configuration", wkc.openIDConfigurationHandler)
} }
type WellKnownController struct { type WellKnownController struct {
jwtService *service.JwtService jwtService *service.JwtService
oidcConfig []byte
} }
// jwksHandler godoc // jwksHandler godoc
@@ -32,7 +45,7 @@ type WellKnownController struct {
func (wkc *WellKnownController) jwksHandler(c *gin.Context) { func (wkc *WellKnownController) jwksHandler(c *gin.Context) {
jwks, err := wkc.jwtService.GetPublicJWKSAsJSON() jwks, err := wkc.jwtService.GetPublicJWKSAsJSON()
if err != nil { if err != nil {
c.Error(err) _ = c.Error(err)
return return
} }
@@ -46,8 +59,16 @@ func (wkc *WellKnownController) jwksHandler(c *gin.Context) {
// @Success 200 {object} object "OpenID Connect configuration" // @Success 200 {object} object "OpenID Connect configuration"
// @Router /.well-known/openid-configuration [get] // @Router /.well-known/openid-configuration [get]
func (wkc *WellKnownController) openIDConfigurationHandler(c *gin.Context) { 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 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, "issuer": appUrl,
"authorization_endpoint": appUrl + "/authorize", "authorization_endpoint": appUrl + "/authorize",
"token_endpoint": appUrl + "/api/oidc/token", "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"}, "claims_supported": []string{"sub", "given_name", "family_name", "name", "email", "email_verified", "preferred_username", "picture", "groups"},
"response_types_supported": []string{"code", "id_token"}, "response_types_supported": []string{"code", "id_token"},
"subject_types_supported": []string{"public"}, "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 { func mapStructInternal(sourceVal reflect.Value, destVal reflect.Value) error {
// Loop through the fields of the destination struct
for i := 0; i < destVal.NumField(); i++ { for i := 0; i < destVal.NumField(); i++ {
destField := destVal.Field(i) destField := destVal.Field(i)
destFieldType := destVal.Type().Field(i) destFieldType := destVal.Type().Field(i)
if destFieldType.Anonymous { if destFieldType.Anonymous {
// Recursively handle embedded structs
if err := mapStructInternal(sourceVal, destField); err != nil { if err := mapStructInternal(sourceVal, destField); err != nil {
return err return err
} }
@@ -55,63 +53,57 @@ func mapStructInternal(sourceVal reflect.Value, destVal reflect.Value) error {
sourceField := sourceVal.FieldByName(destFieldType.Name) sourceField := sourceVal.FieldByName(destFieldType.Name)
// If the source field is valid and can be assigned to the destination field
if sourceField.IsValid() && destField.CanSet() { if sourceField.IsValid() && destField.CanSet() {
// Handle direct assignment for simple types if err := mapField(sourceField, destField); err != nil {
if sourceField.Type() == destField.Type() { return err
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()))
}
}
} }
} }
} }
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 return nil
} }

View File

@@ -1,10 +1,11 @@
package dto package dto
import ( import (
"github.com/gin-gonic/gin/binding"
"github.com/go-playground/validator/v10"
"log" "log"
"regexp" "regexp"
"github.com/gin-gonic/gin/binding"
"github.com/go-playground/validator/v10"
) )
var validateUsername validator.Func = func(fl validator.FieldLevel) bool { 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, "ClearOneTimeAccessTokens", "0 3 * * *", jobs.clearOneTimeAccessTokens)
registerJob(scheduler, "ClearOidcAuthorizationCodes", "0 3 * * *", jobs.clearOidcAuthorizationCodes) registerJob(scheduler, "ClearOidcAuthorizationCodes", "0 3 * * *", jobs.clearOidcAuthorizationCodes)
registerJob(scheduler, "ClearOidcRefreshTokens", "0 3 * * *", jobs.clearOidcRefreshTokens) registerJob(scheduler, "ClearOidcRefreshTokens", "0 3 * * *", jobs.clearOidcRefreshTokens)
registerJob(scheduler, "ClearAuditLogs", "0 3 * * *", jobs.clearAuditLogs)
scheduler.Start() scheduler.Start()
} }

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,8 @@
package middleware package middleware
import ( import (
"net/http"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/pocket-id/pocket-id/backend/internal/common" "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-Headers", "*")
c.Writer.Header().Set("Access-Control-Allow-Methods", "POST, OPTIONS, GET, PUT") 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) c.AbortWithStatus(204)
return 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) c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, maxSize)
if err := c.Request.ParseMultipartForm(maxSize); err != nil { if err := c.Request.ParseMultipartForm(maxSize); err != nil {
err = &common.FileTooLargeError{MaxSize: formatFileSize(maxSize)} err = &common.FileTooLargeError{MaxSize: formatFileSize(maxSize)}
c.Error(err) _ = c.Error(err)
c.Abort() c.Abort()
return return
} }

View File

@@ -19,11 +19,10 @@ func NewJwtAuthMiddleware(jwtService *service.JwtService) *JwtAuthMiddleware {
func (m *JwtAuthMiddleware) Add(adminRequired bool) gin.HandlerFunc { func (m *JwtAuthMiddleware) Add(adminRequired bool) gin.HandlerFunc {
return func(c *gin.Context) { return func(c *gin.Context) {
userID, isAdmin, err := m.Verify(c, adminRequired) userID, isAdmin, err := m.Verify(c, adminRequired)
if err != nil { if err != nil {
c.Abort() c.Abort()
c.Error(err) _ = c.Error(err)
return 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 // Extract the token from the cookie
token, err := c.Cookie(cookie.AccessTokenCookieName) accessToken, err := c.Cookie(cookie.AccessTokenCookieName)
if err != nil { if err != nil {
// Try to extract the token from the Authorization header if it's not in the cookie // Try to extract the token from the Authorization header if it's not in the cookie
authorizationHeaderSplit := strings.Split(c.GetHeader("Authorization"), " ") var ok bool
if len(authorizationHeaderSplit) != 2 { _, accessToken, ok = strings.Cut(c.GetHeader("Authorization"), " ")
if !ok || accessToken == "" {
return "", false, &common.NotSignedInError{} return "", false, &common.NotSignedInError{}
} }
token = authorizationHeaderSplit[1]
} }
claims, err := m.jwtService.VerifyAccessToken(token) token, err := m.jwtService.VerifyAccessToken(accessToken)
if err != nil { if err != nil {
return "", false, &common.NotSignedInError{} return "", false, &common.NotSignedInError{}
} }
subject, ok := token.Subject()
if !ok {
_ = c.Error(&common.TokenInvalidError{})
return
}
// Check if the user is an admin // 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 "", 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) limiter := getLimiter(ip, limit, burst, &mu, clients)
if !limiter.Allow() { if !limiter.Allow() {
c.Error(&common.TooManyRequestsError{}) _ = c.Error(&common.TooManyRequestsError{})
c.Abort() c.Abort()
return return
} }

View File

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

View File

@@ -2,6 +2,7 @@ package model
import ( import (
"strconv" "strconv"
"time"
) )
type AppConfigVariable struct { type AppConfigVariable struct {
@@ -13,11 +14,21 @@ type AppConfigVariable struct {
DefaultValue string DefaultValue string
} }
// IsTrue returns true if the value is a truthy string, such as "true", "t", "yes", "1", etc.
func (a *AppConfigVariable) IsTrue() bool { func (a *AppConfigVariable) IsTrue() bool {
ok, _ := strconv.ParseBool(a.Value) ok, _ := strconv.ParseBool(a.Value)
return ok 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 { type AppConfig struct {
// General // General
AppName AppConfigVariable 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 Data AuditLogData
} }
type AuditLogData map[string]string type AuditLogData map[string]string //nolint:recvcheck
type AuditLogEvent string type AuditLogEvent string //nolint:recvcheck
const ( const (
AuditLogEventSignIn AuditLogEvent = "SIGN_IN" AuditLogEventSignIn AuditLogEvent = "SIGN_IN"

View File

@@ -4,7 +4,7 @@ import (
"time" "time"
"github.com/google/uuid" "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" "gorm.io/gorm"
) )

View File

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

View File

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

View File

@@ -2,10 +2,11 @@ package service
import ( import (
"errors" "errors"
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
"log" "log"
"time" "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/common"
"github.com/pocket-id/pocket-id/backend/internal/dto" "github.com/pocket-id/pocket-id/backend/internal/dto"
"github.com/pocket-id/pocket-id/backend/internal/model" "github.com/pocket-id/pocket-id/backend/internal/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 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() { go func() {
var user model.User var user model.User
s.db.Where("id = ?", userID).First(&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, Value: claim.Value,
} }
if idType == UserID { switch idType {
case UserID:
customClaim.UserID = &value customClaim.UserID = &value
} else if idType == UserGroupID { case UserGroupID:
customClaim.UserGroupID = &value customClaim.UserGroupID = &value
} }

View File

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

View File

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

View File

@@ -3,6 +3,7 @@ package service
import ( import (
"archive/tar" "archive/tar"
"compress/gzip" "compress/gzip"
"context"
"errors" "errors"
"fmt" "fmt"
"io" "io"
@@ -124,8 +125,15 @@ func (s *GeoLiteService) updateDatabase() error {
log.Println("Updating GeoLite2 City database...") log.Println("Updating GeoLite2 City database...")
downloadUrl := fmt.Sprintf(common.EnvConfig.GeoLiteDBUrl, common.EnvConfig.MaxMindLicenseKey) downloadUrl := fmt.Sprintf(common.EnvConfig.GeoLiteDBUrl, common.EnvConfig.MaxMindLicenseKey)
// Download the database tar.gz file ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
resp, err := http.Get(downloadUrl) 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 { if err != nil {
return fmt.Errorf("failed to download database: %w", err) 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) 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 // Iterate over the files in the tar archive
for { for {
header, err := tarReader.Next() 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 // Check if the file is the GeoLite2-City.mmdb file
if header.Typeflag == tar.TypeReg && filepath.Base(header.Name) == "GeoLite2-City.mmdb" { 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. // extract to a temporary file to avoid having a corrupted db in case of write failure.
baseDir := filepath.Dir(common.EnvConfig.GeoLiteDBPath) baseDir := filepath.Dir(common.EnvConfig.GeoLiteDBPath)
tmpFile, err := os.CreateTemp(baseDir, "geolite.*.mmdb.tmp") tmpFile, err := os.CreateTemp(baseDir, "geolite.*.mmdb.tmp")
@@ -185,7 +201,7 @@ func (s *GeoLiteService) extractDatabase(reader io.Reader) error {
tempName := tmpFile.Name() tempName := tmpFile.Name()
// Write the file contents directly to the target location // 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 // if fails to write, then cleanup and throw an error
tmpFile.Close() tmpFile.Close()
os.Remove(tempName) os.Remove(tempName)

View File

@@ -11,13 +11,11 @@ import (
"log" "log"
"os" "os"
"path/filepath" "path/filepath"
"slices"
"strconv"
"time" "time"
"github.com/golang-jwt/jwt/v5"
"github.com/lestrrat-go/jwx/v3/jwa" "github.com/lestrrat-go/jwx/v3/jwa"
"github.com/lestrrat-go/jwx/v3/jwk" "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/common"
"github.com/pocket-id/pocket-id/backend/internal/model" "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 is the usage for the private keys, for the "use" property
KeyUsageSigning = "sig" 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 { type JwtService struct {
@@ -61,11 +66,6 @@ func (s *JwtService) init(appConfigService *AppConfigService, keysPath string) e
return s.loadOrGenerateKey(keysPath) 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. // loadOrGenerateKey loads the private key from the given path or generates it if not existing.
func (s *JwtService) loadOrGenerateKey(keysPath string) error { func (s *JwtService) loadOrGenerateKey(keysPath string) error {
var key jwk.Key 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) { func (s *JwtService) GenerateAccessToken(user model.User) (string, error) {
sessionDurationInMinutes, _ := strconv.Atoi(s.appConfigService.DbConfig.SessionDuration.Value) now := time.Now()
claim := AccessTokenJWTClaims{ token, err := jwt.NewBuilder().
RegisteredClaims: jwt.RegisteredClaims{ Subject(user.ID).
Subject: user.ID, Expiration(now.Add(s.appConfigService.DbConfig.SessionDuration.AsDurationMinutes())).
ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Duration(sessionDurationInMinutes) * time.Minute)), IssuedAt(now).
IssuedAt: jwt.NewNumericDate(time.Now()), Issuer(common.EnvConfig.AppURL).
Audience: jwt.ClaimStrings{common.EnvConfig.AppURL}, Build()
},
IsAdmin: user.IsAdmin,
}
token := jwt.NewWithClaims(jwt.SigningMethodRS256, claim)
token.Header["kid"] = s.keyId
var privateKeyRaw any
err := jwk.Export(s.privateKey, &privateKeyRaw)
if err != nil { 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 { if err != nil {
return "", fmt.Errorf("failed to sign token: %w", err) return "", fmt.Errorf("failed to sign token: %w", err)
} }
return signed, nil return string(signed), nil
} }
func (s *JwtService) VerifyAccessToken(tokenString string) (*AccessTokenJWTClaims, error) { func (s *JwtService) VerifyAccessToken(tokenString string) (jwt.Token, error) {
token, err := jwt.ParseWithClaims(tokenString, &AccessTokenJWTClaims{}, func(token *jwt.Token) (any, error) { alg, _ := s.privateKey.Algorithm()
return s.getPublicKeyRaw() token, err := jwt.ParseString(
}) tokenString,
if err != nil || !token.Valid { jwt.WithValidate(true),
return nil, errors.New("couldn't handle this token") 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) return token, nil
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
} }
func (s *JwtService) GenerateIDToken(userClaims map[string]interface{}, clientID string, nonce string) (string, error) { func (s *JwtService) GenerateIDToken(userClaims map[string]any, 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 now := time.Now()
claims := make(jwt.MapClaims, len(userClaims)+6) token, err := jwt.NewBuilder().
claims["aud"] = clientID Expiration(now.Add(1 * time.Hour)).
claims["exp"] = jwt.NewNumericDate(time.Now().Add(1 * time.Hour)) IssuedAt(now).
claims["iat"] = jwt.NewNumericDate(time.Now()) Issuer(common.EnvConfig.AppURL).
claims["iss"] = 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 { 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 != "" { 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) alg, _ := s.privateKey.Algorithm()
token.Header["kid"] = s.keyId signed, err := jwt.Sign(token, jwt.WithKey(alg, s.privateKey))
var privateKeyRaw any
err := jwk.Export(s.privateKey, &privateKeyRaw)
if err != nil { 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) { func (s *JwtService) VerifyIdToken(tokenString string, acceptExpiredTokens bool) (jwt.Token, error) {
token, err := jwt.ParseWithClaims(tokenString, &jwt.RegisteredClaims{}, func(token *jwt.Token) (any, error) { alg, _ := s.privateKey.Algorithm()
return s.getPublicKeyRaw()
}, jwt.WithIssuer(common.EnvConfig.AppURL))
if err != nil && !errors.Is(err, jwt.ErrTokenExpired) { opts := make([]jwt.ParseOption, 0)
return nil, errors.New("couldn't handle this token")
// 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) token, err := jwt.ParseString(tokenString, opts...)
if !isValid { if err != nil {
return nil, errors.New("can't parse claims") 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) { func (s *JwtService) GenerateOauthAccessToken(user model.User, clientID string) (string, error) {
claim := jwt.RegisteredClaims{ now := time.Now()
Subject: user.ID, token, err := jwt.NewBuilder().
ExpiresAt: jwt.NewNumericDate(time.Now().Add(1 * time.Hour)), Subject(user.ID).
IssuedAt: jwt.NewNumericDate(time.Now()), Expiration(now.Add(1 * time.Hour)).
Audience: jwt.ClaimStrings{clientID}, IssuedAt(now).
Issuer: common.EnvConfig.AppURL, Issuer(common.EnvConfig.AppURL).
} Build()
token := jwt.NewWithClaims(jwt.SigningMethodRS256, claim)
token.Header["kid"] = s.keyId
var privateKeyRaw any
err := jwk.Export(s.privateKey, &privateKeyRaw)
if err != nil { 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) { func (s *JwtService) VerifyOauthAccessToken(tokenString string) (jwt.Token, error) {
token, err := jwt.ParseWithClaims(tokenString, &jwt.RegisteredClaims{}, func(token *jwt.Token) (any, error) { alg, _ := s.privateKey.Algorithm()
return s.getPublicKeyRaw() token, err := jwt.ParseString(
}) tokenString,
if err != nil || !token.Valid { jwt.WithValidate(true),
return nil, errors.New("couldn't handle this token") 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) return token, nil
if !isValid {
return nil, errors.New("can't parse claims")
}
return claims, nil
} }
// GetPublicJWK returns the JSON Web Key (JWK) for the public key. // 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 return s.jwksEncoded, nil
} }
func (s *JwtService) getPublicKeyRaw() (any, error) { // GetKeyAlg returns the algorithm of the key
pubKey, err := s.privateKey.PublicKey() func (s *JwtService) GetKeyAlg() (jwa.KeyAlgorithm, error) {
if err != nil { if len(s.jwksEncoded) == 0 {
return nil, fmt.Errorf("failed to get public key: %w", err) return nil, errors.New("key is not initialized")
} }
var pubKeyRaw any
err = jwk.Export(pubKey, &pubKeyRaw) alg, ok := s.privateKey.Algorithm()
if err != nil { if !ok || alg == nil {
return nil, fmt.Errorf("failed to export raw public key: %w", err) 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) { func (s *JwtService) loadKeyJWK(path string) (jwk.Key, error) {
@@ -438,3 +470,28 @@ func generateRandomKeyID() (string, error) {
} }
return base64.RawURLEncoding.EncodeToString(buf), nil 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 ( import (
"crypto/ecdsa" "crypto/ecdsa"
"crypto/ed25519"
"crypto/elliptic" "crypto/elliptic"
"crypto/rand" "crypto/rand"
"crypto/rsa"
"os" "os"
"path/filepath" "path/filepath"
"sync"
"testing" "testing"
"time" "time"
@@ -20,16 +23,19 @@ import (
) )
func TestJwtService_Init(t *testing.T) { 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) { t.Run("should generate new key when none exists", func(t *testing.T) {
// Create a temporary directory for the test // Create a temporary directory for the test
tempDir := t.TempDir() tempDir := t.TempDir()
// Create a mock AppConfigService
appConfigService := &AppConfigService{}
// Initialize the JWT service // Initialize the JWT service
service := &JwtService{} service := &JwtService{}
err := service.init(appConfigService, tempDir) err := service.init(mockConfig, tempDir)
require.NoError(t, err, "Failed to initialize JWT service") require.NoError(t, err, "Failed to initialize JWT service")
// Verify the private key was set // 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 // Verify the key has been saved to disk as JWK
jwkPath := filepath.Join(tempDir, PrivateKeyFile) jwkPath := filepath.Join(tempDir, PrivateKeyFile)
_, err = os.Stat(jwkPath) _, 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 // Verify the generated key is valid
keyData, err := os.ReadFile(jwkPath) keyData, err := os.ReadFile(jwkPath)
@@ -62,7 +68,7 @@ func TestJwtService_Init(t *testing.T) {
// First create a service to generate a key // First create a service to generate a key
firstService := &JwtService{} firstService := &JwtService{}
err := firstService.init(&AppConfigService{}, tempDir) err := firstService.init(mockConfig, tempDir)
require.NoError(t, err) require.NoError(t, err)
// Get the key ID of the first service // 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 // Now create a new service that should load the existing key
secondService := &JwtService{} secondService := &JwtService{}
err = secondService.init(&AppConfigService{}, tempDir) err = secondService.init(mockConfig, tempDir)
require.NoError(t, err) require.NoError(t, err)
// Verify the loaded key has the same ID as the original // 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") 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 // Create a temporary directory for the test
tempDir := t.TempDir() tempDir := t.TempDir()
// Create a new JWK and save it to disk // 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 // Now create a new service that should load the existing key
svc := &JwtService{} svc := &JwtService{}
err := svc.init(&AppConfigService{}, tempDir) err := svc.init(mockConfig, tempDir)
require.NoError(t, err) 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 // Verify the loaded key has the same ID as the original
loadedKeyID, ok := svc.privateKey.KeyID() loadedKeyID, ok := svc.privateKey.KeyID()
require.True(t, ok) _ = assert.True(t, ok) &&
assert.Equal(t, origKeyID, loadedKeyID, "Loaded key should have the same ID as the original") 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) { 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) { t.Run("returns public key when private key is initialized", func(t *testing.T) {
// Create a temporary directory for the test // Create a temporary directory for the test
tempDir := t.TempDir() tempDir := t.TempDir()
// Create a JWT service with initialized key // Create a JWT service with initialized key
service := &JwtService{} service := &JwtService{}
err := service.init(&AppConfigService{}, tempDir) err := service.init(mockConfig, tempDir)
require.NoError(t, err, "Failed to initialize JWT service") require.NoError(t, err, "Failed to initialize JWT service")
// Get the JWK (public key) // Get the JWK (public key)
@@ -136,11 +181,11 @@ func TestJwtService_GetPublicJWK(t *testing.T) {
tempDir := t.TempDir() tempDir := t.TempDir()
// Create an ECDSA key and save it as JWK // 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 // Create a JWT service that loads the ECDSA key
service := &JwtService{} service := &JwtService{}
err := service.init(&AppConfigService{}, tempDir) err := service.init(mockConfig, tempDir)
require.NoError(t, err, "Failed to initialize JWT service") require.NoError(t, err, "Failed to initialize JWT service")
// Get the JWK (public key) // 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") 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) { t.Run("returns error when private key is not initialized", func(t *testing.T) {
// Create a service with nil private key // Create a service with nil private key
service := &JwtService{ service := &JwtService{
@@ -228,15 +311,22 @@ func TestGenerateVerifyAccessToken(t *testing.T) {
require.NoError(t, err, "Failed to verify generated token") require.NoError(t, err, "Failed to verify generated token")
// Check the claims // Check the claims
assert.Equal(t, user.ID, claims.Subject, "Token subject should match user ID") subject, ok := claims.Subject()
assert.Equal(t, false, claims.IsAdmin, "IsAdmin should be false") _ = assert.True(t, ok, "User ID not found in token") &&
assert.Contains(t, claims.Audience, "https://test.example.com", "Audience should contain the app URL") 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 // Check token expiration time is approximately 1 hour from now
expectedExp := time.Now().Add(60 * time.Minute) expectedExp := time.Now().Add(1 * time.Hour)
tokenExp := claims.ExpiresAt.Time expiration, ok := claims.Expiration()
timeDiff := expectedExp.Sub(tokenExp).Minutes() assert.True(t, ok, "Expiration not found in token")
assert.InDelta(t, 0, timeDiff, 1.0, "Token should expire in approximately 60 minutes") 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) { 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") require.NoError(t, err, "Failed to verify generated token")
// Check the IsAdmin claim is true // Check the IsAdmin claim is true
assert.Equal(t, true, claims.IsAdmin, "IsAdmin should be true for admin users") isAdmin, err := GetIsAdmin(claims)
assert.Equal(t, adminUser.ID, claims.Subject, "Token subject should match admin ID") _ = 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) { 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 // Check token expiration time is approximately 30 minutes from now
expectedExp := time.Now().Add(30 * time.Minute) expectedExp := time.Now().Add(30 * time.Minute)
tokenExp := claims.ExpiresAt.Time expiration, ok := claims.Expiration()
timeDiff := expectedExp.Sub(tokenExp).Minutes() 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") 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) { func TestGenerateVerifyIdToken(t *testing.T) {
@@ -340,21 +597,83 @@ func TestGenerateVerifyIdToken(t *testing.T) {
assert.NotEmpty(t, tokenString, "Token should not be empty") assert.NotEmpty(t, tokenString, "Token should not be empty")
// Verify the token // Verify the token
claims, err := service.VerifyIdToken(tokenString) claims, err := service.VerifyIdToken(tokenString, false)
require.NoError(t, err, "Failed to verify generated ID token") require.NoError(t, err, "Failed to verify generated ID token")
// Check the claims // Check the claims
assert.Equal(t, "user123", claims.Subject, "Token subject should match user ID") subject, ok := claims.Subject()
assert.Contains(t, claims.Audience, clientID, "Audience should contain the client ID") _ = assert.True(t, ok, "User ID not found in token") &&
assert.Equal(t, common.EnvConfig.AppURL, claims.Issuer, "Issuer should match app URL") 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 // Check token expiration time is approximately 1 hour from now
expectedExp := time.Now().Add(1 * time.Hour) expectedExp := time.Now().Add(1 * time.Hour)
tokenExp := claims.ExpiresAt.Time expiration, ok := claims.Expiration()
timeDiff := expectedExp.Sub(tokenExp).Minutes() 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") 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) { t.Run("generates and verifies ID token with nonce", func(t *testing.T) {
// Create a JWT service // Create a JWT service
service := &JwtService{} service := &JwtService{}
@@ -403,9 +722,168 @@ func TestGenerateVerifyIdToken(t *testing.T) {
common.EnvConfig.AppURL = "https://wrong-issuer.com" common.EnvConfig.AppURL = "https://wrong-issuer.com"
// Verify should fail due to issuer mismatch // Verify should fail due to issuer mismatch
_, err = service.VerifyIdToken(tokenString) _, err = service.VerifyIdToken(tokenString, false)
assert.Error(t, err, "Verification should fail with incorrect issuer") require.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") 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") require.NoError(t, err, "Failed to verify generated OAuth access token")
// Check the claims // Check the claims
assert.Equal(t, user.ID, claims.Subject, "Token subject should match user ID") subject, ok := claims.Subject()
assert.Contains(t, claims.Audience, clientID, "Audience should contain the client ID") _ = assert.True(t, ok, "User ID not found in token") &&
assert.Equal(t, common.EnvConfig.AppURL, claims.Issuer, "Issuer should match app URL") 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 // Check token expiration time is approximately 1 hour from now
expectedExp := time.Now().Add(1 * time.Hour) expectedExp := time.Now().Add(1 * time.Hour)
tokenExp := claims.ExpiresAt.Time expiration, ok := claims.Expiration()
timeDiff := expectedExp.Sub(tokenExp).Minutes() 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") 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 // Verify should fail due to expiration
_, err = service.VerifyOauthAccessToken(string(signed)) _, err = service.VerifyOauthAccessToken(string(signed))
assert.Error(t, err, "Verification should fail with expired token") require.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") 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) { 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 // Verify with the second service should fail due to different keys
_, err = service2.VerifyOauthAccessToken(tokenString) _, err = service2.VerifyOauthAccessToken(tokenString)
assert.Error(t, err, "Verification should fail with invalid signature") require.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") 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() 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) privateKey, err := importRawKey(privateKeyRaw)
require.NoError(t, err, "Failed to import private key") require.NoError(t, err, "Failed to import private key")
@@ -544,3 +1186,47 @@ func createECKeyJWK(t *testing.T, path string) string {
return kid 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 ( import (
"bytes" "bytes"
"context"
"crypto/tls" "crypto/tls"
"encoding/base64" "encoding/base64"
"errors" "errors"
@@ -11,6 +12,7 @@ import (
"net/http" "net/http"
"net/url" "net/url"
"strings" "strings"
"time"
"github.com/go-ldap/ldap/v3" "github.com/go-ldap/ldap/v3"
"github.com/pocket-id/pocket-id/backend/internal/dto" "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) { 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") return nil, fmt.Errorf("LDAP is not enabled")
} }
// Setup LDAP connection // Setup LDAP connection
ldapURL := s.appConfigService.DbConfig.LdapUrl.Value ldapURL := s.appConfigService.DbConfig.LdapUrl.Value
skipTLSVerify := s.appConfigService.DbConfig.LdapSkipCertVerify.Value == "true" skipTLSVerify := s.appConfigService.DbConfig.LdapSkipCertVerify.IsTrue()
client, err := ldap.DialURL(ldapURL, ldap.DialWithTLSConfig(&tls.Config{InsecureSkipVerify: skipTLSVerify})) client, err := ldap.DialURL(ldapURL, ldap.DialWithTLSConfig(&tls.Config{InsecureSkipVerify: skipTLSVerify})) //nolint:gosec
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to connect to LDAP: %w", err) return nil, fmt.Errorf("failed to connect to LDAP: %w", err)
} }
@@ -65,6 +67,7 @@ func (s *LdapService) SyncAll() error {
return nil return nil
} }
//nolint:gocognit
func (s *LdapService) SyncGroups() error { func (s *LdapService) SyncGroups() error {
// Setup LDAP connection // Setup LDAP connection
client, err := s.createClient() client, err := s.createClient()
@@ -150,6 +153,9 @@ func (s *LdapService) SyncGroups() error {
} }
} else { } else {
_, err = s.groupService.Update(databaseGroup.ID, syncGroup, true) _, 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) _, err = s.groupService.UpdateUsers(databaseGroup.ID, membersUserId)
if err != nil { if err != nil {
log.Printf("Error syncing group %s: %s", syncGroup.Name, err) 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 // Get all LDAP groups from the database
var ldapGroupsInDb []model.UserGroup var ldapGroupsInDb []model.UserGroup
if err := s.db.Find(&ldapGroupsInDb, "ldap_id IS NOT NULL").Select("ldap_id").Error; err != nil { 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 // Delete groups that no longer exist in LDAP
@@ -180,6 +186,7 @@ func (s *LdapService) SyncGroups() error {
return nil return nil
} }
//nolint:gocognit
func (s *LdapService) SyncUsers() error { func (s *LdapService) SyncUsers() error {
// Setup LDAP connection // Setup LDAP connection
client, err := s.createClient() client, err := s.createClient()
@@ -276,13 +283,13 @@ func (s *LdapService) SyncUsers() error {
// Get all LDAP users from the database // Get all LDAP users from the database
var ldapUsersInDb []model.User var ldapUsersInDb []model.User
if err := s.db.Find(&ldapUsersInDb, "ldap_id IS NOT NULL").Select("ldap_id").Error; err != nil { 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 // Delete users that no longer exist in LDAP
for _, user := range ldapUsersInDb { for _, user := range ldapUsersInDb {
if _, exists := ldapUserIDs[*user.LdapID]; !exists { 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) log.Printf("Failed to delete user %s with: %v", user.Username, err)
} else { } else {
log.Printf("Deleted user %s", user.Username) log.Printf("Deleted user %s", user.Username)
@@ -296,8 +303,15 @@ func (s *LdapService) SaveProfilePicture(userId string, pictureString string) er
var reader io.Reader var reader io.Reader
if _, err := url.ParseRequestURI(pictureString); err == nil { if _, err := url.ParseRequestURI(pictureString); err == nil {
// If the photo is a URL, download it ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
response, err := http.Get(pictureString) 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 { if err != nil {
return fmt.Errorf("failed to download profile picture: %w", err) 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) accessToken, err = s.jwtService.GenerateOauthAccessToken(authorizationCodeMetaData.User, clientID)
if err != nil {
return "", "", "", 0, err
}
s.db.Delete(&authorizationCodeMetaData) s.db.Delete(&authorizationCodeMetaData)
@@ -458,7 +461,7 @@ func (s *OidcService) GetUserClaimsForClient(userID string, clientID string) (ma
if strings.Contains(scope, "email") { if strings.Contains(scope, "email") {
claims["email"] = user.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") { if strings.Contains(scope, "groups") {
@@ -492,8 +495,8 @@ func (s *OidcService) GetUserClaimsForClient(userID string, clientID string) (ma
for _, customClaim := range customClaims { for _, customClaim := range customClaims {
// The value of the custom claim can be a JSON object or a string // The value of the custom claim can be a JSON object or a string
var jsonValue interface{} var jsonValue interface{}
json.Unmarshal([]byte(customClaim.Value), &jsonValue) err := json.Unmarshal([]byte(customClaim.Value), &jsonValue)
if jsonValue != nil { if err == nil {
// It's JSON so we store it as an object // It's JSON so we store it as an object
claims[customClaim.Key] = jsonValue claims[customClaim.Key] = jsonValue
} else { } 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 // 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 { if err != nil {
return "", &common.TokenInvalidError{} 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 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{} return "", &common.OidcClientIdNotMatchingError{}
} }
clientId := claims.Audience[0]
// Check if the user has authorized the client before // Check if the user has authorized the client before
var userAuthorizedOIDCClient model.UserAuthorizedOidcClient 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{} 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 // 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{} 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 // 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{} return model.UserGroup{}, &common.LdapUserGroupUpdateError{}
} }

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,8 +1,10 @@
package utils package utils
import ( import (
"gorm.io/gorm"
"reflect" "reflect"
"strconv"
"gorm.io/gorm"
) )
type PaginationResponse struct { type PaginationResponse struct {
@@ -30,7 +32,7 @@ func PaginateAndSort(sortedPaginationRequest SortedPaginationRequest, query *gor
capitalizedSortColumn := CapitalizeFirstLetter(sort.Column) capitalizedSortColumn := CapitalizeFirstLetter(sort.Column)
sortField, sortFieldFound := reflect.TypeOf(result).Elem().Elem().FieldByName(capitalizedSortColumn) 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" isValidSortOrder := sort.Direction == "asc" || sort.Direction == "desc"
if sortFieldFound && isSortable && isValidSortOrder { 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.", "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", "account_details": "Podrobnosti účtu",
"passkeys": "Přístupové klíče", "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íč", "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_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", "create": "Vytvořit",

View File

@@ -26,14 +26,14 @@
"login_background": "Login Hintergrund", "login_background": "Login Hintergrund",
"logo": "Logo", "logo": "Logo",
"login_code": "Anmeldecode", "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", "one_hour": "1 Stunde",
"twelve_hours": "12 Stunden", "twelve_hours": "12 Stunden",
"one_day": "1 Tag", "one_day": "1 Tag",
"one_week": "1 Woche", "one_week": "1 Woche",
"one_month": "1 Monat", "one_month": "1 Monat",
"expiration": "Ablaufdatum", "expiration": "Ablaufdatum",
"generate_code": "Code generieren", "generate_code": "Code erzeugen",
"name": "Name", "name": "Name",
"browser_unsupported": "Browser nicht unterstützt", "browser_unsupported": "Browser nicht unterstützt",
"this_browser_does_not_support_passkeys": "Dieser Browser unterstützt keine Passkeys. Bitte verwende eine alternative Anmeldemethode.", "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", "authenticator_does_not_support_resident_keys": "Der Authentifikator unterstützt keine residenten Schlüssel",
"passkey_was_previously_registered": "Dieser Passkey wurde bereits registriert", "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_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.", "critical_error_occurred_contact_administrator": "Ein kritischer Fehler ist aufgetreten. Bitte kontaktiere deinen Administrator.",
"sign_in_to": "Bei {name} anmelden", "sign_in_to": "Bei {name} anmelden",
"client_not_found": "Client nicht gefunden", "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.", "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", "continue": "Fortsetzen",
"alternative_sign_in": "Alternative Anmeldemethoden", "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?", "use_your_passkey_instead": "Deinen Passkey stattdessen verwenden?",
"email_login": "E-Mail Anmeldung", "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.", "request_a_login_code_via_email": "Login-Code per E-Mail anfordern.",
"go_back": "Zurück", "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.", "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", "settings": "Einstellungen",
"update_pocket_id": "Pocket ID aktualisieren", "update_pocket_id": "Pocket ID aktualisieren",
"powered_by": "Powered by", "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", "time": "Zeit",
"event": "Ereignis", "event": "Ereignis",
"approximate_location": "Ungefährer Standort", "approximate_location": "Ungefährer Standort",
@@ -113,7 +113,7 @@
"passkeys": "Passkeys", "passkeys": "Passkeys",
"manage_your_passkeys_that_you_can_use_to_authenticate_yourself": "Verwalte deine Passkeys, mit denen du dich authentifizieren kannst.", "manage_your_passkeys_that_you_can_use_to_authenticate_yourself": "Verwalte deine Passkeys, mit denen du dich authentifizieren kannst.",
"add_passkey": "Passkey hinzufügen", "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", "create": "Erzeugen",
"first_name": "Vorname", "first_name": "Vorname",
"last_name": "Nachname", "last_name": "Nachname",
@@ -121,7 +121,7 @@
"save": "Speichern", "save": "Speichern",
"username_can_only_contain": "Der Benutzername darf nur Kleinbuchstaben, Ziffern, Unterstriche, Punkte, Bindestriche und das Symbol „@“ enthalten", "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.", "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", "added_on": "Hinzugefügt am",
"rename": "Umbenennen", "rename": "Umbenennen",
"delete": "Löschen", "delete": "Löschen",

View File

@@ -17,7 +17,7 @@
"items_per_page": "Éléments par page", "items_per_page": "Éléments par page",
"no_items_found": "Aucune donnée trouvée", "no_items_found": "Aucune donnée trouvée",
"search": "Rechercher...", "search": "Rechercher...",
"expand_card": "Expand card", "expand_card": "Carte d'expansion",
"copied": "Copié", "copied": "Copié",
"click_to_copy": "Cliquer pour copier", "click_to_copy": "Cliquer pour copier",
"something_went_wrong": "Quelque chose n'a pas fonctionné", "something_went_wrong": "Quelque chose n'a pas fonctionné",
@@ -196,7 +196,7 @@
"client_configuration": "Configuration du client", "client_configuration": "Configuration du client",
"ldap_url": "URL du serveur LDAP", "ldap_url": "URL du serveur LDAP",
"ldap_bind_dn": "LDAP Bind DN", "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", "ldap_base_dn": "LDAP Base DN",
"user_search_filter": "Filtre de recherche utilisateur", "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.", "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", "back": "Retour",
"user_details_firstname_lastname": "Détails de l'utilisateur {firstName} {lastName}", "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.", "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.", "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", "user_group_created_successfully": "Groupe d'utilisateurs créé avec succès",
"create_user_group": "Créer un groupe d'utilisateurs", "create_user_group": "Créer un groupe d'utilisateurs",
@@ -252,7 +252,7 @@
"manage_user_groups": "Gérer les groupes d'utilisateurs", "manage_user_groups": "Gérer les groupes d'utilisateurs",
"friendly_name": "Nom d'affichage", "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_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}", "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?", "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", "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", "name": "pocket-id-frontend",
"version": "0.43.1", "version": "0.44.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "pocket-id-frontend", "name": "pocket-id-frontend",
"version": "0.43.1", "version": "0.44.0",
"dependencies": { "dependencies": {
"@simplewebauthn/browser": "^13.1.0", "@simplewebauthn/browser": "^13.1.0",
"@tailwindcss/vite": "^4.0.0", "@tailwindcss/vite": "^4.0.0",
@@ -26,6 +26,8 @@
}, },
"devDependencies": { "devDependencies": {
"@inlang/paraglide-js": "^2.0.0", "@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", "@internationalized/date": "^3.7.0",
"@playwright/test": "^1.50.0", "@playwright/test": "^1.50.0",
"@sveltejs/adapter-auto": "^4.0.0", "@sveltejs/adapter-auto": "^4.0.0",
@@ -810,6 +812,24 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/@inlang/recommend-sherlock": {
"version": "0.2.1", "version": "0.2.1",
"resolved": "https://registry.npmjs.org/@inlang/recommend-sherlock/-/recommend-sherlock-0.2.1.tgz", "resolved": "https://registry.npmjs.org/@inlang/recommend-sherlock/-/recommend-sherlock-0.2.1.tgz",
@@ -820,6 +840,30 @@
"comment-json": "^4.2.3" "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": { "node_modules/@internationalized/date": {
"version": "3.7.0", "version": "3.7.0",
"resolved": "https://registry.npmjs.org/@internationalized/date/-/date-3.7.0.tgz", "resolved": "https://registry.npmjs.org/@internationalized/date/-/date-3.7.0.tgz",
@@ -871,6 +915,25 @@
"@jridgewell/sourcemap-codec": "^1.4.14" "@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": { "node_modules/@lix-js/server-api-schema": {
"version": "0.1.1", "version": "0.1.1",
"resolved": "https://registry.npmjs.org/@lix-js/server-api-schema/-/server-api-schema-0.1.1.tgz", "resolved": "https://registry.npmjs.org/@lix-js/server-api-schema/-/server-api-schema-0.1.1.tgz",
@@ -878,6 +941,13 @@
"dev": true, "dev": true,
"license": "Apache-2.0" "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": { "node_modules/@nodelib/fs.scandir": {
"version": "2.1.5", "version": "2.1.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
@@ -2847,6 +2917,19 @@
"url": "https://github.com/sponsors/sindresorhus" "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": { "node_modules/flat-cache": {
"version": "4.0.1", "version": "4.0.1",
"resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz",

View File

@@ -1,6 +1,6 @@
{ {
"name": "pocket-id-frontend", "name": "pocket-id-frontend",
"version": "0.44.0", "version": "0.45.0",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
@@ -31,6 +31,8 @@
}, },
"devDependencies": { "devDependencies": {
"@inlang/paraglide-js": "^2.0.0", "@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", "@internationalized/date": "^3.7.0",
"@playwright/test": "^1.50.0", "@playwright/test": "^1.50.0",
"@sveltejs/adapter-auto": "^4.0.0", "@sveltejs/adapter-auto": "^4.0.0",

View File

@@ -1,10 +1,10 @@
{ {
"$schema": "https://inlang.com/schema/project-settings", "$schema": "https://inlang.com/schema/project-settings",
"baseLocale": "en-US", "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": [ "modules": [
"https://cdn.jsdelivr.net/npm/@inlang/plugin-message-format@4/dist/index.js", "./node_modules/@inlang/plugin-message-format/dist/index.js",
"https://cdn.jsdelivr.net/npm/@inlang/plugin-m-function-matcher@2/dist/index.js" "./node_modules/@inlang/plugin-m-function-matcher/dist/index.js"
], ],
"plugin.inlang.messageFormat": { "plugin.inlang.messageFormat": {
"pathPattern": "./messages/{locale}.json" "pathPattern": "./messages/{locale}.json"

View File

@@ -13,6 +13,7 @@
'en-US': 'English', 'en-US': 'English',
'fr-FR': 'Français', 'fr-FR': 'Français',
'nl-NL': 'Nederlands', 'nl-NL': 'Nederlands',
'pt-BR': 'Português brasileiro',
'ru-RU': 'Русский' '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 }) => { test('End session with id token hint redirects to callback URL', async ({ page }) => {
const client = oidcClients.nextcloud; const client = oidcClients.nextcloud;
// Note: this token has expired, but it should be accepted by the logout endpoint anyways, per spec
const idToken = const idToken =
'eyJhbGciOiJSUzI1NiIsImtpZCI6Ijh1SER3M002cmY4IiwidHlwIjoiSldUIn0.eyJhdWQiOiIzNjU0YTc0Ni0zNWQ0LTQzMjEtYWM2MS0wYmRjZmYyYjQwNTUiLCJlbWFpbCI6InRpbS5jb29rQHRlc3QuY29tIiwiZW1haWxfdmVyaWZpZWQiOnRydWUsImV4cCI6MTY5MDAwMDAwMSwiZmFtaWx5X25hbWUiOiJUaW0iLCJnaXZlbl9uYW1lIjoiQ29vayIsImlhdCI6MTY5MDAwMDAwMCwiaXNzIjoiaHR0cDovL2xvY2FsaG9zdCIsIm5hbWUiOiJUaW0gQ29vayIsIm5vbmNlIjoib1cxQTFPNzhHUTE1RDczT3NIRXg3V1FLajdacXZITFp1XzM3bWRYSXFBUSIsInN1YiI6IjRiODlkYzItNjJmYi00NmJmLTlmNWYtYzM0ZjRlYWZlOTNlIn0.ruYCyjA2BNjROpmLGPNHrhgUNLnpJMEuncvjDYVuv1dAZwvOPfG-Rn-OseAgJDJbV7wJ0qf6ZmBkGWiifwc_B9h--fgd4Vby9fefj0MiHbSDgQyaU5UmpvJU8OlvM-TueD6ICJL0NeT3DwoW5xpIWaHtt3JqJIdP__Q-lTONL2Zokq50kWm0IO-bIw2QrQviSfHNpv8A5rk1RTzpXCPXYNB-eJbm3oBqYQWzerD9HaNrSvrKA7mKG8Te1mI9aMirPpG9FvcAU-I3lY8ky1hJZDu42jHpVEUdWPAmUZPZafoX8iYtlPfkoklDnHj_cdg4aZBGN5bfjM6xf1Oe_rLDWg'; 'eyJhbGciOiJSUzI1NiIsImtpZCI6Ijh1SER3M002cmY4IiwidHlwIjoiSldUIn0.eyJhdWQiOiIzNjU0YTc0Ni0zNWQ0LTQzMjEtYWM2MS0wYmRjZmYyYjQwNTUiLCJlbWFpbCI6InRpbS5jb29rQHRlc3QuY29tIiwiZW1haWxfdmVyaWZpZWQiOnRydWUsImV4cCI6MTY5MDAwMDAwMSwiZmFtaWx5X25hbWUiOiJUaW0iLCJnaXZlbl9uYW1lIjoiQ29vayIsImlhdCI6MTY5MDAwMDAwMCwiaXNzIjoiaHR0cDovL2xvY2FsaG9zdCIsIm5hbWUiOiJUaW0gQ29vayIsIm5vbmNlIjoib1cxQTFPNzhHUTE1RDczT3NIRXg3V1FLajdacXZITFp1XzM3bWRYSXFBUSIsInN1YiI6IjRiODlkYzItNjJmYi00NmJmLTlmNWYtYzM0ZjRlYWZlOTNlIn0.ruYCyjA2BNjROpmLGPNHrhgUNLnpJMEuncvjDYVuv1dAZwvOPfG-Rn-OseAgJDJbV7wJ0qf6ZmBkGWiifwc_B9h--fgd4Vby9fefj0MiHbSDgQyaU5UmpvJU8OlvM-TueD6ICJL0NeT3DwoW5xpIWaHtt3JqJIdP__Q-lTONL2Zokq50kWm0IO-bIw2QrQviSfHNpv8A5rk1RTzpXCPXYNB-eJbm3oBqYQWzerD9HaNrSvrKA7mKG8Te1mI9aMirPpG9FvcAU-I3lY8ky1hJZDu42jHpVEUdWPAmUZPZafoX8iYtlPfkoklDnHj_cdg4aZBGN5bfjM6xf1Oe_rLDWg';