Compare commits

..

18 Commits

Author SHA1 Message Date
Elias Schneider
881d3df24e remove SetAllowedFormAction and explicitly set csp header 2026-04-19 15:30:23 +02:00
Kyle Mendell
d620bc6818 Merge branch 'main' into feat/add_form_post 2026-03-26 12:48:00 -05:00
John
b5424d49f3 Merge branch 'main' into feat/add_form_post 2026-03-26 14:28:13 +01:00
John
5c3bd875ac Merge branch 'main' into feat/add_form_post 2026-03-17 11:39:40 +01:00
Alessandro (Ale) Segala
119454bf24 Merge branch 'main' into feat/add_form_post 2026-03-10 09:38:48 +08:00
John
43b66437ce Removed whitespace 2026-03-09 21:18:46 +01:00
John van der Wulp
1d1a792043 Chagned CSP to allow only values via SetAllowedFormAction 2026-03-09 10:03:18 +01:00
John van der Wulp
def2c9264a Removed the \n that was added unintentionally. 2026-03-06 10:34:31 +01:00
John van der Wulp
7e0eae9abc added e2e test for form_post 2026-03-06 10:31:58 +01:00
John van der Wulp
108866b470 Changed error message (its a static message) 2026-03-06 09:01:52 +01:00
John van der Wulp
5f9b94742d Accept query parameter on backend. 2026-03-06 08:53:47 +01:00
John van der Wulp
45887686a7 validate the Redirect_URI 2026-03-05 22:26:39 +01:00
John van der Wulp
b42087dbfc Simplyfied ValidateResponseMode 2026-03-05 22:25:36 +01:00
John van der Wulp
3d491e0fe0 There is no query... 2026-03-05 16:46:11 +01:00
John van der Wulp
503bd8de74 Add validation for responsemode and test 2026-03-05 16:33:10 +01:00
John van der Wulp
74f49b0ff6 Simplified csp logic 2026-03-05 16:32:23 +01:00
John van der Wulp
e42bfc52cc Removed default query parameter 2026-03-05 16:31:59 +01:00
John van der Wulp
1ea5eec7d2 add support for response_mode=form_post 2026-03-04 13:21:44 +01:00
297 changed files with 5446 additions and 11530 deletions

View File

@@ -1,86 +1,12 @@
# To get started with Dependabot version updates, you'll need to specify which
# package ecosystems to update and where the package manifests are located.
# Please see the documentation for more information:
# https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates
# https://containers.dev/guide/dependabot
version: 2
multi-ecosystem-groups:
all-dependencies:
schedule:
interval: "weekly"
day: "friday"
time: "17:00"
timezone: "Europe/Zurich"
labels:
- "dependencies"
open-pull-requests-limit: 2
pull-request-branch-name:
separator: "-"
commit-message:
prefix: "chore"
include: "scope"
updates:
- package-ecosystem: "github-actions"
directory: "/"
patterns: ["*"]
multi-ecosystem-group: "all-dependencies"
ignore:
- dependency-name: "*"
update-types: ["version-update:semver-major"]
cooldown:
default-days: 7
- package-ecosystem: "gomod"
directory: "/backend"
patterns: ["*"]
multi-ecosystem-group: "all-dependencies"
ignore:
- dependency-name: "*"
update-types: ["version-update:semver-major"]
cooldown:
default-days: 7
- package-ecosystem: "npm"
directories:
- "/frontend"
- "/tests"
- "/email-templates"
patterns: ["*"]
multi-ecosystem-group: "all-dependencies"
versioning-strategy: "increase-if-necessary"
ignore:
- dependency-name: "*"
update-types: ["version-update:semver-major"]
cooldown:
default-days: 7
- package-ecosystem: "docker"
directories:
- "/docker"
- "/tests/setup"
patterns: ["*"]
multi-ecosystem-group: "all-dependencies"
ignore:
- dependency-name: "*"
update-types: ["version-update:semver-major"]
cooldown:
default-days: 7
- package-ecosystem: "docker-compose"
directories:
- "/"
- "/tests/setup"
patterns: ["*"]
multi-ecosystem-group: "all-dependencies"
ignore:
- dependency-name: "*"
update-types: ["version-update:semver-major"]
cooldown:
default-days: 7
- package-ecosystem: "devcontainers"
directory: "/"
patterns: ["*"]
multi-ecosystem-group: "all-dependencies"
ignore:
- dependency-name: "*"
update-types: ["version-update:semver-major"]
cooldown:
default-days: 7
- package-ecosystem: "devcontainers"
directory: "/"
schedule:
interval: weekly

View File

@@ -17,15 +17,14 @@ permissions:
pull-requests: read
# Optional: allow write access to checks to allow the action to annotate code in the PR.
checks: write
id-token: write
jobs:
golangci-lint:
name: Run Golangci-lint
runs-on: depot-ubuntu-latest
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v6
uses: actions/checkout@v5
- name: Set up Go
uses: actions/setup-go@v6
@@ -35,7 +34,7 @@ jobs:
- name: Run Golangci-lint
uses: golangci/golangci-lint-action@v9.0.0
with:
version: v2.11.4
version: v2.9.0
args: --build-tags=exclude_frontend
working-directory: backend
only-new-issues: ${{ github.event_name == 'pull_request' }}

View File

@@ -9,40 +9,43 @@ concurrency:
group: build-next-image
cancel-in-progress: true
permissions:
contents: read
packages: write
id-token: write
attestations: write
artifact-metadata: write
jobs:
build-next:
runs-on: depot-ubuntu-latest
env:
CONTAINER_IMAGE_NAME: ghcr.io/${{ github.repository_owner }}/pocket-id
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
id-token: write
attestations: write
steps:
- name: Checkout code
uses: actions/checkout@v6
uses: actions/checkout@v5
- name: Setup pnpm
uses: pnpm/action-setup@v5
uses: pnpm/action-setup@v4
- name: Setup Node.js
uses: actions/setup-node@v6
uses: actions/setup-node@v5
with:
node-version: 24
cache: "pnpm"
- name: Setup Go
uses: actions/setup-go@v6
with:
go-version-file: "backend/go.mod"
- name: Set up Depot CLI
uses: depot/setup-action@v1
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Set DOCKER_IMAGE_NAME
run: |
# Lowercase REPO_OWNER which is required for containers
REPO_OWNER=${{ github.repository_owner }}
DOCKER_IMAGE_NAME="ghcr.io/${REPO_OWNER,,}/pocket-id"
echo "DOCKER_IMAGE_NAME=${DOCKER_IMAGE_NAME}" >>${GITHUB_ENV}
- name: Login to GitHub Container Registry
uses: docker/login-action@v3
@@ -51,40 +54,6 @@ jobs:
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Container Image Metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.CONTAINER_IMAGE_NAME }}
tags: |
type=raw,value=next
labels: |
org.opencontainers.image.authors=Pocket ID
org.opencontainers.image.url=https://github.com/pocket-id/pocket-id
org.opencontainers.image.documentation=https://github.com/pocket-id/pocket-id/blob/main/README.md
org.opencontainers.image.source=https://github.com/pocket-id/pocket-id
org.opencontainers.image.version=next
org.opencontainers.image.licenses=BSD-2-Clause
org.opencontainers.image.ref.name=pocket-id
org.opencontainers.image.title=Pocket ID
- name: Container Image Metadata
id: distroless-meta
uses: docker/metadata-action@v5
with:
images: ${{ env.CONTAINER_IMAGE_NAME }}
tags: |
type=raw,value=next-distroless
labels: |
org.opencontainers.image.authors=Pocket ID
org.opencontainers.image.url=https://github.com/pocket-id/pocket-id
org.opencontainers.image.documentation=https://github.com/pocket-id/pocket-id/blob/main/README.md
org.opencontainers.image.source=https://github.com/pocket-id/pocket-id
org.opencontainers.image.version=next-distroless
org.opencontainers.image.licenses=BSD-2-Clause
org.opencontainers.image.ref.name=pocket-id
org.opencontainers.image.title=Pocket ID
- name: Install frontend dependencies
run: pnpm install --frozen-lockfile
@@ -97,40 +66,31 @@ jobs:
- name: Build and push container image
id: build-push-image
uses: depot/build-push-action@v1
uses: docker/build-push-action@v6
with:
context: .
file: docker/Dockerfile-prebuilt
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
sbom: false
provenance: true
tags: ${{ env.DOCKER_IMAGE_NAME }}:next
file: docker/Dockerfile-prebuilt
- name: Build and push container image (distroless)
uses: depot/build-push-action@v1
uses: docker/build-push-action@v6
id: container-build-push-distroless
with:
context: .
file: docker/Dockerfile-distroless
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.distroless-meta.outputs.tags }}
labels: ${{ steps.distroless-meta.outputs.labels }}
sbom: false
provenance: true
tags: ${{ env.DOCKER_IMAGE_NAME }}:next-distroless
file: docker/Dockerfile-distroless
- name: Container image attestation
uses: actions/attest-build-provenance@v2
with:
subject-name: "${{ env.CONTAINER_IMAGE_NAME }}"
subject-name: "${{ env.DOCKER_IMAGE_NAME }}"
subject-digest: ${{ steps.build-push-image.outputs.digest }}
push-to-registry: true
- name: Container image attestation (distroless)
uses: actions/attest-build-provenance@v2
with:
subject-name: "${{ env.CONTAINER_IMAGE_NAME }}"
subject-name: "${{ env.DOCKER_IMAGE_NAME }}"
subject-digest: ${{ steps.container-build-push-distroless.outputs.digest }}
push-to-registry: true

View File

@@ -1,27 +1,59 @@
name: E2E Tests
on:
push:
branches: [ main ]
branches: [main]
paths-ignore:
- "docs/**"
- "**.md"
- ".github/**"
pull_request:
branches: [ main, breaking/** ]
branches: [main, breaking/**]
paths-ignore:
- "docs/**"
- "**.md"
- ".github/**"
permissions:
contents: read
actions: write
id-token: write
jobs:
build:
if: github.event.pull_request.head.ref != 'i18n_crowdin'
timeout-minutes: 20
permissions:
contents: read
actions: write
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build and export
uses: docker/build-push-action@v6
with:
context: .
file: docker/Dockerfile
push: false
load: false
tags: pocket-id:test
outputs: type=docker,dest=/tmp/docker-image.tar
build-args: BUILD_TAGS=e2etest
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Upload Docker image artifact
uses: actions/upload-artifact@v4
with:
name: docker-image
path: /tmp/docker-image.tar
retention-days: 1
test:
if: github.event.pull_request.head.ref != 'i18n_crowdin'
runs-on: depot-ubuntu-24.04-32
permissions:
contents: read
actions: write
runs-on: ubuntu-latest
needs: build
strategy:
fail-fast: false
matrix:
@@ -38,33 +70,25 @@ jobs:
storage: database
steps:
- name: Checkout code
uses: actions/checkout@v6
- uses: actions/checkout@v5
- name: Setup pnpm
uses: pnpm/action-setup@v5
uses: pnpm/action-setup@v4
- name: Setup Node.js
uses: actions/setup-node@v6
uses: actions/setup-node@v5
with:
node-version: 24
cache: "pnpm"
- name: Set up Depot CLI
uses: depot/setup-action@v1
- name: Set up Depot Docker builder
run: depot configure-docker
- name: Cache Playwright Browsers
uses: actions/cache@v5
uses: actions/cache@v4
id: playwright-cache
with:
path: ~/.cache/ms-playwright
key: ${{ runner.os }}-playwright-${{ hashFiles('pnpm-lock.yaml') }}
- name: Cache PostgreSQL Docker image
uses: actions/cache@v5
uses: actions/cache@v4
id: postgres-cache
with:
path: /tmp/postgres-image.tar
@@ -78,8 +102,23 @@ jobs:
if: matrix.db == 'postgres' && steps.postgres-cache.outputs.cache-hit == 'true'
run: docker load < /tmp/postgres-image.tar
- name: Cache LLDAP Docker image
uses: actions/cache@v4
id: lldap-cache
with:
path: /tmp/lldap-image.tar
key: lldap-stable-${{ runner.os }}
- name: Pull and save LLDAP image
if: steps.lldap-cache.outputs.cache-hit != 'true'
run: |
docker pull lldap/lldap:2025-05-19
docker save lldap/lldap:2025-05-19 > /tmp/lldap-image.tar
- name: Load LLDAP image
if: steps.lldap-cache.outputs.cache-hit == 'true'
run: docker load < /tmp/lldap-image.tar
- name: Cache SCIM Test Server Docker image
uses: actions/cache@v5
uses: actions/cache@v4
id: scim-cache
with:
path: /tmp/scim-test-server-image.tar
@@ -95,20 +134,45 @@ jobs:
- name: Cache Localstack S3 Docker image
if: matrix.storage == 's3'
uses: actions/cache@v5
uses: actions/cache@v4
id: s3-cache
with:
path: /tmp/localstack-s3-image.tar
key: localstack-4.14.0-${{ runner.os }}
key: localstack-s3-latest-${{ runner.os }}
- name: Pull and save Localstack S3 image
if: matrix.storage == 's3' && steps.s3-cache.outputs.cache-hit != 'true'
run: |
docker pull localstack/localstack:4.14.0
docker save localstack/localstack:4.14.0 > /tmp/localstack-s3-image.tar
docker pull localstack/localstack:s3-latest
docker save localstack/localstack:s3-latest > /tmp/localstack-s3-image.tar
- name: Load Localstack S3 image
if: matrix.storage == 's3' && steps.s3-cache.outputs.cache-hit == 'true'
run: docker load < /tmp/localstack-s3-image.tar
- name: Cache AWS CLI Docker image
if: matrix.storage == 's3'
uses: actions/cache@v4
id: aws-cli-cache
with:
path: /tmp/aws-cli-image.tar
key: aws-cli-latest-${{ runner.os }}
- name: Pull and save AWS CLI image
if: matrix.storage == 's3' && steps.aws-cli-cache.outputs.cache-hit != 'true'
run: |
docker pull amazon/aws-cli:latest
docker save amazon/aws-cli:latest > /tmp/aws-cli-image.tar
- name: Load AWS CLI image
if: matrix.storage == 's3' && steps.aws-cli-cache.outputs.cache-hit == 'true'
run: docker load < /tmp/aws-cli-image.tar
- name: Download Docker image artifact
uses: actions/download-artifact@v4
with:
name: docker-image
path: /tmp
- name: Load Docker image
run: docker load -i /tmp/docker-image.tar
- name: Install test dependencies
run: pnpm --filter pocket-id-tests install --frozen-lockfile
@@ -134,7 +198,7 @@ jobs:
DOCKER_COMPOSE_FILE=docker-compose-s3.yml
fi
docker compose -f "$DOCKER_COMPOSE_FILE" up -d --build
docker compose -f "$DOCKER_COMPOSE_FILE" up -d
{
LOG_FILE="/tmp/backend.log"
@@ -155,7 +219,7 @@ jobs:
run: pnpm exec playwright test
- name: Upload Test Report
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@v4
if: always() && github.event.pull_request.head.ref != 'i18n_crowdin'
with:
name: playwright-report-${{ matrix.db }}-${{ matrix.storage }}
@@ -164,7 +228,7 @@ jobs:
retention-days: 15
- name: Upload Backend Test Report
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@v4
if: always() && github.event.pull_request.head.ref != 'i18n_crowdin'
with:
name: backend-${{ matrix.db }}-${{ matrix.storage }}

View File

@@ -5,140 +5,105 @@ on:
tags:
- "v*.*.*"
permissions:
contents: write
packages: write
attestations: write
id-token: write
artifact-metadata: write
jobs:
build:
runs-on: depot-ubuntu-24.04-16
env:
CONTAINER_IMAGE_NAME: ghcr.io/${{ github.repository_owner }}/pocket-id
runs-on: ubuntu-latest
permissions:
contents: write
packages: write
attestations: write
id-token: write
steps:
- name: Checkout code
uses: actions/checkout@v6
uses: actions/checkout@v3
- name: Setup pnpm
uses: pnpm/action-setup@v5
- name: Set up Depot CLI
uses: depot/setup-action@v1
uses: pnpm/action-setup@v4
- name: Setup Node.js
uses: actions/setup-node@v6
uses: actions/setup-node@v5
with:
node-version: 24
cache: "pnpm"
- uses: actions/setup-go@v6
with:
go-version-file: "backend/go.mod"
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Set DOCKER_IMAGE_NAME
run: |
# Lowercase REPO_OWNER which is required for containers
REPO_OWNER=${{ github.repository_owner }}
DOCKER_IMAGE_NAME="ghcr.io/${REPO_OWNER,,}/pocket-id"
echo "DOCKER_IMAGE_NAME=${DOCKER_IMAGE_NAME}" >>${GITHUB_ENV}
- name: Login to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{github.repository_owner}}
password: ${{secrets.GITHUB_TOKEN}}
- name: Docker metadata
id: meta
uses: docker/metadata-action@v5
with:
images: |
${{ env.CONTAINER_IMAGE_NAME }}
${{ env.DOCKER_IMAGE_NAME }}
tags: |
type=semver,pattern={{version}},prefix=v
type=semver,pattern={{major}}.{{minor}},prefix=v
type=semver,pattern={{major}},prefix=v
labels: |
org.opencontainers.image.authors=Pocket ID
org.opencontainers.image.url=https://github.com/pocket-id/pocket-id
org.opencontainers.image.documentation=https://github.com/pocket-id/pocket-id/blob/main/README.md
org.opencontainers.image.source=https://github.com/pocket-id/pocket-id
org.opencontainers.image.version=next
org.opencontainers.image.licenses=BSD-2-Clause
org.opencontainers.image.ref.name=pocket-id
org.opencontainers.image.title=Pocket ID
- name: Docker metadata (distroless)
id: meta-distroless
uses: docker/metadata-action@v5
with:
images: |
${{ env.CONTAINER_IMAGE_NAME }}
${{ env.DOCKER_IMAGE_NAME }}
flavor: |
suffix=-distroless,onlatest=true
tags: |
type=semver,pattern={{version}},prefix=v
type=semver,pattern={{major}}.{{minor}},prefix=v
type=semver,pattern={{major}},prefix=v
labels: |
org.opencontainers.image.authors=Pocket ID
org.opencontainers.image.url=https://github.com/pocket-id/pocket-id
org.opencontainers.image.documentation=https://github.com/pocket-id/pocket-id/blob/main/README.md
org.opencontainers.image.source=https://github.com/pocket-id/pocket-id
org.opencontainers.image.version=next-distroless
org.opencontainers.image.licenses=BSD-2-Clause
org.opencontainers.image.ref.name=pocket-id
org.opencontainers.image.title=Pocket ID
- name: Install frontend dependencies
run: pnpm --filter pocket-id-frontend install --frozen-lockfile
- name: Build frontend
run: pnpm --filter pocket-id-frontend build
- name: Build binaries
run: sh scripts/development/build-binaries.sh
- name: Build and push container image
uses: depot/build-push-action@v1
uses: docker/build-push-action@v6
id: container-build-push
with:
context: .
file: docker/Dockerfile-prebuilt
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
sbom: false
provenance: true
file: docker/Dockerfile-prebuilt
- name: Build and push container image (distroless)
uses: depot/build-push-action@v1
uses: docker/build-push-action@v6
id: container-build-push-distroless
with:
context: .
file: docker/Dockerfile-distroless
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.meta-distroless.outputs.tags }}
labels: ${{ steps.meta-distroless.outputs.labels }}
sbom: false
provenance: true
file: docker/Dockerfile-distroless
- name: Binary attestation
uses: actions/attest-build-provenance@v2
with:
subject-path: "backend/.bin/pocket-id-**"
- name: Container image attestation
uses: actions/attest-build-provenance@v2
with:
subject-name: "${{ env.CONTAINER_IMAGE_NAME }}"
subject-name: "${{ env.DOCKER_IMAGE_NAME }}"
subject-digest: ${{ steps.container-build-push.outputs.digest }}
push-to-registry: true
- name: Container image attestation (distroless)
uses: actions/attest-build-provenance@v2
with:
subject-name: "${{ env.CONTAINER_IMAGE_NAME }}"
subject-name: "${{ env.DOCKER_IMAGE_NAME }}"
subject-digest: ${{ steps.container-build-push-distroless.outputs.digest }}
push-to-registry: true
- name: Upload binaries to release
@@ -147,12 +112,14 @@ jobs:
run: gh release upload ${{ github.ref_name }} backend/.bin/*
publish-release:
runs-on: depot-ubuntu-latest
runs-on: ubuntu-latest
needs: [build]
permissions:
contents: write
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
steps:
- name: Checkout code
uses: actions/checkout@v6
uses: actions/checkout@v5
- name: Mark release as published
run: gh release edit ${{ github.ref_name }} --draft=false

View File

@@ -21,31 +21,28 @@ on:
- "frontend/svelte.config.js"
workflow_dispatch:
permissions:
contents: read
checks: write
pull-requests: write
id-token: write
jobs:
type-check:
name: Run Svelte Check
# Don't run on dependabot branches
if: github.actor != 'dependabot[bot]'
runs-on: depot-ubuntu-latest
runs-on: ubuntu-latest
permissions:
contents: read
checks: write
pull-requests: write
steps:
- name: Checkout code
uses: actions/checkout@v6
uses: actions/checkout@v5
- name: Setup pnpm
uses: pnpm/action-setup@v5
uses: pnpm/action-setup@v4
- name: Setup Node.js
uses: actions/setup-node@v6
uses: actions/setup-node@v5
with:
node-version: 24
cache: "pnpm"
- name: Install dependencies
run: pnpm --filter pocket-id-frontend install --frozen-lockfile

View File

@@ -1,24 +1,22 @@
name: Unit Tests
on:
push:
branches: [ main ]
branches: [main]
paths:
- "backend/**"
pull_request:
branches: [ main ]
branches: [main]
paths:
- "backend/**"
permissions:
contents: read
id-token: write
actions: write
jobs:
test-backend:
runs-on: depot-ubuntu-latest
permissions:
contents: read
actions: write
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v5
- uses: actions/setup-go@v6
with:
go-version-file: "backend/go.mod"
@@ -32,7 +30,7 @@ jobs:
run: |
set -e -o pipefail
go test -tags=exclude_frontend -v ./... | tee /tmp/TestResults.log
- uses: actions/upload-artifact@v7
- uses: actions/upload-artifact@v4
if: always()
with:
name: backend-unit-tests

View File

@@ -8,15 +8,14 @@ on:
permissions:
contents: write
pull-requests: write
id-token: write
jobs:
update-aaguids:
runs-on: depot-ubuntu-latest
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v6
uses: actions/checkout@v5
- name: Fetch JSON data
run: |

View File

@@ -1 +1 @@
2.7.0
2.4.0

View File

@@ -1,122 +1,3 @@
## v2.7.0
### Bug Fixes
- add `_FILE` support for `S3_SECRET_ACCESS_KEY_FILE` env var ([#1452](https://github.com/pocket-id/pocket-id/pull/1452) by @ItalyPaleAle)
- invalidate cache when changing image ([#1462](https://github.com/pocket-id/pocket-id/pull/1462) by @GameTec-live)
- fall back to Basic auth when PKCE puts client_id in body ([#1466](https://github.com/pocket-id/pocket-id/pull/1466) by @mgabor3141)
### Documentation
- add missing /api prefix to app config swagger routes ([#1454](https://github.com/pocket-id/pocket-id/pull/1454) by @aclerici38)
### Features
- add support for response_mode=form_post ([#1360](https://github.com/pocket-id/pocket-id/pull/1360) by @Johnwulp)
- add support for "select_account" prompt ([#1453](https://github.com/pocket-id/pocket-id/pull/1453) by @ItalyPaleAle)
### Other
- add script to update deps ([f9f93f0](https://github.com/pocket-id/pocket-id/commit/f9f93f0ef164426d4d4cfb58fcf59ce30b686130) by @stonith404)
- upgrade dependencies ([20df033](https://github.com/pocket-id/pocket-id/commit/20df033c1fc3d737927447206b4f5e65d3ab4a40) by @stonith404)
- post dependency upgrade fixes ([e33a9b8](https://github.com/pocket-id/pocket-id/commit/e33a9b8c888988f7b2e6c500e72d53b57db01f34) by @stonith404)
- migrate github actions runners to depot runners ([#1329](https://github.com/pocket-id/pocket-id/pull/1329) by @kmendell)
- fix caching of ldap-cli e2e tests docker build ([#1457](https://github.com/pocket-id/pocket-id/pull/1457) by @kmendell)
- fix incorrect container name variable ([5c7e5f6](https://github.com/pocket-id/pocket-id/commit/5c7e5f680bb70d3056b11f1a33669cc4bd9a1e17) by @kmendell)
**Full Changelog**: https://github.com/pocket-id/pocket-id/compare/v2.6.2...v2.7.0
## v2.6.2
### Bug Fixes
- return correct byte count in HEAD request writer ([#1443](https://github.com/pocket-id/pocket-id/pull/1443) by @ahampal)
- improve keyboard navigation and screen-reader labels ([#1445](https://github.com/pocket-id/pocket-id/pull/1445) by @bjoernch)
### Other
- upgrade to vite 8.0 and pnpm 10.33.0 ([#1446](https://github.com/pocket-id/pocket-id/pull/1446) by @kmendell)
**Full Changelog**: https://github.com/pocket-id/pocket-id/compare/v2.6.1...v2.6.2
## v2.6.1
### Bug Fixes
- restore login screen background from not showing up ([975d3c7](https://github.com/pocket-id/pocket-id/commit/975d3c79c6a882291c69b31d25bfcd8b7896528c) by @kmendell)
### Other
- ignore webauthn type for swagger generation ([ce4b89d](https://github.com/pocket-id/pocket-id/commit/ce4b89da650f025747fd0dd45eab5cebe29f5a93) by @kmendell)
- update golangci-lint ([#1440](https://github.com/pocket-id/pocket-id/pull/1440) by @ItalyPaleAle)
- Add catalan language ([#1436](https://github.com/pocket-id/pocket-id/pull/1436) by @mcasellas)
**Full Changelog**: https://github.com/pocket-id/pocket-id/compare/v2.6.0...v2.6.1
## v2.6.0
### Bug Fixes
- disable callback URLs with protocols "javascript" and "data" ([#1397](https://github.com/pocket-id/pocket-id/pull/1397) by @ItalyPaleAle)
- strip Root prefix from S3 List() returned paths ([#1413](https://github.com/pocket-id/pocket-id/pull/1413) by @vtmocanu)
- use valid Tailwind v4 transition class for auth animation squares ([#1415](https://github.com/pocket-id/pocket-id/pull/1415) by @CoolShades)
- resolve posixGroup memberUid as bare usernames ([#1422](https://github.com/pocket-id/pocket-id/pull/1422) by @gucong3000)
- prevent flickering if no background image is set on login page ([027e6f0](https://github.com/pocket-id/pocket-id/commit/027e6f078da0eec712ae22a04b37c86110cb262b) by @stonith404)
- improve form input layout if description next to it is multi col ([9ec4683](https://github.com/pocket-id/pocket-id/commit/9ec4683d18036ba1945bffd4bce14ec4c2dff7f9) by @stonith404)
- access token renewal bypasses important checks ([978ac87](https://github.com/pocket-id/pocket-id/commit/978ac87deffec58beaccd15aead975e91b94c8a5) by @stonith404)
### Features
- add ability to revoke passkeys of users as admin ([#1386](https://github.com/pocket-id/pocket-id/pull/1386) by @jose-d)
- add auth method claim (`amr`) to tokens ([#1433](https://github.com/pocket-id/pocket-id/pull/1433) by @stonith404)
- add TLS support for HTTP/2 server ([#1429](https://github.com/pocket-id/pocket-id/pull/1429) by @IngmarStein)
- add OpenID Connect `prompt` Parameter Handling ([#1299](https://github.com/pocket-id/pocket-id/pull/1299) by @rjaakke)
- return not found. on `/setup` if already completed ([444f7ff](https://github.com/pocket-id/pocket-id/commit/444f7ff2b0269c12f1dba334a37d7db2007e172f) by @stonith404)
### Other
- update AAGUIDs ([#1403](https://github.com/pocket-id/pocket-id/pull/1403) by @github-actions[bot])
- upgrade dependencies ([f8f7222](https://github.com/pocket-id/pocket-id/commit/f8f7222468dad90f630ae18f7c3fd78e37ba3f77) by @stonith404)
- combobox not closed in e2e test ([fbdb93f](https://github.com/pocket-id/pocket-id/commit/fbdb93f1a768a05e6e3f2c6fd32b5de50a745bc6) by @stonith404)
- Security upgrade alpine from latest to 3.23.4 ([#1431](https://github.com/pocket-id/pocket-id/pull/1431) by @stonith404)
- security upgrade alpine from latest to 3.23.4 ([#1432](https://github.com/pocket-id/pocket-id/pull/1432) by @stonith404)
- add Catalan language files ([4f09de2](https://github.com/pocket-id/pocket-id/commit/4f09de2cfc7d1e92632116821493a670fc7ee80d) by @stonith404)
- reduce complexity of `ValidateEnvConfig` and `initRouter` ([a0cb574](https://github.com/pocket-id/pocket-id/commit/a0cb57431372c2bcc59904342597845e92a42a93) by @stonith404)
- pass context to `shutdownServer` ([ff26c42](https://github.com/pocket-id/pocket-id/commit/ff26c4273a061b7d2c84e7b74f1e0f9e0acc6eb0) by @stonith404)
**Full Changelog**: https://github.com/pocket-id/pocket-id/compare/v2.5.0...v2.6.0
## v2.5.0
### Bug Fixes
- better error messages when there's another instance of Pocket ID running ([#1370](https://github.com/pocket-id/pocket-id/pull/1370) by @ItalyPaleAle)
- move tooltip inside of form input to prevent shifting ([#1369](https://github.com/pocket-id/pocket-id/pull/1369) by @GameTec-live)
- derive LDAP admin access from group membership ([#1374](https://github.com/pocket-id/pocket-id/pull/1374) by @kmendell)
- avoid fmt.Sprintf on custom GeoLiteDBUrl without %s placeholder ([#1384](https://github.com/pocket-id/pocket-id/pull/1384) by @choyri)
- show a warning when SQLite DB is stored on NFS/SMB/FUSE ([#1381](https://github.com/pocket-id/pocket-id/pull/1381) by @ItalyPaleAle)
- empty background restore after reboot ([#1379](https://github.com/pocket-id/pocket-id/pull/1379) by @taoso)
- allow one-char username on signup ([#1378](https://github.com/pocket-id/pocket-id/pull/1378) by @taoso)
### Features
- allow use of svg, png, and ico images types for favicon ([#1289](https://github.com/pocket-id/pocket-id/pull/1289) by @taoso)
- allow clearing background image ([#1290](https://github.com/pocket-id/pocket-id/pull/1290) by @taoso)
- add `token_endpoint_auth_methods_supported` to `.well-known` ([#1388](https://github.com/pocket-id/pocket-id/pull/1388) by @owenvoke)
- add TRUSTED_PLATFORM environment variable for gin ([#1372](https://github.com/pocket-id/pocket-id/pull/1372) by @choyri)
### Other
- add pr quality action ([e3905cf](https://github.com/pocket-id/pocket-id/commit/e3905cf3159fe0370778b0d7d3be64b4246d19be) by @stonith404)
- separate querying LDAP and updating DB during sync ([#1371](https://github.com/pocket-id/pocket-id/pull/1371) by @ItalyPaleAle)
- bump google.golang.org/grpc from 1.79.1 to 1.79.3 in /backend in the go_modules group across 1 directory ([#1391](https://github.com/pocket-id/pocket-id/pull/1391) by @dependabot[bot])
- Improve Latvian translations in lv.json ([#1382](https://github.com/pocket-id/pocket-id/pull/1382) by @Raito00)
- ignore linter on app image bootstrap ([5251cd9](https://github.com/pocket-id/pocket-id/commit/5251cd97994177c96cb6f9ab3f88ca31367b5b55) by @kmendell)
- upgrade dependencies ([e7e0176](https://github.com/pocket-id/pocket-id/commit/e7e0176316857186b9683e2f0cb0686189f86cfb) by @kmendell)
- upgrade dependencies ([3c42a71](https://github.com/pocket-id/pocket-id/commit/3c42a713ce91b4061ffcf86d92cbb19294359ff8) by @kmendell)
**Full Changelog**: https://github.com/pocket-id/pocket-id/compare/v2.4.0...v2.5.0
## v2.4.0
### Bug Fixes

View File

@@ -2,11 +2,8 @@
## Supported Versions
It's recommended to always use the latest version of Pocket ID. We will provide security updates for the latest version.
> [!NOTE]
> Updates can be automated with e.g [Watchtower](https://github.com/containrrr/watchtower). Upgrading between non major versions is safe but you shouldn't upgrade between major versions before checking the release notes.
As Pocket ID is in its early stages, older versions don't get security updates. Please consider to update Pocket ID regularly. Updates can be automated with e.g [Watchtower](https://github.com/containrrr/watchtower).
## Reporting a Vulnerability
Thank you for taking the time to report a vulnerability. Please DO NOT create an issue on GitHub because the vulnerability could get exploited. Instead please create a Security Advisory [here](https://github.com/pocket-id/pocket-id/security/advisories/new). You can learn more about how to report a vulnerability in the [GitHub Security Advisories documentation](https://docs.github.com/en/code-security/security-advisories/creating-a-security-advisory).
Thank you for taking the time to report a vulnerability. Please DO NOT create an issue on GitHub because the vulnerability could get exploited. Instead please write an email to [elias@eliasschneider.com](mailto:elias@eliasschneider.com).

View File

@@ -7,6 +7,6 @@ import (
"github.com/pocket-id/pocket-id/backend/internal/service"
)
func RegisterFrontend(router *gin.Engine, oidcService *service.OidcService) error {
func RegisterFrontend(router *gin.Engine, rateLimitMiddleware gin.HandlerFunc, oidcService *service.OidcService) error {
return ErrFrontendNotIncluded
}

View File

@@ -10,6 +10,7 @@ import (
"io/fs"
"mime"
"net/http"
"os"
"path"
"strings"
"time"
@@ -17,7 +18,6 @@ import (
"github.com/gin-gonic/gin"
"github.com/pocket-id/pocket-id/backend/internal/middleware"
"github.com/pocket-id/pocket-id/backend/internal/service"
"golang.org/x/time/rate"
)
//go:embed all:dist/*
@@ -55,7 +55,7 @@ func init() {
}
}
func RegisterFrontend(router *gin.Engine, oidcService *service.OidcService) error {
func RegisterFrontend(router *gin.Engine, rateLimitMiddleware gin.HandlerFunc, oidcService *service.OidcService) error {
distFS, err := fs.Sub(frontendFS, "dist")
if err != nil {
return fmt.Errorf("failed to create sub FS: %w", err)
@@ -79,18 +79,27 @@ func RegisterFrontend(router *gin.Engine, oidcService *service.OidcService) erro
return
}
if strings.HasPrefix(path, "api/") || strings.HasPrefix(path, ".well-known/") {
if strings.HasPrefix(path, "api/") {
c.JSON(http.StatusNotFound, gin.H{"error": "API endpoint not found"})
return
}
if isSPARequest(path, distFS) {
// If path is / or does not exist, serve index.html
if path == "" {
path = "index.html"
} else if _, err := fs.Stat(distFS, path); os.IsNotExist(err) {
path = "index.html"
}
if path == "index.html" {
nonce := middleware.GetCSPNonce(c)
if isOAuth2AuthorizationPostRequest(c) {
// In that case, we need to validate and allow form submissions to the redirect_uri
redirectURI := c.Query("redirect_uri")
clientID := c.Query("client_id")
// Check if this is an OAuth2 authorization request with response_mode=form_post
// In that case, we need to validate and allow form submissions to the redirect_uri
responseMode := c.Query("response_mode")
redirectURI := c.Query("redirect_uri")
clientID := c.Query("client_id")
if responseMode == "form_post" && redirectURI != "" && clientID != "" {
validatedRedirectURI, err := oidcService.ResolveAllowedCallbackURL(c.Request.Context(), clientID, redirectURI)
if err == nil {
c.Header("Content-Security-Policy", middleware.BuildCSP(nonce, validatedRedirectURI))
@@ -112,46 +121,11 @@ func RegisterFrontend(router *gin.Engine, oidcService *service.OidcService) erro
fileServer.ServeHTTP(c.Writer, c.Request)
}
rateLimitMiddleware := middleware.NewRateLimitMiddleware().Add(rate.Every(300*time.Millisecond), 50)
router.NoRoute(rateLimitOnlyForOAuth2AuthorizationPostRequest(rateLimitMiddleware, distFS), handler)
router.NoRoute(rateLimitMiddleware, handler)
return nil
}
func rateLimitOnlyForOAuth2AuthorizationPostRequest(rateLimitMiddleware gin.HandlerFunc, distFS fs.FS) gin.HandlerFunc {
return func(c *gin.Context) {
path := strings.TrimPrefix(c.Request.URL.Path, "/")
if isSPARequest(path, distFS) && isOAuth2AuthorizationPostRequest(c) {
rateLimitMiddleware(c)
return
}
c.Next()
}
}
// isOAuth2AuthorizationRequest checks if this is an OAuth2 authorization request with response_mode=form_post
// In that case, we need to validate and allow form submissions to the redirect_uri
func isOAuth2AuthorizationPostRequest(c *gin.Context) bool {
responseMode := c.Query("response_mode")
redirectURI := c.Query("redirect_uri")
clientID := c.Query("client_id")
return responseMode == "form_post" && redirectURI != "" && clientID != ""
}
func isSPARequest(path string, distFS fs.FS) bool {
if path == "" {
return true
}
if _, err := fs.Stat(distFS, path); err != nil {
return true
}
return false
}
// FileServerWithCaching wraps http.FileServer to add caching headers
type FileServerWithCaching struct {
root http.FileSystem

View File

@@ -1,111 +0,0 @@
//go:build !exclude_frontend
package frontend
import (
"net/http"
"net/http/httptest"
"testing"
"testing/fstest"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
)
func TestIsSPARequest(t *testing.T) {
distFS := fstest.MapFS{
"assets/app.js": &fstest.MapFile{Data: []byte("console.log('test')")},
}
t.Run("root path is spa request", func(t *testing.T) {
assert.True(t, isSPARequest("", distFS))
})
t.Run("existing bundled asset is not spa request", func(t *testing.T) {
assert.False(t, isSPARequest("assets/app.js", distFS))
})
t.Run("unknown path is spa request", func(t *testing.T) {
assert.True(t, isSPARequest("authorize", distFS))
})
}
func TestRateLimitOnlyForOAuth2AuthorizationPostRequest(t *testing.T) {
gin.SetMode(gin.TestMode)
distFS := fstest.MapFS{
"assets/app.js": &fstest.MapFile{Data: []byte("console.log('test')")},
}
t.Run("rate limits spa form_post request", func(t *testing.T) {
rateLimited := false
nextCalled := false
middleware := rateLimitOnlyForOAuth2AuthorizationPostRequest(func(c *gin.Context) {
rateLimited = true
c.Abort()
}, distFS)
router := gin.New()
router.NoRoute(
middleware,
func(c *gin.Context) {
nextCalled = true
},
)
recorder := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/authorize?response_mode=form_post&client_id=test&redirect_uri=https://example.com/callback", nil)
router.ServeHTTP(recorder, req)
assert.True(t, rateLimited)
assert.False(t, nextCalled)
})
t.Run("does not rate limit page request with no form_post params", func(t *testing.T) {
rateLimited := false
nextCalled := false
middleware := rateLimitOnlyForOAuth2AuthorizationPostRequest(func(c *gin.Context) {
rateLimited = true
c.Abort()
}, distFS)
router := gin.New()
router.NoRoute(
middleware,
func(c *gin.Context) {
nextCalled = true
},
)
recorder := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/authorize", nil)
router.ServeHTTP(recorder, req)
assert.False(t, rateLimited)
assert.True(t, nextCalled)
})
t.Run("does not rate limit static asset request with form_post params", func(t *testing.T) {
rateLimited := false
nextCalled := false
middleware := rateLimitOnlyForOAuth2AuthorizationPostRequest(func(c *gin.Context) {
rateLimited = true
c.Abort()
}, distFS)
router := gin.New()
router.NoRoute(
middleware,
func(c *gin.Context) {
nextCalled = true
},
)
recorder := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/assets/app.js?response_mode=form_post&client_id=test&redirect_uri=https://example.com/callback", nil)
router.ServeHTTP(recorder, req)
assert.False(t, rateLimited)
assert.True(t, nextCalled)
})
}

View File

@@ -3,123 +3,110 @@ module github.com/pocket-id/pocket-id/backend
go 1.26.0
require (
github.com/aws/aws-sdk-go-v2 v1.41.7
github.com/aws/aws-sdk-go-v2/config v1.32.17
github.com/aws/aws-sdk-go-v2/credentials v1.19.16
github.com/aws/aws-sdk-go-v2/service/s3 v1.101.0
github.com/aws/smithy-go v1.25.1
github.com/caarlos0/env/v11 v11.4.1
github.com/aws/aws-sdk-go-v2 v1.41.1
github.com/aws/aws-sdk-go-v2/config v1.32.9
github.com/aws/aws-sdk-go-v2/credentials v1.19.9
github.com/aws/aws-sdk-go-v2/service/s3 v1.96.0
github.com/aws/smithy-go v1.24.1
github.com/caarlos0/env/v11 v11.3.1
github.com/cenkalti/backoff/v5 v5.0.3
github.com/coreos/go-systemd v0.0.0-20191104093116-d3cd4ed1dbcf
github.com/disintegration/imageorient v0.0.0-20180920195336-8147d86e83ec
github.com/disintegration/imaging v1.6.2
github.com/dunglas/go-urlpattern v0.0.0-20241020164140-716dfa1c80b1
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6
github.com/emersion/go-smtp v0.24.0
github.com/fsnotify/fsnotify v1.10.1
github.com/gin-contrib/slog v1.2.1
github.com/gin-gonic/gin v1.12.0
github.com/gin-contrib/slog v1.2.0
github.com/gin-gonic/gin v1.11.0
github.com/glebarez/go-sqlite v1.22.0
github.com/glebarez/sqlite v1.11.0
github.com/go-co-op/gocron/v2 v2.21.1
github.com/go-ldap/ldap/v3 v3.4.13
github.com/go-playground/validator/v10 v10.30.2
github.com/go-webauthn/webauthn v0.17.3
github.com/go-co-op/gocron/v2 v2.19.1
github.com/go-ldap/ldap/v3 v3.4.12
github.com/go-playground/validator/v10 v10.30.1
github.com/go-webauthn/webauthn v0.15.0
github.com/golang-migrate/migrate/v4 v4.19.1
github.com/google/uuid v1.6.0
github.com/hashicorp/go-uuid v1.0.3
github.com/jinzhu/copier v0.4.0
github.com/joho/godotenv v1.5.1
github.com/lestrrat-go/httprc/v3 v3.0.5
github.com/lestrrat-go/jwx/v3 v3.1.1
github.com/lestrrat-go/httprc/v3 v3.0.4
github.com/lestrrat-go/jwx/v3 v3.0.13
github.com/lmittmann/tint v1.1.3
github.com/mattn/go-isatty v0.0.22
github.com/mattn/go-isatty v0.0.20
github.com/mileusna/useragent v1.3.5
github.com/orandin/slog-gorm v1.4.0
github.com/oschwald/maxminddb-golang/v2 v2.2.0
github.com/oschwald/maxminddb-golang/v2 v2.1.1
github.com/spf13/cobra v1.10.2
github.com/stretchr/testify v1.11.1
github.com/zitadel/exifremove v0.1.0
go.opentelemetry.io/contrib/bridges/otelslog v0.18.0
go.opentelemetry.io/contrib/exporters/autoexport v0.68.0
go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin v0.68.0
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0
go.opentelemetry.io/otel v1.43.0
go.opentelemetry.io/otel/log v0.19.0
go.opentelemetry.io/otel/metric v1.43.0
go.opentelemetry.io/otel/sdk v1.43.0
go.opentelemetry.io/otel/sdk/log v0.19.0
go.opentelemetry.io/otel/sdk/metric v1.43.0
go.opentelemetry.io/otel/trace v1.43.0
golang.org/x/crypto v0.51.0
golang.org/x/image v0.40.0
golang.org/x/sync v0.20.0
golang.org/x/text v0.37.0
golang.org/x/time v0.15.0
go.opentelemetry.io/contrib/bridges/otelslog v0.15.0
go.opentelemetry.io/contrib/exporters/autoexport v0.65.0
go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin v0.65.0
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0
go.opentelemetry.io/otel v1.40.0
go.opentelemetry.io/otel/log v0.16.0
go.opentelemetry.io/otel/metric v1.40.0
go.opentelemetry.io/otel/sdk v1.40.0
go.opentelemetry.io/otel/sdk/log v0.16.0
go.opentelemetry.io/otel/sdk/metric v1.40.0
go.opentelemetry.io/otel/trace v1.40.0
golang.org/x/crypto v0.48.0
golang.org/x/image v0.36.0
golang.org/x/net v0.50.0
golang.org/x/sync v0.19.0
golang.org/x/text v0.34.0
golang.org/x/time v0.14.0
gorm.io/driver/postgres v1.6.0
gorm.io/gorm v1.31.1
)
require (
github.com/Azure/go-ntlmssp v0.1.0 // indirect
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.10 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.23 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.23 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.23 // indirect
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.24 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.9 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.15 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.23 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.23 // indirect
github.com/aws/aws-sdk-go-v2/service/signin v1.0.11 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.30.17 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.21 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.42.1 // indirect
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.17 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.17 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.17 // indirect
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 // indirect
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.17 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.8 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.17 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.17 // indirect
github.com/aws/aws-sdk-go-v2/service/signin v1.0.5 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.30.10 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.14 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.41.6 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/bits-and-blooms/bitset v1.24.4 // indirect
github.com/bytedance/gopkg v0.1.4 // indirect
github.com/bits-and-blooms/bitset v1.14.3 // indirect
github.com/bytedance/gopkg v0.1.3 // indirect
github.com/bytedance/sonic v1.15.0 // indirect
github.com/bytedance/sonic/loader v0.5.1 // indirect
github.com/bytedance/sonic/loader v0.5.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/cloudwego/base64x v0.1.6 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1 // indirect
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 // indirect
github.com/disintegration/gift v1.2.1 // indirect
github.com/dsoprea/go-exif v0.0.0-20230826092837-6579e82b732d // indirect
github.com/dsoprea/go-exif/v2 v2.0.0-20230826092837-6579e82b732d // indirect
github.com/dsoprea/go-iptc v0.0.0-20200610044640-bc9ca208b413 // indirect
github.com/dsoprea/go-jpeg-image-structure v0.0.0-20221012074422-4f3f7e934102 // indirect
github.com/dsoprea/go-logging v0.0.0-20200710184922-b02d349568dd // indirect
github.com/dsoprea/go-photoshop-info-format v0.0.0-20200610045659-121dd752914d // indirect
github.com/dsoprea/go-png-image-structure v0.0.0-20210512210324-29b889a6093d // indirect
github.com/dsoprea/go-utility v0.0.0-20221003172846-a3e1774ef349 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/fxamacker/cbor/v2 v2.9.2 // indirect
github.com/fxamacker/cbor/v2 v2.9.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.13 // indirect
github.com/gin-contrib/sse v1.1.1 // indirect
github.com/gin-contrib/sse v1.1.0 // indirect
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 // indirect
github.com/go-errors/errors v1.5.1 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
github.com/go-webauthn/x v0.2.5 // indirect
github.com/go-xmlfmt/xmlfmt v1.1.3 // indirect
github.com/goccy/go-json v0.10.6 // indirect
github.com/go-webauthn/x v0.2.1 // indirect
github.com/goccy/go-json v0.10.5 // indirect
github.com/goccy/go-yaml v1.19.2 // indirect
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
github.com/golang/geo v0.0.0-20250319145452-ed1c8b99c3d7 // indirect
github.com/google/go-github/v39 v39.2.0 // indirect
github.com/google/go-querystring v1.2.0 // indirect
github.com/google/go-tpm v0.9.8 // indirect
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 // indirect
github.com/h2non/filetype v1.1.3 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/pgx/v5 v5.9.1 // indirect
github.com/jackc/pgx/v5 v5.8.0 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
@@ -128,65 +115,60 @@ require (
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/lestrrat-go/blackmagic v1.0.4 // indirect
github.com/lestrrat-go/dsig v1.2.1 // indirect
github.com/lestrrat-go/dsig v1.0.0 // indirect
github.com/lestrrat-go/dsig-secp256k1 v1.0.0 // indirect
github.com/lestrrat-go/httpcc v1.0.1 // indirect
github.com/lestrrat-go/option/v2 v2.0.0 // indirect
github.com/lib/pq v1.12.3 // indirect
github.com/mattn/go-sqlite3 v1.14.42 // indirect
github.com/lib/pq v1.11.2 // indirect
github.com/mattn/go-sqlite3 v1.14.34 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/nlnwa/whatwg-url v0.6.2 // indirect
github.com/pelletier/go-toml/v2 v2.3.0 // indirect
github.com/philhofer/fwd v1.2.0 // indirect
github.com/nlnwa/whatwg-url v0.5.0 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/prometheus/client_golang v1.23.2 // indirect
github.com/prometheus/client_model v0.6.2 // indirect
github.com/prometheus/common v0.67.5 // indirect
github.com/prometheus/otlptranslator v1.0.0 // indirect
github.com/prometheus/procfs v0.20.1 // indirect
github.com/prometheus/procfs v0.19.2 // indirect
github.com/quic-go/qpack v0.6.0 // indirect
github.com/quic-go/quic-go v0.59.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/robfig/cron/v3 v3.0.1 // indirect
github.com/segmentio/asm v1.2.1 // indirect
github.com/spf13/pflag v1.0.10 // indirect
github.com/tinylib/msgp v1.6.4 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.3.1 // indirect
github.com/valyala/fastjson v1.6.10 // indirect
github.com/x448/float16 v0.8.4 // indirect
go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/contrib/bridges/prometheus v0.68.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.19.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.19.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.43.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.43.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.43.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0 // indirect
go.opentelemetry.io/otel/exporters/prometheus v0.65.0 // indirect
go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.19.0 // indirect
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.43.0 // indirect
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.43.0 // indirect
go.opentelemetry.io/proto/otlp v1.10.0 // indirect
go.yaml.in/yaml/v2 v2.4.4 // indirect
golang.org/x/arch v0.26.0 // indirect
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f // indirect
golang.org/x/net v0.54.0 // indirect
golang.org/x/oauth2 v0.36.0 // indirect
golang.org/x/sys v0.44.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20260406210006-6f92a3bedf2d // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260406210006-6f92a3bedf2d // indirect
google.golang.org/grpc v1.80.0 // indirect
go.opentelemetry.io/contrib/bridges/prometheus v0.65.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.16.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.16.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.40.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.40.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.40.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.40.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.40.0 // indirect
go.opentelemetry.io/otel/exporters/prometheus v0.62.0 // indirect
go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.16.0 // indirect
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.40.0 // indirect
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.40.0 // indirect
go.opentelemetry.io/proto/otlp v1.9.0 // indirect
go.yaml.in/yaml/v2 v2.4.3 // indirect
golang.org/x/arch v0.24.0 // indirect
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa // indirect
golang.org/x/oauth2 v0.35.0 // indirect
golang.org/x/sys v0.41.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20260217215200-42d3e9bedb6d // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d // indirect
google.golang.org/grpc v1.79.3 // indirect
google.golang.org/protobuf v1.36.11 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
modernc.org/libc v1.71.0 // indirect
modernc.org/libc v1.68.0 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
modernc.org/sqlite v1.48.2 // indirect
modernc.org/sqlite v1.46.1 // indirect
)

View File

@@ -6,55 +6,57 @@ github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERo
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e h1:4dAU9FXIyQktpoUAgOJK3OTFc/xug0PCXYCqU0FgDKI=
github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4=
github.com/aws/aws-sdk-go-v2 v1.41.7 h1:DWpAJt66FmnnaRIOT/8ASTucrvuDPZASqhhLey6tLY8=
github.com/aws/aws-sdk-go-v2 v1.41.7/go.mod h1:4LAfZOPHNVNQEckOACQx60Y8pSRjIkNZQz1w92xpMJc=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.10 h1:gx1AwW1Iyk9Z9dD9F4akX5gnN3QZwUB20GGKH/I+Rho=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.10/go.mod h1:qqY157uZoqm5OXq/amuaBJyC9hgBCBQnsaWnPe905GY=
github.com/aws/aws-sdk-go-v2/config v1.32.17 h1:FpL4/758/diKwqbytU0prpuiu60fgXKUWCpDJtApclU=
github.com/aws/aws-sdk-go-v2/config v1.32.17/go.mod h1:OXqUMzgXytfoF9JaKkhrOYsyh72t9G+MJH8mMRaexOE=
github.com/aws/aws-sdk-go-v2/credentials v1.19.16 h1:r3RJBuU7X9ibt8RHbMjWE6y60QbKBiII6wSrXnapxSU=
github.com/aws/aws-sdk-go-v2/credentials v1.19.16/go.mod h1:6cx7zqDENJDbBIIWX6P8s0h6hqHC8Avbjh9Dseo27ug=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.23 h1:UuSfcORqNSz/ey3VPRS8TcVH2Ikf0/sC+Hdj400QI6U=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.23/go.mod h1:+G/OSGiOFnSOkYloKj/9M35s74LgVAdJBSD5lsFfqKg=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.23 h1:GpT/TrnBYuE5gan2cZbTtvP+JlHsutdmlV2YfEyNde0=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.23/go.mod h1:xYWD6BS9ywC5bS3sz9Xh04whO/hzK2plt2Zkyrp4JuA=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.23 h1:bpd8vxhlQi2r1hiueOw02f/duEPTMK59Q4QMAoTTtTo=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.23/go.mod h1:15DfR2nw+CRHIk0tqNyifu3G1YdAOy68RftkhMDDwYk=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.24 h1:OQqn11BtaYv1WLUowvcA30MpzIu8Ti4pcLPIIyoKZrA=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.24/go.mod h1:X5ZJyfwVrWA96GzPmUCWFQaEARPR7gCrpq2E92PJwAE=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.9 h1:FLudkZLt5ci0ozzgkVo8BJGwvqNaZbTWb3UcucAateA=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.9/go.mod h1:w7wZ/s9qK7c8g4al+UyoF1Sp/Z45UwMGcqIzLWVQHWk=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.15 h1:ieLCO1JxUWuxTZ1cRd0GAaeX7O6cIxnwk7tc1LsQhC4=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.15/go.mod h1:e3IzZvQ3kAWNykvE0Tr0RDZCMFInMvhku3qNpcIQXhM=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.23 h1:pbrxO/kuIwgEsOPLkaHu0O+m4fNgLU8B3vxQ+72jTPw=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.23/go.mod h1:/CMNUqoj46HpS3MNRDEDIwcgEnrtZlKRaHNaHxIFpNA=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.23 h1:03xatSQO4+AM1lTAbnRg5OK528EUg744nW7F73U8DKw=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.23/go.mod h1:M8l3mwgx5ToK7wot2sBBce/ojzgnPzZXUV445gTSyE8=
github.com/aws/aws-sdk-go-v2/service/s3 v1.101.0 h1:etqBTKY581iwLL/H/S2sVgk3C9lAsTJFeXWFDsDcWOU=
github.com/aws/aws-sdk-go-v2/service/s3 v1.101.0/go.mod h1:L2dcoOgS2VSgbPLvpak2NyUPsO1TBN7M45Z4H7DlRc4=
github.com/aws/aws-sdk-go-v2/service/signin v1.0.11 h1:TdJ+HdzOBhU8+iVAOGUTU63VXopcumCOF1paFulHWZc=
github.com/aws/aws-sdk-go-v2/service/signin v1.0.11/go.mod h1:R82ZRExE/nheo0N+T8zHPcLRTcH8MGsnR3BiVGX0TwI=
github.com/aws/aws-sdk-go-v2/service/sso v1.30.17 h1:7byT8HUWrgoRp6sXjxtZwgOKfhss5fW6SkLBtqzgRoE=
github.com/aws/aws-sdk-go-v2/service/sso v1.30.17/go.mod h1:xNWknVi4Ezm1vg1QsB/5EWpAJURq22uqd38U8qKvOJc=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.21 h1:+1Kl1zx6bWi4X7cKi3VYh29h8BvsCoHQEQ6ST9X8w7w=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.21/go.mod h1:4vIRDq+CJB2xFAXZ+YgGUTiEft7oAQlhIs71xcSeuVg=
github.com/aws/aws-sdk-go-v2/service/sts v1.42.1 h1:F/M5Y9I3nwr2IEpshZgh1GeHpOItExNM9L1euNuh/fk=
github.com/aws/aws-sdk-go-v2/service/sts v1.42.1/go.mod h1:mTNxImtovCOEEuD65mKW7DCsL+2gjEH+RPEAexAzAio=
github.com/aws/smithy-go v1.25.1 h1:J8ERsGSU7d+aCmdQur5Txg6bVoYelvQJgtZehD12GkI=
github.com/aws/smithy-go v1.25.1/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
github.com/aws/aws-sdk-go-v2 v1.41.1 h1:ABlyEARCDLN034NhxlRUSZr4l71mh+T5KAeGh6cerhU=
github.com/aws/aws-sdk-go-v2 v1.41.1/go.mod h1:MayyLB8y+buD9hZqkCW3kX1AKq07Y5pXxtgB+rRFhz0=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4 h1:489krEF9xIGkOaaX3CE/Be2uWjiXrkCH6gUX+bZA/BU=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4/go.mod h1:IOAPF6oT9KCsceNTvvYMNHy0+kMF8akOjeDvPENWxp4=
github.com/aws/aws-sdk-go-v2/config v1.32.9 h1:ktda/mtAydeObvJXlHzyGpK1xcsLaP16zfUPDGoW90A=
github.com/aws/aws-sdk-go-v2/config v1.32.9/go.mod h1:U+fCQ+9QKsLW786BCfEjYRj34VVTbPdsLP3CHSYXMOI=
github.com/aws/aws-sdk-go-v2/credentials v1.19.9 h1:sWvTKsyrMlJGEuj/WgrwilpoJ6Xa1+KhIpGdzw7mMU8=
github.com/aws/aws-sdk-go-v2/credentials v1.19.9/go.mod h1:+J44MBhmfVY/lETFiKI+klz0Vym2aCmIjqgClMmW82w=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.17 h1:I0GyV8wiYrP8XpA70g1HBcQO1JlQxCMTW9npl5UbDHY=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.17/go.mod h1:tyw7BOl5bBe/oqvoIeECFJjMdzXoa/dfVz3QQ5lgHGA=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.17 h1:xOLELNKGp2vsiteLsvLPwxC+mYmO6OZ8PYgiuPJzF8U=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.17/go.mod h1:5M5CI3D12dNOtH3/mk6minaRwI2/37ifCURZISxA/IQ=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.17 h1:WWLqlh79iO48yLkj1v3ISRNiv+3KdQoZ6JWyfcsyQik=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.17/go.mod h1:EhG22vHRrvF8oXSTYStZhJc1aUgKtnJe+aOiFEV90cM=
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 h1:WKuaxf++XKWlHWu9ECbMlha8WOEGm0OUEZqm4K/Gcfk=
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4/go.mod h1:ZWy7j6v1vWGmPReu0iSGvRiise4YI5SkR3OHKTZ6Wuc=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.17 h1:JqcdRG//czea7Ppjb+g/n4o8i/R50aTBHkA7vu0lK+k=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.17/go.mod h1:CO+WeGmIdj/MlPel2KwID9Gt7CNq4M65HUfBW97liM0=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4 h1:0ryTNEdJbzUCEWkVXEXoqlXV72J5keC1GvILMOuD00E=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4/go.mod h1:HQ4qwNZh32C3CBeO6iJLQlgtMzqeG17ziAA/3KDJFow=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.8 h1:Z5EiPIzXKewUQK0QTMkutjiaPVeVYXX7KIqhXu/0fXs=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.8/go.mod h1:FsTpJtvC4U1fyDXk7c71XoDv3HlRm8V3NiYLeYLh5YE=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.17 h1:RuNSMoozM8oXlgLG/n6WLaFGoea7/CddrCfIiSA+xdY=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.17/go.mod h1:F2xxQ9TZz5gDWsclCtPQscGpP0VUOc8RqgFM3vDENmU=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.17 h1:bGeHBsGZx0Dvu/eJC0Lh9adJa3M1xREcndxLNZlve2U=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.17/go.mod h1:dcW24lbU0CzHusTE8LLHhRLI42ejmINN8Lcr22bwh/g=
github.com/aws/aws-sdk-go-v2/service/s3 v1.96.0 h1:oeu8VPlOre74lBA/PMhxa5vewaMIMmILM+RraSyB8KA=
github.com/aws/aws-sdk-go-v2/service/s3 v1.96.0/go.mod h1:5jggDlZ2CLQhwJBiZJb4vfk4f0GxWdEDruWKEJ1xOdo=
github.com/aws/aws-sdk-go-v2/service/signin v1.0.5 h1:VrhDvQib/i0lxvr3zqlUwLwJP4fpmpyD9wYG1vfSu+Y=
github.com/aws/aws-sdk-go-v2/service/signin v1.0.5/go.mod h1:k029+U8SY30/3/ras4G/Fnv/b88N4mAfliNn08Dem4M=
github.com/aws/aws-sdk-go-v2/service/sso v1.30.10 h1:+VTRawC4iVY58pS/lzpo0lnoa/SYNGF4/B/3/U5ro8Y=
github.com/aws/aws-sdk-go-v2/service/sso v1.30.10/go.mod h1:yifAsgBxgJWn3ggx70A3urX2AN49Y5sJTD1UQFlfqBw=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.14 h1:0jbJeuEHlwKJ9PfXtpSFc4MF+WIWORdhN1n30ITZGFM=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.14/go.mod h1:sTGThjphYE4Ohw8vJiRStAcu3rbjtXRsdNB0TvZ5wwo=
github.com/aws/aws-sdk-go-v2/service/sts v1.41.6 h1:5fFjR/ToSOzB2OQ/XqWpZBmNvmP/pJ1jOWYlFDJTjRQ=
github.com/aws/aws-sdk-go-v2/service/sts v1.41.6/go.mod h1:qgFDZQSD/Kys7nJnVqYlWKnh0SSdMjAi0uSwON4wgYQ=
github.com/aws/smithy-go v1.24.1 h1:VbyeNfmYkWoxMVpGUAbQumkODcYmfMRfZ8yQiH30SK0=
github.com/aws/smithy-go v1.24.1/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/bits-and-blooms/bitset v1.20.0/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8=
github.com/bits-and-blooms/bitset v1.24.4 h1:95H15Og1clikBrKr/DuzMXkQzECs1M6hhoGXLwLQOZE=
github.com/bits-and-blooms/bitset v1.24.4/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8=
github.com/bytedance/gopkg v0.1.4 h1:oZnQwnX82KAIWb7033bEwtxvTqXcYMxDBaQxo5JJHWM=
github.com/bytedance/gopkg v0.1.4/go.mod h1:v1zWfPm21Fb+OsyXN2VAHdL6TBb2L88anLQgdyje6R4=
github.com/bits-and-blooms/bitset v1.13.0/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8=
github.com/bits-and-blooms/bitset v1.14.3 h1:Gd2c8lSNf9pKXom5JtD7AaKO8o7fGQ2LtFj1436qilA=
github.com/bits-and-blooms/bitset v1.14.3/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8=
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE=
github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k=
github.com/bytedance/sonic/loader v0.5.1 h1:Ygpfa9zwRCCKSlrp5bBP/b/Xzc3VxsAW+5NIYXrOOpI=
github.com/bytedance/sonic/loader v0.5.1/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
github.com/caarlos0/env/v11 v11.4.1 h1:fYwH0sWEsBSMPG7t4e/PEfTFzrWrpjyygXyUnWiSwEw=
github.com/caarlos0/env/v11 v11.4.1/go.mod h1:qupehSf/Y0TUTsxKywqRt/vJjN5nz6vauiYEUUr8P4U=
github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE=
github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
github.com/caarlos0/env/v11 v11.3.1 h1:cArPWC15hWmEt+gWk7YBi7lEXTXCvpaSdCiZE2X5mCA=
github.com/caarlos0/env/v11 v11.3.1/go.mod h1:qupehSf/Y0TUTsxKywqRt/vJjN5nz6vauiYEUUr8P4U=
github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM=
github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
@@ -65,15 +67,13 @@ github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG
github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M=
github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE=
github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk=
github.com/coreos/go-systemd v0.0.0-20191104093116-d3cd4ed1dbcf h1:iW4rZ826su+pqaw19uhpSCzhj44qo35pNgKFGqzDKkU=
github.com/coreos/go-systemd v0.0.0-20191104093116-d3cd4ed1dbcf/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1 h1:5RVFMOWjMyRy8cARdy79nAmgYw3hK/4HUq48LQ6Wwqo=
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40=
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 h1:NMZiJj8QnKe1LgsbDayM4UoHwbvwDRwnI3hwNaAHRnc=
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40=
github.com/dhui/dktest v0.4.6 h1:+DPKyScKSEp3VLtbMDHcUq6V5Lm5zfZZVb0Sk7Ahom4=
github.com/dhui/dktest v0.4.6/go.mod h1:JHTSYDtKkvFNFHJKqCzVzqXecyv+tKt8EzceOmQOgbU=
github.com/disintegration/gift v1.1.2/go.mod h1:Jh2i7f7Q2BM7Ezno3PhfezbR1xpUg9dUg3/RlKGr4HI=
@@ -91,32 +91,6 @@ github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj
github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc=
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
github.com/dsoprea/go-exif v0.0.0-20230826092837-6579e82b732d h1:ygcRCGNKuEiA98k7X35hknEN8RIRUF1jrz7k1rZCvsk=
github.com/dsoprea/go-exif v0.0.0-20230826092837-6579e82b732d/go.mod h1:lOaOt7+UEppOgyvRy749v3do836U/hw0YVJNjoyPaEs=
github.com/dsoprea/go-exif/v2 v2.0.0-20200321225314-640175a69fe4/go.mod h1:Lm2lMM2zx8p4a34ZemkaUV95AnMl4ZvLbCUbwOvLC2E=
github.com/dsoprea/go-exif/v2 v2.0.0-20200604193436-ca8584a0e1c4/go.mod h1:9EXlPeHfblFFnwu5UOqmP2eoZfJyAZ2Ri/Vki33ajO0=
github.com/dsoprea/go-exif/v2 v2.0.0-20230826092837-6579e82b732d h1:yeH8wrJa3+8uKKDAdURHUK1ds2UvKhMqX2MiOdVeKPs=
github.com/dsoprea/go-exif/v2 v2.0.0-20230826092837-6579e82b732d/go.mod h1:oKrjk2kb3rAR5NbtSTLUMvMSbc+k8ZosI3MaVH47noc=
github.com/dsoprea/go-exif/v3 v3.0.0-20200717053412-08f1b6708903/go.mod h1:0nsO1ce0mh5czxGeLo4+OCZ/C6Eo6ZlMWsz7rH/Gxv8=
github.com/dsoprea/go-exif/v3 v3.0.0-20210512043655-120bcdb2a55e/go.mod h1:cg5SNYKHMmzxsr9X6ZeLh/nfBRHHp5PngtEPcujONtk=
github.com/dsoprea/go-iptc v0.0.0-20200609062250-162ae6b44feb/go.mod h1:kYIdx9N9NaOyD7U6D+YtExN7QhRm+5kq7//yOsRXQtM=
github.com/dsoprea/go-iptc v0.0.0-20200610044640-bc9ca208b413 h1:YDRiMEm32T60Kpm35YzOK9ZHgjsS1Qrid+XskNcsdp8=
github.com/dsoprea/go-iptc v0.0.0-20200610044640-bc9ca208b413/go.mod h1:kYIdx9N9NaOyD7U6D+YtExN7QhRm+5kq7//yOsRXQtM=
github.com/dsoprea/go-jpeg-image-structure v0.0.0-20221012074422-4f3f7e934102 h1:P1dsxzctGkmG6Zf7gH2xrZhNXWP5/FuLDI7xbCGsWTo=
github.com/dsoprea/go-jpeg-image-structure v0.0.0-20221012074422-4f3f7e934102/go.mod h1:6+tQXZ+I62x13UZ+hemLVoZIuq/usVzvau7bqwUo9P0=
github.com/dsoprea/go-logging v0.0.0-20190624164917-c4f10aab7696/go.mod h1:Nm/x2ZUNRW6Fe5C3LxdY1PyZY5wmDv/s5dkPJ/VB3iA=
github.com/dsoprea/go-logging v0.0.0-20200517223158-a10564966e9d/go.mod h1:7I+3Pe2o/YSU88W0hWlm9S22W7XI1JFNJ86U0zPKMf8=
github.com/dsoprea/go-logging v0.0.0-20200710184922-b02d349568dd h1:l+vLbuxptsC6VQyQsfD7NnEC8BZuFpz45PgY+pH8YTg=
github.com/dsoprea/go-logging v0.0.0-20200710184922-b02d349568dd/go.mod h1:7I+3Pe2o/YSU88W0hWlm9S22W7XI1JFNJ86U0zPKMf8=
github.com/dsoprea/go-photoshop-info-format v0.0.0-20200609050348-3db9b63b202c/go.mod h1:pqKB+ijp27cEcrHxhXVgUUMlSDRuGJJp1E+20Lj5H0E=
github.com/dsoprea/go-photoshop-info-format v0.0.0-20200610045659-121dd752914d h1:dg6UMHa50VI01WuPWXPbNJpO8QSyvIF5T5n2IZiqX3A=
github.com/dsoprea/go-photoshop-info-format v0.0.0-20200610045659-121dd752914d/go.mod h1:pqKB+ijp27cEcrHxhXVgUUMlSDRuGJJp1E+20Lj5H0E=
github.com/dsoprea/go-png-image-structure v0.0.0-20210512210324-29b889a6093d h1:8+qI8ant/vZkNSsbwSjIR6XJfWcDVTg/qx/3pRUUZNA=
github.com/dsoprea/go-png-image-structure v0.0.0-20210512210324-29b889a6093d/go.mod h1:yTR3tKgyk20phAFg6IE9ulMA5NjEDD2wyx+okRFLVtw=
github.com/dsoprea/go-utility v0.0.0-20200711062821-fab8125e9bdf/go.mod h1:95+K3z2L0mqsVYd6yveIv1lmtT3tcQQ3dVakPySffW8=
github.com/dsoprea/go-utility v0.0.0-20221003172846-a3e1774ef349 h1:/py11NlxDaOxkT9OKN+gXgT+QOH5xj1ZRoyusfRIlo4=
github.com/dsoprea/go-utility v0.0.0-20221003172846-a3e1774ef349/go.mod h1:KVK+/Hul09ujXAGq+42UBgCTnXkiJZRnLYdURGjQUwo=
github.com/dsoprea/go-utility/v2 v2.0.0-20200717064901-2fccff4aa15e/go.mod h1:uAzdkPTub5Y9yQwXe8W4m2XuP0tK4a9Q/dantD0+uaU=
github.com/dunglas/go-urlpattern v0.0.0-20241020164140-716dfa1c80b1 h1:RW22Y3QjGrb97NUA8yupdFcaqg//+hMI2fZrETBvQ4s=
github.com/dunglas/go-urlpattern v0.0.0-20241020164140-716dfa1c80b1/go.mod h1:mnVcdqOeYg0HvT6veRo7wINa1mJ+lC/R4ig2lWcapSI=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
@@ -127,33 +101,26 @@ github.com/emersion/go-smtp v0.24.0 h1:g6AfoF140mvW0vLNPD/LuCBLEAdlxOjIXqbIkJIS6
github.com/emersion/go-smtp v0.24.0/go.mod h1:ZtRRkbTyp2XTHCA+BmyTFTrj8xY4I+b4McvHxCU2gsQ=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho=
github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo=
github.com/fxamacker/cbor/v2 v2.9.2 h1:X4Ksno9+x3cz0TZv69ec1hxP/+tymuR8PXQJyDwfh78=
github.com/fxamacker/cbor/v2 v2.9.2/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM=
github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM=
github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
github.com/gin-contrib/slog v1.2.1 h1:tQbsmllW/PNgtvHRdVlI38jLfpN4IFLS7Pb4HgTeiYw=
github.com/gin-contrib/slog v1.2.1/go.mod h1:f/Ke0A3h4DUh0cQnjR2b/l+i0EmVJ+6VY6GIw3RKtxA=
github.com/gin-contrib/sse v1.1.1 h1:uGYpNwTacv5R68bSGMapo62iLTRa9l5zxGCps4hK6ko=
github.com/gin-contrib/sse v1.1.1/go.mod h1:QXzuVkA0YO7o/gun03UI1Q+FTI8ZV/n5t03kIQAI89s=
github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8=
github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc=
github.com/gin-contrib/slog v1.2.0 h1:vAxZfr7knD1ZYK5+pMJLP52sZXIkJXkcRPa/0dx9hSk=
github.com/gin-contrib/slog v1.2.0/go.mod h1:vYK6YltmpsEFkO0zfRMLTKHrWS3DwUSn0TMpT+kMagI=
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
github.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk=
github.com/gin-gonic/gin v1.11.0/go.mod h1:+iq/FyxlGzII0KHiBGjuNn4UNENUlKbGlNmc+W50Dls=
github.com/glebarez/go-sqlite v1.22.0 h1:uAcMJhaA6r3LHMTFgP0SifzgXg46yJkgxqyuyec+ruQ=
github.com/glebarez/go-sqlite v1.22.0/go.mod h1:PlBIdHe0+aUEFn+r2/uthrWq4FxbzugL0L8Li6yQJbc=
github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw=
github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ=
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 h1:BP4M0CvQ4S3TGls2FvczZtj5Re/2ZzkV9VwqPHH/3Bo=
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0=
github.com/go-co-op/gocron/v2 v2.21.1 h1:QYOK6iOQVCut+jDcs4zRdWRTBHRxRCEeeFi1TnAmgbU=
github.com/go-co-op/gocron/v2 v2.21.1/go.mod h1:5lEiCKk1oVJV39Zg7/YG10OnaVrDAV5GGR6O0663k6U=
github.com/go-errors/errors v1.0.1/go.mod h1:f4zRHt4oKfwPJE5k8C9vpYG+aDHdBFUsgrm6/TyX73Q=
github.com/go-errors/errors v1.0.2/go.mod h1:psDX2osz5VnTOnFWbDeWwS7yejl+uV3FEWEp4lssFEs=
github.com/go-errors/errors v1.1.1/go.mod h1:psDX2osz5VnTOnFWbDeWwS7yejl+uV3FEWEp4lssFEs=
github.com/go-errors/errors v1.5.1 h1:ZwEMSLRCapFLflTpT7NKaAc7ukJ8ZPEjzlxt8rPN8bk=
github.com/go-errors/errors v1.5.1/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og=
github.com/go-ldap/ldap/v3 v3.4.13 h1:+x1nG9h+MZN7h/lUi5Q3UZ0fJ1GyDQYbPvbuH38baDQ=
github.com/go-ldap/ldap/v3 v3.4.13/go.mod h1:LxsGZV6vbaK0sIvYfsv47rfh4ca0JXokCoKjZxsszv0=
github.com/go-co-op/gocron/v2 v2.19.1 h1:B4iLeA0NB/2iO3EKQ7NfKn5KsQgZfjb2fkvoZJU3yBI=
github.com/go-co-op/gocron/v2 v2.19.1/go.mod h1:5lEiCKk1oVJV39Zg7/YG10OnaVrDAV5GGR6O0663k6U=
github.com/go-ldap/ldap/v3 v3.4.12 h1:1b81mv7MagXZ7+1r7cLTWmyuTqVqdwbtJSjC0DAp9s4=
github.com/go-ldap/ldap/v3 v3.4.12/go.mod h1:+SPAGcTtOfmGsCb3h1RFiq4xpp4N636G75OEace8lNo=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
@@ -165,19 +132,16 @@ github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/o
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.30.2 h1:JiFIMtSSHb2/XBUbWM4i/MpeQm9ZK2xqPNk8vgvu5JQ=
github.com/go-playground/validator/v10 v10.30.2/go.mod h1:mAf2pIOVXjTEBrwUMGKkCWKKPs9NheYGabeB04txQSc=
github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w=
github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM=
github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro=
github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/go-webauthn/webauthn v0.17.3 h1:XHZ0TXV7k8vChcE4TFgPitOPJ5cb7h1dpAeFDS0cjCo=
github.com/go-webauthn/webauthn v0.17.3/go.mod h1:PlkMgmuL9McCT7dvgBj/Sz/fgs3V6ZID6/KnFkEcPvQ=
github.com/go-webauthn/x v0.2.5 h1:wEVTfU04XFyPTXGQbKOQwMKhcDWfDAkdsDDBsDaG9yY=
github.com/go-webauthn/x v0.2.5/go.mod h1:Qna/yJz9rV6lRzwl5BfYbmTJpVGxcBIds3gJtw2tlGg=
github.com/go-xmlfmt/xmlfmt v0.0.0-20191208150333-d5b6f63a941b/go.mod h1:aUCEOzzezBEjDBbFBoSiya/gduyIiWYRP6CnSFIV8AM=
github.com/go-xmlfmt/xmlfmt v1.1.3 h1:t8Ey3Uy7jDSEisW2K3somuMKIpzktkWptA0iFCnRUWY=
github.com/go-xmlfmt/xmlfmt v1.1.3/go.mod h1:aUCEOzzezBEjDBbFBoSiya/gduyIiWYRP6CnSFIV8AM=
github.com/goccy/go-json v0.10.6 h1:p8HrPJzOakx/mn/bQtjgNjdTcN+/S6FcG2CTtQOrHVU=
github.com/goccy/go-json v0.10.6/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/go-webauthn/webauthn v0.15.0 h1:LR1vPv62E0/6+sTenX35QrCmpMCzLeVAcnXeH4MrbJY=
github.com/go-webauthn/webauthn v0.15.0/go.mod h1:hcAOhVChPRG7oqG7Xj6XKN1mb+8eXTGP/B7zBLzkX5A=
github.com/go-webauthn/x v0.2.1 h1:/oB8i0FhSANuoN+YJF5XHMtppa7zGEYaQrrf6ytotjc=
github.com/go-webauthn/x v0.2.1/go.mod h1:Wm0X0zXkzznit4gHj4m82GiBZRMEm+TDUIoJWIQLsE4=
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
@@ -186,10 +150,6 @@ github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63Y
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/golang-migrate/migrate/v4 v4.19.1 h1:OCyb44lFuQfYXYLx1SCxPZQGU7mcaZ7gH9yH4jSFbBA=
github.com/golang-migrate/migrate/v4 v4.19.1/go.mod h1:CTcgfjxhaUtsLipnLoQRWCrjYXycRz/g5+RWDuYgPrE=
github.com/golang/geo v0.0.0-20190916061304-5b978397cfec/go.mod h1:QZ0nwyI2jOfgRAoBvP+ab5aRr7c9x7lhGEJrKvBwjWI=
github.com/golang/geo v0.0.0-20200319012246-673a6f80352d/go.mod h1:QZ0nwyI2jOfgRAoBvP+ab5aRr7c9x7lhGEJrKvBwjWI=
github.com/golang/geo v0.0.0-20250319145452-ed1c8b99c3d7 h1:kG/6mhO8OwbQrA/0XEPwKJs3D3jG0m1rNH/ZRKDA/pQ=
github.com/golang/geo v0.0.0-20250319145452-ed1c8b99c3d7/go.mod h1:J+F9/3Ofc8ysEOY2/cNjxTMl2eB1gvPIywEHUplPgDA=
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
@@ -206,8 +166,6 @@ github.com/google/go-querystring v1.2.0 h1:yhqkPbu2/OH+V9BfpCVPZkNmUXhb2gBxJArfh
github.com/google/go-querystring v1.2.0/go.mod h1:8IFJqpSRITyJ8QhQ13bmbeMBDfmeEJZD5A0egEOmkqU=
github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo=
github.com/google/go-tpm v0.9.8/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY=
github.com/google/go-tpm-tools v0.3.13-0.20230620182252-4639ecce2aba h1:qJEJcuLzH5KDR0gKc0zcktin6KSAwL7+jWKBYceddTc=
github.com/google/go-tpm-tools v0.3.13-0.20230620182252-4639ecce2aba/go.mod h1:EFYHy8/1y2KfgTAsx7Luu7NGhoxtuVHnNo8jE7FikKc=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
@@ -215,8 +173,6 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 h1:HWRh5R2+9EifMyIHV7ZV+MIZqgz+PMpZ14Jynv3O2Zs=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0/go.mod h1:JfhWUomR1baixubs02l85lZYYOm7LV6om4ceouMv45c=
github.com/h2non/filetype v1.1.3 h1:FKkx9QbD7HR/zjK1Ia5XiBsq9zdLi5Kf3zGyFTAFkGg=
github.com/h2non/filetype v1.1.3/go.mod h1:319b3zT68BvV+WRj7cwy856M2ehB3HqNOt6sy1HndBY=
github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8=
github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
@@ -227,8 +183,8 @@ github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsI
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.9.1 h1:uwrxJXBnx76nyISkhr33kQLlUqjv7et7b9FjCen/tdc=
github.com/jackc/pgx/v5 v5.9.1/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
github.com/jackc/pgx/v5 v5.8.0 h1:TYPDoleBBme0xGSAX3/+NujXXtpZn9HBONkQC7IEZSo=
github.com/jackc/pgx/v5 v5.8.0/go.mod h1:QVeDInX2m9VyzvNeiCJVjCkNFqzsNb43204HshNSZKw=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/jcmturner/aescts/v2 v2.0.0 h1:9YKLH6ey7H4eDBXW8khjYslgyqG2xZikXP0EQFKrle8=
@@ -243,7 +199,6 @@ github.com/jcmturner/gokrb5/v8 v8.4.4 h1:x1Sv4HaTpepFkXbt2IkL29DXRf8sOfZXo8eRKh6
github.com/jcmturner/gokrb5/v8 v8.4.4/go.mod h1:1btQEpgT6k+unzCwX1KdWMEwPPkkgBtP+F6aCACiMrs=
github.com/jcmturner/rpc/v2 v2.0.3 h1:7FXXj8Ti1IaVFpSAziCZWNzbNuZmnvw/i6CqLNdWfZY=
github.com/jcmturner/rpc/v2 v2.0.3/go.mod h1:VUJYCIDm3PVOEHw8sgt091/20OJjskO/YJki3ELg/Hc=
github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
github.com/jinzhu/copier v0.4.0 h1:w3ciUoD19shMCRargcpm0cm91ytaBhDvuRpz1ODO/U8=
github.com/jinzhu/copier v0.4.0/go.mod h1:DfbEm0FYsaqBcKcFuvmOZb218JkPGtvSHsKg8S8hyyg=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
@@ -270,26 +225,26 @@ github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/lestrrat-go/blackmagic v1.0.4 h1:IwQibdnf8l2KoO+qC3uT4OaTWsW7tuRQXy9TRN9QanA=
github.com/lestrrat-go/blackmagic v1.0.4/go.mod h1:6AWFyKNNj0zEXQYfTMPfZrAXUWUfTIZ5ECEUEJaijtw=
github.com/lestrrat-go/dsig v1.2.1 h1:MwxzZhE4+4fguHi+uDALKVlC3Cn+O1QU1Q/F8D7hVIc=
github.com/lestrrat-go/dsig v1.2.1/go.mod h1:RD2eOaidyPvpc7IJQoO3Qq52RWdy8ZcJs8lrOnoa1Kc=
github.com/lestrrat-go/dsig v1.0.0 h1:OE09s2r9Z81kxzJYRn07TFM9XA4akrUdoMwr0L8xj38=
github.com/lestrrat-go/dsig v1.0.0/go.mod h1:dEgoOYYEJvW6XGbLasr8TFcAxoWrKlbQvmJgCR0qkDo=
github.com/lestrrat-go/dsig-secp256k1 v1.0.0 h1:JpDe4Aybfl0soBvoVwjqDbp+9S1Y2OM7gcrVVMFPOzY=
github.com/lestrrat-go/dsig-secp256k1 v1.0.0/go.mod h1:CxUgAhssb8FToqbL8NjSPoGQlnO4w3LG1P0qPWQm/NU=
github.com/lestrrat-go/httpcc v1.0.1 h1:ydWCStUeJLkpYyjLDHihupbn2tYmZ7m22BGkcvZZrIE=
github.com/lestrrat-go/httpcc v1.0.1/go.mod h1:qiltp3Mt56+55GPVCbTdM9MlqhvzyuL6W/NMDA8vA5E=
github.com/lestrrat-go/httprc/v3 v3.0.5 h1:S+Mb4L2I+bM6JGTibLmxExhyTOqnXjqx+zi9MoXw/TM=
github.com/lestrrat-go/httprc/v3 v3.0.5/go.mod h1:mSMtkZW92Z98M5YoNNztbRGxbXHql7tSitCvaxvo9l0=
github.com/lestrrat-go/jwx/v3 v3.1.1 h1:yd9AdPmZ4INnQ7k42IrzXYpnEG803+SrQ6hdMvzHJzw=
github.com/lestrrat-go/jwx/v3 v3.1.1/go.mod h1:uw/MN2M/Xiu4FhwcIwH11Zsh9JWx9SWzgALl7/uIEkU=
github.com/lestrrat-go/httprc/v3 v3.0.4 h1:pXyH2ppK8GYYggygxJ3TvxpCZnbEUWc9qSwRTTApaLA=
github.com/lestrrat-go/httprc/v3 v3.0.4/go.mod h1:mSMtkZW92Z98M5YoNNztbRGxbXHql7tSitCvaxvo9l0=
github.com/lestrrat-go/jwx/v3 v3.0.13 h1:AdHKiPIYeCSnOJtvdpipPg/0SuFh9rdkN+HF3O0VdSk=
github.com/lestrrat-go/jwx/v3 v3.0.13/go.mod h1:2m0PV1A9tM4b/jVLMx8rh6rBl7F6WGb3EG2hufN9OQU=
github.com/lestrrat-go/option/v2 v2.0.0 h1:XxrcaJESE1fokHy3FpaQ/cXW8ZsIdWcdFzzLOcID3Ss=
github.com/lestrrat-go/option/v2 v2.0.0/go.mod h1:oSySsmzMoR0iRzCDCaUfsCzxQHUEuhOViQObyy7S6Vg=
github.com/lib/pq v1.12.3 h1:tTWxr2YLKwIvK90ZXEw8GP7UFHtcbTtty8zsI+YjrfQ=
github.com/lib/pq v1.12.3/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA=
github.com/lib/pq v1.11.2 h1:x6gxUeu39V0BHZiugWe8LXZYZ+Utk7hSJGThs8sdzfs=
github.com/lib/pq v1.11.2/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA=
github.com/lmittmann/tint v1.1.3 h1:Hv4EaHWXQr+GTFnOU4VKf8UvAtZgn0VuKT+G0wFlO3I=
github.com/lmittmann/tint v1.1.3/go.mod h1:HIS3gSy7qNwGCj+5oRjAutErFBl4BzdQP6cJZ0NfMwE=
github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4=
github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
github.com/mattn/go-sqlite3 v1.14.42 h1:MigqEP4ZmHw3aIdIT7T+9TLa90Z6smwcthx+Azv4Cgo=
github.com/mattn/go-sqlite3 v1.14.42/go.mod h1:pjEuOr8IwzLJP2MfGeTb0A35jauH+C2kbHKBr7yXKVQ=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-sqlite3 v1.14.34 h1:3NtcvcUnFBPsuRcno8pUtupspG/GM+9nZ88zgJcp6Zk=
github.com/mattn/go-sqlite3 v1.14.34/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/mileusna/useragent v1.3.5 h1:SJM5NzBmh/hO+4LGeATKpaEX9+b4vcGg2qXGLiNGDws=
github.com/mileusna/useragent v1.3.5/go.mod h1:3d8TOmwL/5I8pJjyVDteHtgDGcefrFUX4ccGOMKNYYc=
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
@@ -307,20 +262,18 @@ github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/nlnwa/whatwg-url v0.6.2 h1:jU61lU2ig4LANydbEJmA2nPrtCGiKdtgT0rmMd2VZ/Q=
github.com/nlnwa/whatwg-url v0.6.2/go.mod h1:x0FPXJzzOEieQtsBT/AKvbiBbQ46YlL6Xa7m02M1ECk=
github.com/nlnwa/whatwg-url v0.5.0 h1:l71cqfqG44+VCQZQX3wD4bwheFWicPxuwaCimLEfpDo=
github.com/nlnwa/whatwg-url v0.5.0/go.mod h1:X/ejnFFVbaOWdSul+cnlsSHviCzGZJdvPkgc9zD8IY8=
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug=
github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM=
github.com/orandin/slog-gorm v1.4.0 h1:FgA8hJufF9/jeNSYoEXmHPPBwET2gwlF3B85JdpsTUU=
github.com/orandin/slog-gorm v1.4.0/go.mod h1:MoZ51+b7xE9lwGNPYEhxcUtRNrYzjdcKvA8QXQQGEPA=
github.com/oschwald/maxminddb-golang/v2 v2.2.0 h1:/2khmIiNvFxgfwGxitper3XBJBs5qTCPQ/H1iR9MgBw=
github.com/oschwald/maxminddb-golang/v2 v2.2.0/go.mod h1:n/ctYVTFYQypkn5uO1CZnTmj8jdQKIVh/LX7gSaIl0w=
github.com/pelletier/go-toml/v2 v2.3.0 h1:k59bC/lIZREW0/iVaQR8nDHxVq8OVlIzYCOJf421CaM=
github.com/pelletier/go-toml/v2 v2.3.0/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM=
github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM=
github.com/oschwald/maxminddb-golang/v2 v2.1.1 h1:lA8FH0oOrM4u7mLvowq8IT6a3Q/qEnqRzLQn9eH5ojc=
github.com/oschwald/maxminddb-golang/v2 v2.1.1/go.mod h1:PLdx6PR+siSIoXqqy7C7r3SB3KZnhxWr1Dp6g0Hacl8=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
@@ -334,8 +287,8 @@ github.com/prometheus/common v0.67.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTU
github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw=
github.com/prometheus/otlptranslator v1.0.0 h1:s0LJW/iN9dkIH+EnhiD3BlkkP5QVIUVEoIwkU+A6qos=
github.com/prometheus/otlptranslator v1.0.0/go.mod h1:vRYWnXvI6aWGpsdY/mOT/cbeVRBlPWtBNDb7kGR3uKM=
github.com/prometheus/procfs v0.20.1 h1:XwbrGOIplXW/AU3YhIhLODXMJYyC1isLFfYCsTEycfc=
github.com/prometheus/procfs v0.20.1/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo=
github.com/prometheus/procfs v0.19.2 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4Vws=
github.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw=
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw=
@@ -366,8 +319,6 @@ github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXl
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/tinylib/msgp v1.6.4 h1:mOwYbyYDLPj35mkA2BjjYejgJk9BuHxDdvRnb6v2ZcQ=
github.com/tinylib/msgp v1.6.4/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
@@ -377,188 +328,155 @@ github.com/valyala/fastjson v1.6.10/go.mod h1:e6FubmQouUNP73jtMLmcbxS6ydWIpOfhz3
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/zitadel/exifremove v0.1.0 h1:qD50ezWsfeeqfcvs79QyyjVfK+snN12v0U0deaU8aKg=
github.com/zitadel/exifremove v0.1.0/go.mod h1:rzKJ3woL/Rz2KthVBiSBKIBptNTvgmk9PLaeUKTm+ek=
go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE=
go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/contrib/bridges/otelslog v0.18.0 h1:hhPGP3zvvy1xWT9RTy970wlniSxFttBIsAK1gvMguJM=
go.opentelemetry.io/contrib/bridges/otelslog v0.18.0/go.mod h1:twJF7inoMza6kxMcF8JOdL3mPmtOZu7GEr34CUNE6Dg=
go.opentelemetry.io/contrib/bridges/prometheus v0.68.0 h1:w3zlHYETbDwXyWHZlyyR58ZC39XGi8rAhkBgUgJ9d5w=
go.opentelemetry.io/contrib/bridges/prometheus v0.68.0/go.mod h1:GR/mClR2nn7vE8RLwxKjoBNg+QtgdDhRzxVa93koy5o=
go.opentelemetry.io/contrib/exporters/autoexport v0.68.0 h1:0D3GFvELGIwQGfC6agLsbrEYSGWZTRTxIXxcQUqrOuk=
go.opentelemetry.io/contrib/exporters/autoexport v0.68.0/go.mod h1:DM2NV7Zb8CcGeVPt6glouY0FAiwZQ/iqgcWExhgWeN8=
go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin v0.68.0 h1:5FXSL2s6afUC1bzNzl1iedZZ8yqR7GOhbCoEXtyeK6Q=
go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin v0.68.0/go.mod h1:MdHW7tLtkeGJnR4TyOrnd5D0zUGZQB1l84uHCe8hRpE=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0 h1:CqXxU8VOmDefoh0+ztfGaymYbhdB/tT3zs79QaZTNGY=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0/go.mod h1:BuhAPThV8PBHBvg8ZzZ/Ok3idOdhWIodywz2xEcRbJo=
go.opentelemetry.io/contrib/propagators/b3 v1.43.0 h1:CETqV3QLLPTy5yNrqyMr41VnAOOD4lsRved7n4QG00A=
go.opentelemetry.io/contrib/propagators/b3 v1.43.0/go.mod h1:Q4mCiCdziYzpNR0g+6UqVotAlCDZdzz6L8jwY4knOrw=
go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I=
go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0=
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.19.0 h1:Dn8rkudDzY6KV9dr/D/bTUuWgqDf9xe0rr4G2elrn0Y=
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.19.0/go.mod h1:gMk9F0xDgyN9M/3Ed5Y1wKcx/9mlU91NXY2SNq7RQuU=
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.19.0 h1:HIBTQ3VO5aupLKjC90JgMqpezVXwFuq6Ryjn0/izoag=
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.19.0/go.mod h1:ji9vId85hMxqfvICA0Jt8JqEdrXaAkcpkI9HPXya0ro=
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.43.0 h1:8UQVDcZxOJLtX6gxtDt3vY2WTgvZqMQRzjsqiIHQdkc=
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.43.0/go.mod h1:2lmweYCiHYpEjQ/lSJBYhj9jP1zvCvQW4BqL9dnT7FQ=
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.43.0 h1:w1K+pCJoPpQifuVpsKamUdn9U0zM3xUziVOqsGksUrY=
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.43.0/go.mod h1:HBy4BjzgVE8139ieRI75oXm3EcDN+6GhD88JT1Kjvxg=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0 h1:88Y4s2C8oTui1LGM6bTWkw0ICGcOLCAI5l6zsD1j20k=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0/go.mod h1:Vl1/iaggsuRlrHf/hfPJPvVag77kKyvrLeD10kpMl+A=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.43.0 h1:RAE+JPfvEmvy+0LzyUA25/SGawPwIUbZ6u0Wug54sLc=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.43.0/go.mod h1:AGmbycVGEsRx9mXMZ75CsOyhSP6MFIcj/6dnG+vhVjk=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0 h1:3iZJKlCZufyRzPzlQhUIWVmfltrXuGyfjREgGP3UUjc=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0/go.mod h1:/G+nUPfhq2e+qiXMGxMwumDrP5jtzU+mWN7/sjT2rak=
go.opentelemetry.io/otel/exporters/prometheus v0.65.0 h1:jOveH/b4lU9HT7y+Gfamf18BqlOuz2PWEvs8yM7Q6XE=
go.opentelemetry.io/otel/exporters/prometheus v0.65.0/go.mod h1:i1P8pcumauPtUI4YNopea1dhzEMuEqWP1xoUZDylLHo=
go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.19.0 h1:GJkybS+crDMdExT/BUNCEgfrmfboztcS6PhvSo88HKM=
go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.19.0/go.mod h1:NuAyxRYIG2lKX3YQkB+83StTxM7s52PUUkRRiC0wnYI=
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.43.0 h1:TC+BewnDpeiAmcscXbGMfxkO+mwYUwE/VySwvw88PfA=
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.43.0/go.mod h1:J/ZyF4vfPwsSr9xJSPyQ4LqtcTPULFR64KwTikGLe+A=
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.43.0 h1:mS47AX77OtFfKG4vtp+84kuGSFZHTyxtXIN269vChY0=
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.43.0/go.mod h1:PJnsC41lAGncJlPUniSwM81gc80GkgWJWr3cu2nKEtU=
go.opentelemetry.io/otel/log v0.19.0 h1:KUZs/GOsw79TBBMfDWsXS+KZ4g2Ckzksd1ymzsIEbo4=
go.opentelemetry.io/otel/log v0.19.0/go.mod h1:5DQYeGmxVIr4n0/BcJvF4upsraHjg6vudJJpnkL6Ipk=
go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM=
go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY=
go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg=
go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg=
go.opentelemetry.io/otel/sdk/log v0.19.0 h1:scYVLqT22D2gqXItnWiocLUKGH9yvkkeql5dBDiXyko=
go.opentelemetry.io/otel/sdk/log v0.19.0/go.mod h1:vFBowwXGLlW9AvpuF7bMgnNI95LiW10szrOdvzBHlAg=
go.opentelemetry.io/otel/sdk/log/logtest v0.19.0 h1:BEbF7ZBB6qQloV/Ub1+3NQoOUnVtcGkU3XX4Ws3GQfk=
go.opentelemetry.io/otel/sdk/log/logtest v0.19.0/go.mod h1:Lua81/3yM0wOmoHTokLj9y9ADeA02v1naRrVrkAZuKk=
go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw=
go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A=
go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A=
go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0=
go.opentelemetry.io/proto/otlp v1.10.0 h1:IQRWgT5srOCYfiWnpqUYz9CVmbO8bFmKcwYxpuCSL2g=
go.opentelemetry.io/proto/otlp v1.10.0/go.mod h1:/CV4QoCR/S9yaPj8utp3lvQPoqMtxXdzn7ozvvozVqk=
go.opentelemetry.io/contrib/bridges/otelslog v0.15.0 h1:yOYhGNPZseueTTvWp5iBD3/CthrmvayUXYEX862dDi4=
go.opentelemetry.io/contrib/bridges/otelslog v0.15.0/go.mod h1:CvaNVqIfcybc+7xqZNubbE+26K6P7AKZF/l0lE2kdCk=
go.opentelemetry.io/contrib/bridges/prometheus v0.65.0 h1:I/7S/yWobR3QHFLqHsJ8QOndoiFsj1VgHpQiq43KlUI=
go.opentelemetry.io/contrib/bridges/prometheus v0.65.0/go.mod h1:jPF6gn3y1E+nozCAEQj3c6NZ8KY+tvAgSVfvoOJUFac=
go.opentelemetry.io/contrib/exporters/autoexport v0.65.0 h1:2gApdml7SznX9szEKFjKjM4qGcGSvAybYLBY319XG3g=
go.opentelemetry.io/contrib/exporters/autoexport v0.65.0/go.mod h1:0QqAGlbHXhmPYACG3n5hNzO5DnEqqtg4VcK5pr22RI0=
go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin v0.65.0 h1:LSJsvNqhj2sBNFb5NWHbyDK4QJ/skQ2ydjeOZ9OYNZ4=
go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin v0.65.0/go.mod h1:0Q5ocj6h/+C6KYq8cnl4tDFVd4I1HBdsJ440aeagHos=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0 h1:7iP2uCb7sGddAr30RRS6xjKy7AZ2JtTOPA3oolgVSw8=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0/go.mod h1:c7hN3ddxs/z6q9xwvfLPk+UHlWRQyaeR1LdgfL/66l0=
go.opentelemetry.io/contrib/propagators/b3 v1.40.0 h1:xariChe8OOVF3rNlfzGFgQc61npQmXhzZj/i82mxMfg=
go.opentelemetry.io/contrib/propagators/b3 v1.40.0/go.mod h1:72WvbdxbOfXaELEQfonFfOL6osvcVjI7uJEE8C2nkrs=
go.opentelemetry.io/otel v1.40.0 h1:oA5YeOcpRTXq6NN7frwmwFR0Cn3RhTVZvXsP4duvCms=
go.opentelemetry.io/otel v1.40.0/go.mod h1:IMb+uXZUKkMXdPddhwAHm6UfOwJyh4ct1ybIlV14J0g=
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.16.0 h1:ZVg+kCXxd9LtAaQNKBxAvJ5NpMf7LpvEr4MIZqb0TMQ=
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.16.0/go.mod h1:hh0tMeZ75CCXrHd9OXRYxTlCAdxcXioWHFIpYw2rZu8=
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.16.0 h1:djrxvDxAe44mJUrKataUbOhCKhR3F8QCyWucO16hTQs=
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.16.0/go.mod h1:dt3nxpQEiSoKvfTVxp3TUg5fHPLhKtbcnN3Z1I1ePD0=
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.40.0 h1:NOyNnS19BF2SUDApbOKbDtWZ0IK7b8FJ2uAGdIWOGb0=
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.40.0/go.mod h1:VL6EgVikRLcJa9ftukrHu/ZkkhFBSo1lzvdBC9CF1ss=
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.40.0 h1:9y5sHvAxWzft1WQ4BwqcvA+IFVUJ1Ya75mSAUnFEVwE=
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.40.0/go.mod h1:eQqT90eR3X5Dbs1g9YSM30RavwLF725Ris5/XSXWvqE=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.40.0 h1:QKdN8ly8zEMrByybbQgv8cWBcdAarwmIPZ6FThrWXJs=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.40.0/go.mod h1:bTdK1nhqF76qiPoCCdyFIV+N/sRHYXYCTQc+3VCi3MI=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.40.0 h1:DvJDOPmSWQHWywQS6lKL+pb8s3gBLOZUtw4N+mavW1I=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.40.0/go.mod h1:EtekO9DEJb4/jRyN4v4Qjc2yA7AtfCBuz2FynRUWTXs=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.40.0 h1:wVZXIWjQSeSmMoxF74LzAnpVQOAFDo3pPji9Y4SOFKc=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.40.0/go.mod h1:khvBS2IggMFNwZK/6lEeHg/W57h/IX6J4URh57fuI40=
go.opentelemetry.io/otel/exporters/prometheus v0.62.0 h1:krvC4JMfIOVdEuNPTtQ0ZjCiXrybhv+uOHMfHRmnvVo=
go.opentelemetry.io/otel/exporters/prometheus v0.62.0/go.mod h1:fgOE6FM/swEnsVQCqCnbOfRV4tOnWPg7bVeo4izBuhQ=
go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.16.0 h1:ivlbaajBWJqhcCPniDqDJmRwj4lc6sRT+dCAVKNmxlQ=
go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.16.0/go.mod h1:u/G56dEKDDwXNCVLsbSrllB2o8pbtFLUC4HpR66r2dc=
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.40.0 h1:ZrPRak/kS4xI3AVXy8F7pipuDXmDsrO8Lg+yQjBLjw0=
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.40.0/go.mod h1:3y6kQCWztq6hyW8Z9YxQDDm0Je9AJoFar2G0yDcmhRk=
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.40.0 h1:MzfofMZN8ulNqobCmCAVbqVL5syHw+eB2qPRkCMA/fQ=
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.40.0/go.mod h1:E73G9UFtKRXrxhBsHtG00TB5WxX57lpsQzogDkqBTz8=
go.opentelemetry.io/otel/log v0.16.0 h1:DeuBPqCi6pQwtCK0pO4fvMB5eBq6sNxEnuTs88pjsN4=
go.opentelemetry.io/otel/log v0.16.0/go.mod h1:rWsmqNVTLIA8UnwYVOItjyEZDbKIkMxdQunsIhpUMes=
go.opentelemetry.io/otel/metric v1.40.0 h1:rcZe317KPftE2rstWIBitCdVp89A2HqjkxR3c11+p9g=
go.opentelemetry.io/otel/metric v1.40.0/go.mod h1:ib/crwQH7N3r5kfiBZQbwrTge743UDc7DTFVZrrXnqc=
go.opentelemetry.io/otel/sdk v1.40.0 h1:KHW/jUzgo6wsPh9At46+h4upjtccTmuZCFAc9OJ71f8=
go.opentelemetry.io/otel/sdk v1.40.0/go.mod h1:Ph7EFdYvxq72Y8Li9q8KebuYUr2KoeyHx0DRMKrYBUE=
go.opentelemetry.io/otel/sdk/log v0.16.0 h1:e/b4bdlQwC5fnGtG3dlXUrNOnP7c8YLVSpSfEBIkTnI=
go.opentelemetry.io/otel/sdk/log v0.16.0/go.mod h1:JKfP3T6ycy7QEuv3Hj8oKDy7KItrEkus8XJE6EoSzw4=
go.opentelemetry.io/otel/sdk/log/logtest v0.16.0 h1:/XVkpZ41rVRTP4DfMgYv1nEtNmf65XPPyAdqV90TMy4=
go.opentelemetry.io/otel/sdk/log/logtest v0.16.0/go.mod h1:iOOPgQr5MY9oac/F5W86mXdeyWZGleIx3uXO98X2R6Y=
go.opentelemetry.io/otel/sdk/metric v1.40.0 h1:mtmdVqgQkeRxHgRv4qhyJduP3fYJRMX4AtAlbuWdCYw=
go.opentelemetry.io/otel/sdk/metric v1.40.0/go.mod h1:4Z2bGMf0KSK3uRjlczMOeMhKU2rhUqdWNoKcYrtcBPg=
go.opentelemetry.io/otel/trace v1.40.0 h1:WA4etStDttCSYuhwvEa8OP8I5EWu24lkOzp+ZYblVjw=
go.opentelemetry.io/otel/trace v1.40.0/go.mod h1:zeAhriXecNGP/s2SEG3+Y8X9ujcJOTqQ5RgdEJcawiA=
go.opentelemetry.io/proto/otlp v1.9.0 h1:l706jCMITVouPOqEnii2fIAuO3IVGBRPV5ICjceRb/A=
go.opentelemetry.io/proto/otlp v1.9.0/go.mod h1:xE+Cx5E/eEHw+ISFkwPLwCZefwVjY+pqKg1qcK03+/4=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ=
go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ=
go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0=
go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/arch v0.26.0 h1:jZ6dpec5haP/fUv1kLCbuJy6dnRrfX6iVK08lZBFpk4=
golang.org/x/arch v0.26.0/go.mod h1:0X+GdSIP+kL5wPmpK7sdkEVTt2XoYP0cSjQSbZBwOi8=
golang.org/x/arch v0.24.0 h1:qlJ3M9upxvFfwRM51tTg3Yl+8CP9vCC1E7vlFpgv99Y=
golang.org/x/arch v0.24.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210817164053-32db794688a5/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc=
golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI=
golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8=
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f h1:W3F4c+6OLc6H2lb//N1q4WpJkhzJCK5J6kUi1NTVXfM=
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f/go.mod h1:J1xhfL/vlindoeF/aINzNzt2Bket5bjo9sdOYzOsU80=
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa h1:Zt3DZoOFFYkKhDT3v7Lm9FDMEV06GpzjG2jrqW+QTE0=
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa/go.mod h1:K79w1Vqn7PoiZn+TkNpx3BUWUQksGO3JcVX6qIjytmA=
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/image v0.40.0 h1:Tw4GyDXMo+daZN1znreBRC3VayR1aLFUyUEOLUdW1a8=
golang.org/x/image v0.40.0/go.mod h1:uIc348UZMSvS5Z65CVZ7iDPaNobNFEPeJ4kbqTOszmA=
golang.org/x/image v0.36.0 h1:Iknbfm1afbgtwPTmHnS2gTM/6PPZfH+z2EFuOkSbqwc=
golang.org/x/image v0.36.0/go.mod h1:YsWD2TyyGKiIX1kZlu9QfKIsQ4nAAK9bdgdrIsE7xy4=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM=
golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU=
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20191209160850-c0dbc17a3553/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200320220750-118fecf932d8/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200501053045-e0ff5e5a1de5/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20200513185701-a91f0712d120/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20200520182314-0ba52f642ac2/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k=
golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w=
golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ=
golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ=
golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
golang.org/x/term v0.28.0/go.mod h1:Sw/lC2IAUZ92udQNf3WodGtn4k/XoLyZoh8v/8uiwek=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c=
golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI=
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
google.golang.org/genproto/googleapis/api v0.0.0-20260406210006-6f92a3bedf2d h1:/aDRtSZJjyLQzm75d+a1wOJaqyKBMvIAfeQmoa3ORiI=
google.golang.org/genproto/googleapis/api v0.0.0-20260406210006-6f92a3bedf2d/go.mod h1:etfGUgejTiadZAUaEP14NP97xi1RGeawqkjDARA/UOs=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260406210006-6f92a3bedf2d h1:wT2n40TBqFY6wiwazVK9/iTWbsQrgk5ZfCSVFLO9LQA=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260406210006-6f92a3bedf2d/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/grpc v1.80.0 h1:Xr6m2WmWZLETvUNvIUmeD5OAagMw3FiKmMlTdViWsHM=
google.golang.org/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07bwF4=
google.golang.org/genproto/googleapis/api v0.0.0-20260217215200-42d3e9bedb6d h1:EocjzKLywydp5uZ5tJ79iP6Q0UjDnyiHkGRWxuPBP8s=
google.golang.org/genproto/googleapis/api v0.0.0-20260217215200-42d3e9bedb6d/go.mod h1:48U2I+QQUYhsFrg2SY6r+nJzeOtjey7j//WBESw+qyQ=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d h1:t/LOSXPJ9R0B6fnZNyALBRfZBH0Uy0gT+uR+SJ6syqQ=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE=
google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v2 v2.2.7/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
@@ -566,20 +484,20 @@ gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4=
gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo=
gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg=
gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
modernc.org/cc/v4 v4.27.3 h1:uNCgn37E5U09mTv1XgskEVUJ8ADKpmFMPxzGJ0TSo+U=
modernc.org/cc/v4 v4.27.3/go.mod h1:3YjcbCqhoTTHPycJDRl2WZKKFj0nwcOIPBfEZK0Hdk8=
modernc.org/ccgo/v4 v4.32.4 h1:L5OB8rpEX4ZsXEQwGozRfJyJSFHbbNVOoQ59DU9/KuU=
modernc.org/ccgo/v4 v4.32.4/go.mod h1:lY7f+fiTDHfcv6YlRgSkxYfhs+UvOEEzj49jAn2TOx0=
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=
modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
modernc.org/ccgo/v4 v4.30.2 h1:4yPaaq9dXYXZ2V8s1UgrC3KIj580l2N4ClrLwnbv2so=
modernc.org/ccgo/v4 v4.30.2/go.mod h1:yZMnhWEdW0qw3EtCndG1+ldRrVGS+bIwyWmAWzS0XEw=
modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA=
modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo=
modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.71.0 h1:bu0djXJGhqed3DnBzyzu3sY0fv432lesyz99ecEahyA=
modernc.org/libc v1.71.0/go.mod h1:tTU8DL8A+XLVkEY3x5E/tO7s2Q/q42EtnNWda/L5QhQ=
modernc.org/libc v1.68.0 h1:PJ5ikFOV5pwpW+VqCK1hKJuEWsonkIJhhIXyuF/91pQ=
modernc.org/libc v1.68.0/go.mod h1:NnKCYeoYgsEqnY3PgvNgAeaJnso968ygU8Z0DxjoEc0=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
@@ -588,8 +506,8 @@ modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.48.2 h1:5CnW4uP8joZtA0LedVqLbZV5GD7F/0x91AXeSyjoh5c=
modernc.org/sqlite v1.48.2/go.mod h1:hWjRO6Tj/5Ik8ieqxQybiEOUXy0NJFNp2tpvVpKlvig=
modernc.org/sqlite v1.46.1 h1:eFJ2ShBLIEnUWlLy12raN0Z1plqmFX9Qe3rjQTKt6sU=
modernc.org/sqlite v1.46.1/go.mod h1:CzbrU2lSB1DKUusvwGz7rqEKIq+NUd8GWuBBZDs9/nA=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=

View File

@@ -2,7 +2,6 @@ package bootstrap
import (
"context"
"crypto/tls"
"errors"
"fmt"
"log/slog"
@@ -11,15 +10,13 @@ import (
"os"
"strconv"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/coreos/go-systemd/activation"
"github.com/fsnotify/fsnotify"
sloggin "github.com/gin-contrib/slog"
"github.com/gin-gonic/gin"
"go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin"
"golang.org/x/net/http2"
"golang.org/x/net/http2/h2c"
"golang.org/x/time/rate"
"gorm.io/gorm"
@@ -35,47 +32,6 @@ import (
var registerTestControllers []func(apiGroup *gin.RouterGroup, db *gorm.DB, svc *services)
func initRouter(db *gorm.DB, svc *services) (utils.Service, error) {
r, err := initEngine()
if err != nil {
return nil, err
}
err = registerRoutes(r, db, svc)
if err != nil {
return nil, err
}
serverConfig, err := initServer(r)
if err != nil {
return nil, err
}
runFn := func(ctx context.Context) error {
return runServer(ctx, serverConfig)
}
return runFn, nil
}
type serverConfig struct {
addr string
certProvider *tlsCertProvider
listener net.Listener
server *http.Server
tlsConfig *tls.Config
}
func initEngine() (*gin.Engine, error) {
setGinMode()
r := gin.New()
initLogger(r)
configureEngine(r)
registerGlobalMiddleware(r)
return r, nil
}
func setGinMode() {
// Set the appropriate Gin mode based on the environment
switch common.EnvConfig.AppEnv {
case common.AppEnvProduction:
@@ -85,9 +41,10 @@ func setGinMode() {
case common.AppEnvTest:
gin.SetMode(gin.TestMode)
}
}
func configureEngine(r *gin.Engine) {
r := gin.New()
initLogger(r)
if !common.EnvConfig.TrustProxy {
_ = r.SetTrustedProxies(nil)
}
@@ -99,181 +56,68 @@ func configureEngine(r *gin.Engine) {
if common.EnvConfig.TracingEnabled {
r.Use(otelgin.Middleware(common.Name))
}
}
func registerGlobalMiddleware(r *gin.Engine) {
// Setup global middleware
r.Use(middleware.HeadMiddleware())
r.Use(middleware.NewCacheControlMiddleware().Add())
r.Use(middleware.NewCorsMiddleware().Add())
r.Use(middleware.NewCspMiddleware().Add())
r.Use(middleware.NewErrorHandlerMiddleware().Add())
}
func registerRoutes(r *gin.Engine, db *gorm.DB, svc *services) error {
err := frontend.RegisterFrontend(r, svc.oidcService)
frontendRateLimitMiddleware := middleware.NewRateLimitMiddleware().Add(rate.Every(100*time.Millisecond), 300)
err := frontend.RegisterFrontend(r, frontendRateLimitMiddleware, svc.oidcService)
if errors.Is(err, frontend.ErrFrontendNotIncluded) {
slog.Warn("Frontend is not included in the build. Skipping frontend registration.")
} else if err != nil {
return fmt.Errorf("failed to register frontend: %w", err)
return nil, fmt.Errorf("failed to register frontend: %w", err)
}
// Initialize middleware for specific routes
authMiddleware := middleware.NewAuthMiddleware(svc.apiKeyService, svc.userService, svc.jwtService)
fileSizeLimitMiddleware := middleware.NewFileSizeLimitMiddleware()
apiRateLimitMiddleware := middleware.NewRateLimitMiddleware().Add(rate.Every(time.Second), 100)
// Set up API routes
apiGroup := r.Group("/api", apiRateLimitMiddleware)
controller.NewApiKeyController(apiGroup, authMiddleware, svc.apiKeyService)
controller.NewWebauthnController(apiGroup, authMiddleware, middleware.NewRateLimitMiddleware(), svc.webauthnService, svc.appConfigService)
controller.NewOidcController(apiGroup, authMiddleware, fileSizeLimitMiddleware, svc.oidcService, svc.jwtService)
controller.NewUserController(apiGroup, authMiddleware, middleware.NewRateLimitMiddleware(), svc.userService, svc.oneTimeAccessService, svc.webauthnService, svc.appConfigService)
controller.NewUserController(apiGroup, authMiddleware, middleware.NewRateLimitMiddleware(), svc.userService, svc.oneTimeAccessService, svc.appConfigService)
controller.NewAppConfigController(apiGroup, authMiddleware, svc.appConfigService, svc.emailService, svc.ldapService)
controller.NewAppImagesController(apiGroup, authMiddleware, svc.appImagesService)
controller.NewAuditLogController(apiGroup, svc.auditLogService, authMiddleware)
controller.NewUserGroupController(apiGroup, authMiddleware, svc.userGroupService)
controller.NewCustomClaimController(apiGroup, authMiddleware, svc.customClaimService)
controller.NewVersionController(apiGroup, authMiddleware, svc.versionService)
controller.NewScimController(apiGroup, authMiddleware, svc.scimService)
controller.NewUserSignupController(apiGroup, authMiddleware, middleware.NewRateLimitMiddleware(), svc.userSignUpService, svc.appConfigService)
registerTestRoutes(apiGroup, db, svc)
// Add test controller in non-production environments
if !common.EnvConfig.AppEnv.IsProduction() {
for _, f := range registerTestControllers {
f(apiGroup, db, svc)
}
}
// Set up base routes
baseGroup := r.Group("/", apiRateLimitMiddleware)
controller.NewWellKnownController(baseGroup, svc.jwtService)
// These are not rate-limited.
// Set up healthcheck routes
// These are not rate-limited
controller.NewHealthzController(r)
return nil
}
func registerTestRoutes(apiGroup *gin.RouterGroup, db *gorm.DB, svc *services) {
if common.EnvConfig.AppEnv.IsProduction() {
return
}
for _, f := range registerTestControllers {
f(apiGroup, db, svc)
}
}
func initServer(r *gin.Engine) (*serverConfig, error) {
protocols, tlsConfig, certProvider, err := initServerProtocols()
if err != nil {
return nil, err
}
var socketFn func() (*socket, error)
switch {
case common.EnvConfig.SystemdSocket:
socketFn = systemdSocket
case common.EnvConfig.UnixSocket != "":
socketFn = unixSocket
default:
socketFn = tcpSocket
}
socket, err := socketFn()
if err != nil {
return nil, err
}
addr := socket.addr
listener := socket.listener
server := newHTTPServer(r, protocols)
return &serverConfig{addr, certProvider, listener, server, tlsConfig}, nil
}
func initServerProtocols() (*http.Protocols, *tls.Config, *tlsCertProvider, error) {
protocols := new(http.Protocols)
var protocols http.Protocols
protocols.SetHTTP1(true)
protocols.SetUnencryptedHTTP2(true)
if common.EnvConfig.TLSCertFile == "" || common.EnvConfig.TLSKeyFile == "" {
protocols.SetUnencryptedHTTP2(true)
return protocols, nil, nil, nil
}
protocols.SetHTTP2(true)
certProvider, err := newCertProvider(common.EnvConfig.TLSCertFile, common.EnvConfig.TLSKeyFile)
if err != nil {
return nil, nil, nil, fmt.Errorf("failed to load TLS certificate: %w", err)
}
tlsConfig := &tls.Config{
GetCertificate: certProvider.GetCertificate,
MinVersion: tls.VersionTLS13,
NextProtos: []string{"h2"},
}
slog.Info("TLS enabled")
return protocols, tlsConfig, certProvider, nil
}
type socket struct {
addr string
listener net.Listener
}
func systemdSocket() (*socket, error) {
listeners, err := activation.Listeners()
if err != nil {
return nil, fmt.Errorf("failed to receive socket from systemd: %w", err)
}
if len(listeners) == 0 {
return nil, errors.New("did not receive any sockets from systemd")
}
if len(listeners) > 1 {
return nil, errors.New("received too many sockets from systemd")
}
return &socket{"(systemd)", listeners[0]}, nil
}
func unixSocket() (*socket, error) {
addr := common.EnvConfig.UnixSocket
os.Remove(addr) // remove dangling the socket file to avoid file-exist error
listener, err := net.Listen("unix", addr) //nolint:noctx
if err != nil {
return nil, fmt.Errorf("failed to create UNIX socket: %w", err)
}
if common.EnvConfig.UnixSocketMode != "" {
mode, err := strconv.ParseUint(common.EnvConfig.UnixSocketMode, 8, 32)
if err != nil {
listener.Close()
return nil, fmt.Errorf("failed to parse UNIX socket mode '%s': %w", common.EnvConfig.UnixSocketMode, err)
}
if err := os.Chmod(addr, os.FileMode(mode)); err != nil {
listener.Close()
return nil, fmt.Errorf("failed to set UNIX socket mode '%s': %w", common.EnvConfig.UnixSocketMode, err)
}
}
return &socket{addr, listener}, nil
}
func tcpSocket() (*socket, error) {
addr := net.JoinHostPort(common.EnvConfig.Host, common.EnvConfig.Port)
listener, err := net.Listen("tcp", addr) //nolint:noctx
if err != nil {
return nil, fmt.Errorf("failed to create TCP socket: %w", err)
}
return &socket{addr, listener}, nil
}
func newHTTPServer(r *gin.Engine, protocols *http.Protocols) *http.Server {
return &http.Server{
// Set up the server
srv := &http.Server{
MaxHeaderBytes: 1 << 20,
ReadHeaderTimeout: 10 * time.Second,
Protocols: protocols,
Handler: http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
Protocols: &protocols,
Handler: h2c.NewHandler(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
// HEAD requests don't get matched by Gin routes, so we convert them to GET
// middleware.HeadMiddleware will convert them back to HEAD later
if req.Method == http.MethodHead {
@@ -283,94 +127,75 @@ func newHTTPServer(r *gin.Engine, protocols *http.Protocols) *http.Server {
}
r.ServeHTTP(w, req)
}),
}), &http2.Server{}),
}
}
func runServer(ctx context.Context, config *serverConfig) error {
slog.Info("Server listening", slog.String("addr", config.addr), slog.Bool("tls", config.tlsConfig != nil))
// Set up the listener
network := "tcp"
addr := net.JoinHostPort(common.EnvConfig.Host, common.EnvConfig.Port)
if common.EnvConfig.UnixSocket != "" {
network = "unix"
addr = common.EnvConfig.UnixSocket
os.Remove(addr) // remove dangling the socket file to avoid file-exist error
}
certWatcher, err := startCertWatcher(ctx, config.certProvider)
listener, err := net.Listen(network, addr) //nolint:noctx
if err != nil {
return err
}
defer closeCertWatcher(certWatcher)
startHTTPServer(config)
notifySystemdReady()
<-ctx.Done()
// We do not pass the context because it's already been canceled
//nolint:contextcheck
return shutdownServer(config.server)
}
func startCertWatcher(ctx context.Context, certProvider *tlsCertProvider) (*fsnotify.Watcher, error) {
if certProvider == nil {
return nil, nil
return nil, fmt.Errorf("failed to create %s listener: %w", network, err)
}
certWatcher, err := fsnotify.NewWatcher()
if err != nil {
return nil, fmt.Errorf("failed to create certificate watcher: %w", err)
}
if err := certWatcher.Add(common.EnvConfig.TLSCertFile); err != nil {
certWatcher.Close()
return nil, fmt.Errorf("failed to watch TLS certificate: %w", err)
}
if err := certWatcher.Add(common.EnvConfig.TLSKeyFile); err != nil {
certWatcher.Close()
return nil, fmt.Errorf("failed to watch TLS key: %w", err)
}
go certProvider.StartWatching(ctx, certWatcher)
return certWatcher, nil
}
func closeCertWatcher(certWatcher *fsnotify.Watcher) {
if certWatcher != nil {
certWatcher.Close()
}
}
func startHTTPServer(config *serverConfig) {
go func() {
defer config.listener.Close()
listener := config.listener
if config.tlsConfig != nil {
listener = tls.NewListener(config.listener, config.tlsConfig)
// Set the socket mode if using a Unix socket
if network == "unix" && common.EnvConfig.UnixSocketMode != "" {
mode, err := strconv.ParseUint(common.EnvConfig.UnixSocketMode, 8, 32)
if err != nil {
return nil, fmt.Errorf("failed to parse UNIX socket mode '%s': %w", common.EnvConfig.UnixSocketMode, err)
}
srvErr := config.server.Serve(listener)
if srvErr != http.ErrServerClosed {
slog.Error("Error starting app server", "error", srvErr)
os.Exit(1)
if err := os.Chmod(addr, os.FileMode(mode)); err != nil {
return nil, fmt.Errorf("failed to set UNIX socket mode '%s': %w", common.EnvConfig.UnixSocketMode, err)
}
}()
}
func notifySystemdReady() {
err := systemd.SdNotifyReady()
if err != nil {
// Log the error only
slog.Warn("Unable to notify systemd that the service is ready", "error", err)
}
}
func shutdownServer(srv *http.Server) error {
// Note we use the background context here as ctx has been canceled already
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 5*time.Second)
shutdownErr := srv.Shutdown(shutdownCtx) //nolint:contextcheck
shutdownCancel()
if shutdownErr != nil {
// Log the error only (could be context canceled)
slog.Warn("App server shutdown error", "error", shutdownErr)
}
return nil
// Service runner function
runFn := func(ctx context.Context) error {
slog.Info("Server listening", slog.String("addr", addr))
// Start the server in a background goroutine
go func() {
defer listener.Close()
// Next call blocks until the server is shut down
srvErr := srv.Serve(listener)
if srvErr != http.ErrServerClosed {
slog.Error("Error starting app server", "error", srvErr)
os.Exit(1)
}
}()
// Notify systemd that we are ready
err = systemd.SdNotifyReady()
if err != nil {
// Log the error only
slog.Warn("Unable to notify systemd that the service is ready", "error", err)
}
// Block until the context is canceled
<-ctx.Done()
// Handle graceful shutdown
// Note we use the background context here as ctx has been canceled already
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 5*time.Second)
shutdownErr := srv.Shutdown(shutdownCtx) //nolint:contextcheck
shutdownCancel()
if shutdownErr != nil {
// Log the error only (could be context canceled)
slog.Warn("App server shutdown error", "error", shutdownErr)
}
return nil
}
return runFn, nil
}
func initLogger(r *gin.Engine) {
@@ -399,99 +224,3 @@ func initLogger(r *gin.Engine) {
}),
))
}
// tlsCertProvider holds certificates that can be dynamically reloaded
type tlsCertProvider struct {
certMutex sync.RWMutex
cert *tls.Certificate
certFile string
keyFile string
forceReload atomic.Bool
}
// GetCertificate implements tls.GetCertificate interface for dynamic certificate loading
func (p *tlsCertProvider) GetCertificate(_ *tls.ClientHelloInfo) (*tls.Certificate, error) {
if p.forceReload.Load() {
p.certMutex.Lock()
p.forceReload.Store(false)
p.certMutex.Unlock()
}
p.certMutex.RLock()
defer p.certMutex.RUnlock()
return p.cert, nil
}
// newCertProvider creates a new certificate provider with initial certificates loaded
func newCertProvider(certFile, keyFile string) (*tlsCertProvider, error) {
cert, err := tls.LoadX509KeyPair(certFile, keyFile)
if err != nil {
return nil, err
}
return &tlsCertProvider{
cert: &cert,
certFile: certFile,
keyFile: keyFile,
}, nil
}
// reloadCertificate reloads the certificate from disk
func (p *tlsCertProvider) reloadCertificate() error {
cert, err := tls.LoadX509KeyPair(p.certFile, p.keyFile)
if err != nil {
return fmt.Errorf("failed to reload TLS certificate: %w", err)
}
p.certMutex.Lock()
p.cert = &cert
p.certMutex.Unlock()
return nil
}
// StartWatching begins monitoring the certificate files for changes with debouncing
func (p *tlsCertProvider) StartWatching(ctx context.Context, watcher *fsnotify.Watcher) {
debounceDuration := 1 * time.Second
reloadTimer := time.NewTimer(debounceDuration)
reloadTimer.Stop()
for {
select {
case <-ctx.Done():
return
case event, ok := <-watcher.Events:
if !ok {
return
}
// Only process write/rename events for certificate/key files
if event.Has(fsnotify.Write | fsnotify.Rename) {
// Reset the debounce timer whenever we get a relevant event
reloadTimer.Stop()
// Drain the channel if there's a pending value
select {
case <-reloadTimer.C:
default:
}
reloadTimer.Reset(debounceDuration)
slog.Debug("TLS file change detected, debouncing", slog.String("path", event.Name))
}
case <-reloadTimer.C:
// Timer fired - no more events in 500ms, so reload
slog.Info("Reloading TLS certificate")
if err := p.reloadCertificate(); err != nil {
slog.Error("Failed to reload TLS certificate", "error", err)
continue
}
p.forceReload.Store(true)
slog.Info("TLS certificate reloaded successfully")
case err, ok := <-watcher.Errors:
if !ok {
return
}
slog.Error("Certificate watcher error", "error", err)
}
}
}

View File

@@ -13,25 +13,25 @@ import (
)
type services struct {
appConfigService *service.AppConfigService
appImagesService *service.AppImagesService
emailService *service.EmailService
geoLiteService *service.GeoLiteService
auditLogService *service.AuditLogService
jwtService *service.JwtService
webauthnService *service.WebAuthnService
scimService *service.ScimService
userService *service.UserService
customFieldValueService *service.CustomFieldValueService
oidcService *service.OidcService
userGroupService *service.UserGroupService
ldapService *service.LdapService
apiKeyService *service.ApiKeyService
versionService *service.VersionService
fileStorage storage.FileStorage
appLockService *service.AppLockService
userSignUpService *service.UserSignUpService
oneTimeAccessService *service.OneTimeAccessService
appConfigService *service.AppConfigService
appImagesService *service.AppImagesService
emailService *service.EmailService
geoLiteService *service.GeoLiteService
auditLogService *service.AuditLogService
jwtService *service.JwtService
webauthnService *service.WebAuthnService
scimService *service.ScimService
userService *service.UserService
customClaimService *service.CustomClaimService
oidcService *service.OidcService
userGroupService *service.UserGroupService
ldapService *service.LdapService
apiKeyService *service.ApiKeyService
versionService *service.VersionService
fileStorage storage.FileStorage
appLockService *service.AppLockService
userSignUpService *service.UserSignUpService
oneTimeAccessService *service.OneTimeAccessService
}
// Initializes all services
@@ -59,7 +59,7 @@ func initServices(ctx context.Context, db *gorm.DB, httpClient *http.Client, ima
return nil, fmt.Errorf("failed to create JWT service: %w", err)
}
svc.customFieldValueService = service.NewCustomFieldValueService(db, svc.appConfigService)
svc.customClaimService = service.NewCustomClaimService(db)
svc.webauthnService, err = service.NewWebAuthnService(db, svc.jwtService, svc.auditLogService, svc.appConfigService)
if err != nil {
return nil, fmt.Errorf("failed to create WebAuthn service: %w", err)
@@ -67,13 +67,13 @@ func initServices(ctx context.Context, db *gorm.DB, httpClient *http.Client, ima
svc.scimService = service.NewScimService(db, scheduler, httpClient)
svc.oidcService, err = service.NewOidcService(ctx, db, svc.jwtService, svc.appConfigService, svc.auditLogService, svc.customFieldValueService, svc.webauthnService, svc.scimService, httpClient, fileStorage)
svc.oidcService, err = service.NewOidcService(ctx, db, svc.jwtService, svc.appConfigService, svc.auditLogService, svc.customClaimService, svc.webauthnService, svc.scimService, httpClient, fileStorage)
if err != nil {
return nil, fmt.Errorf("failed to create OIDC service: %w", err)
}
svc.userGroupService = service.NewUserGroupService(db, svc.appConfigService, svc.customFieldValueService, svc.scimService)
svc.userService = service.NewUserService(db, svc.jwtService, svc.auditLogService, svc.emailService, svc.appConfigService, svc.customFieldValueService, svc.appImagesService, svc.scimService, fileStorage)
svc.userGroupService = service.NewUserGroupService(db, svc.appConfigService, svc.scimService)
svc.userService = service.NewUserService(db, svc.jwtService, svc.auditLogService, svc.emailService, svc.appConfigService, svc.customClaimService, svc.appImagesService, svc.scimService, fileStorage)
svc.ldapService = service.NewLdapService(db, httpClient, svc.appConfigService, svc.userService, svc.userGroupService, fileStorage)
svc.apiKeyService, err = service.NewApiKeyService(ctx, db, svc.emailService)

View File

@@ -2,12 +2,9 @@ package cmds
import (
"context"
"fmt"
"log/slog"
"net"
"net/http"
"os"
"strings"
"time"
"github.com/spf13/cobra"
@@ -16,14 +13,8 @@ import (
)
type healthcheckFlags struct {
Endpoint string
UnixSocket string
Verbose bool
}
type healthcheckResult struct {
StatusCode int
URL string
Endpoint string
Verbose bool
}
func init() {
@@ -38,26 +29,47 @@ func init() {
ctx, cancel := context.WithTimeout(cmd.Context(), 5*time.Second)
defer cancel()
if flags.UnixSocket == "" && !cmd.Flags().Changed("endpoint") {
flags.UnixSocket = common.EnvConfig.UnixSocket
}
result, err := healthcheck(ctx, flags)
url := flags.Endpoint + "/healthz"
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
slog.ErrorContext(ctx,
"Healthcheck failed",
"Failed to create request object",
"error", err,
"url", url,
"ms", time.Since(start).Milliseconds(),
)
os.Exit(1)
}
res, err := http.DefaultClient.Do(req)
if err != nil {
slog.ErrorContext(ctx,
"Failed to perform request",
"error", err,
"url", url,
"ms", time.Since(start).Milliseconds(),
)
os.Exit(1)
}
defer res.Body.Close()
if res.StatusCode < 200 || res.StatusCode >= 300 {
if err != nil {
slog.ErrorContext(ctx,
"Healthcheck failed",
"status", res.StatusCode,
"url", url,
"ms", time.Since(start).Milliseconds(),
)
os.Exit(1)
}
}
if flags.Verbose {
slog.InfoContext(ctx,
"Healthcheck succeeded",
"status", result.StatusCode,
"url", result.URL,
"unixSocket", flags.UnixSocket,
"status", res.StatusCode,
"url", url,
"ms", time.Since(start).Milliseconds(),
)
}
@@ -65,42 +77,7 @@ func init() {
}
healthcheckCmd.Flags().StringVarP(&flags.Endpoint, "endpoint", "e", "http://localhost:"+common.EnvConfig.Port, "Endpoint for Pocket ID")
healthcheckCmd.Flags().StringVar(&flags.UnixSocket, "unix-socket", "", "UNIX socket path for Pocket ID")
healthcheckCmd.Flags().BoolVarP(&flags.Verbose, "verbose", "v", false, "Enable verbose mode")
rootCmd.AddCommand(healthcheckCmd)
}
func healthcheck(ctx context.Context, flags healthcheckFlags) (*healthcheckResult, error) {
url := strings.TrimRight(flags.Endpoint, "/") + "/healthz"
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, fmt.Errorf("failed to create request object for %q: %w", url, err)
}
client := http.DefaultClient
if flags.UnixSocket != "" {
transport := http.DefaultTransport.(*http.Transport).Clone()
transport.Proxy = nil
transport.DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) {
dialer := net.Dialer{}
return dialer.DialContext(ctx, "unix", flags.UnixSocket)
}
client = &http.Client{Transport: transport}
}
res, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to perform request to %q: %w", url, err)
}
defer res.Body.Close()
if res.StatusCode < 200 || res.StatusCode >= 300 {
return nil, fmt.Errorf("unexpected status %d from %q", res.StatusCode, url)
}
return &healthcheckResult{
StatusCode: res.StatusCode,
URL: url,
}, nil
}

View File

@@ -1,79 +0,0 @@
package cmds
import (
"context"
"net"
"net/http"
"net/http/httptest"
"path/filepath"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestHealthcheckTCPSuccess(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "/healthz", r.URL.Path)
w.WriteHeader(http.StatusNoContent)
}))
defer server.Close()
result, err := healthcheck(t.Context(), healthcheckFlags{
Endpoint: server.URL,
})
require.NoError(t, err)
require.Equal(t, http.StatusNoContent, result.StatusCode)
require.Equal(t, server.URL+"/healthz", result.URL)
}
func TestHealthcheckFailsOnUnexpectedStatus(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
}))
defer server.Close()
_, err := healthcheck(t.Context(), healthcheckFlags{
Endpoint: server.URL,
})
require.Error(t, err)
require.ErrorContains(t, err, "unexpected status 500")
}
func TestHealthcheckUnixSocket(t *testing.T) {
socketPath := filepath.Join(t.TempDir(), "pocket-id.sock")
listener, err := (&net.ListenConfig{}).Listen(t.Context(), "unix", socketPath)
require.NoError(t, err)
server := &http.Server{
Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "/healthz", r.URL.Path)
w.WriteHeader(http.StatusNoContent)
}),
ReadHeaderTimeout: time.Second,
}
errCh := make(chan error, 1)
go func() {
errCh <- server.Serve(listener)
}()
t.Cleanup(func() {
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
require.NoError(t, server.Shutdown(ctx))
require.ErrorIs(t, <-errCh, http.ErrServerClosed)
})
result, err := healthcheck(t.Context(), healthcheckFlags{
Endpoint: "http://localhost:1411",
UnixSocket: socketPath,
})
require.NoError(t, err)
require.Equal(t, http.StatusNoContent, result.StatusCode)
require.Equal(t, "http://localhost:1411/healthz", result.URL)
}

View File

@@ -60,7 +60,7 @@ type EnvConfigSchema struct {
S3Region string `env:"S3_REGION"`
S3Endpoint string `env:"S3_ENDPOINT"`
S3AccessKeyID string `env:"S3_ACCESS_KEY_ID"`
S3SecretAccessKey string `env:"S3_SECRET_ACCESS_KEY" options:"file"`
S3SecretAccessKey string `env:"S3_SECRET_ACCESS_KEY"`
S3ForcePathStyle bool `env:"S3_FORCE_PATH_STYLE"`
S3DisableDefaultIntegrityChecks bool `env:"S3_DISABLE_DEFAULT_INTEGRITY_CHECKS"`
@@ -68,12 +68,8 @@ type EnvConfigSchema struct {
Host string `env:"HOST" options:"toLower"`
UnixSocket string `env:"UNIX_SOCKET"`
UnixSocketMode string `env:"UNIX_SOCKET_MODE"`
SystemdSocket bool `env:"SYSTEMD_SOCKET"`
LocalIPv6Ranges string `env:"LOCAL_IPV6_RANGES"`
TLSCertFile string `env:"TLS_CERT" options:"file"`
TLSKeyFile string `env:"TLS_KEY" options:"file"`
MaxMindLicenseKey string `env:"MAXMIND_LICENSE_KEY" options:"file"`
GeoLiteDBPath string `env:"GEOLITE_DB_PATH"`
GeoLiteDBUrl string `env:"GEOLITE_DB_URL"`
@@ -146,34 +142,6 @@ func ValidateEnvConfig(config *EnvConfigSchema) error {
return errors.New("ENCRYPTION_KEY must be at least 16 bytes long")
}
prepareDbConfig(config)
if err := validateAppURLs(config); err != nil {
return err
}
if err := validateFileBackend(config); err != nil {
return err
}
if config.SystemdSocket && config.UnixSocket != "" {
return errors.New("SYSTEMD_SOCKET and UNIX_SOCKET are mutually exclusive")
}
if err := validateLocalIPv6Ranges(config.LocalIPv6Ranges); err != nil {
return err
}
if config.AuditLogRetentionDays <= 0 {
return errors.New("AUDIT_LOG_RETENTION_DAYS must be greater than 0")
}
if config.StaticApiKey != "" && len(config.StaticApiKey) < 16 {
return errors.New("STATIC_API_KEY must be at least 16 characters long")
}
return validateTLSConfig(config)
}
func prepareDbConfig(config *EnvConfigSchema) {
switch {
case config.DbConnectionString == "":
config.DbProvider = DbProviderSqlite
@@ -183,95 +151,64 @@ func prepareDbConfig(config *EnvConfigSchema) {
default:
config.DbProvider = DbProviderSqlite
}
}
func validateAppURLs(config *EnvConfigSchema) error {
if err := validateURLWithoutPath(config.AppURL, "APP_URL"); err != nil {
return err
parsedAppUrl, err := url.Parse(config.AppURL)
if err != nil {
return errors.New("APP_URL is not a valid URL")
}
if parsedAppUrl.Path != "" {
return errors.New("APP_URL must not contain a path")
}
// Derive INTERNAL_APP_URL from APP_URL if not set; validate only when provided
if config.InternalAppURL == "" {
config.InternalAppURL = config.AppURL
return nil
} else {
parsedInternalAppUrl, err := url.Parse(config.InternalAppURL)
if err != nil {
return errors.New("INTERNAL_APP_URL is not a valid URL")
}
if parsedInternalAppUrl.Path != "" {
return errors.New("INTERNAL_APP_URL must not contain a path")
}
}
return validateURLWithoutPath(config.InternalAppURL, "INTERNAL_APP_URL")
}
func validateURLWithoutPath(rawURL, envName string) error {
parsedURL, err := url.Parse(rawURL)
if err != nil {
return fmt.Errorf("%s is not a valid URL", envName)
}
if parsedURL.Path != "" {
return fmt.Errorf("%s must not contain a path", envName)
}
return nil
}
func validateFileBackend(config *EnvConfigSchema) error {
switch config.FileBackend {
case "s3", "database":
return nil
// All good, these are valid values
case "", "filesystem":
if config.UploadPath == "" {
config.UploadPath = defaultFsUploadPath
}
return nil
default:
return errors.New("invalid FILE_BACKEND value. Must be 'filesystem', 'database', or 's3'")
}
}
func validateLocalIPv6Ranges(localIPv6Ranges string) error {
ranges := strings.SplitSeq(localIPv6Ranges, ",")
// Validate LOCAL_IPV6_RANGES
ranges := strings.SplitSeq(config.LocalIPv6Ranges, ",")
for rangeStr := range ranges {
rangeStr = strings.TrimSpace(rangeStr)
if rangeStr == "" {
continue
}
if err := validateLocalIPv6Range(rangeStr); err != nil {
return err
_, ipNet, err := net.ParseCIDR(rangeStr)
if err != nil {
return fmt.Errorf("invalid LOCAL_IPV6_RANGES '%s': %w", rangeStr, err)
}
}
return nil
}
func validateLocalIPv6Range(rangeStr string) error {
_, ipNet, err := net.ParseCIDR(rangeStr)
if err != nil {
return fmt.Errorf("invalid LOCAL_IPV6_RANGES '%s': %w", rangeStr, err)
}
if ipNet.IP.To4() != nil {
return fmt.Errorf("range '%s' is not a valid IPv6 range", rangeStr)
}
return nil
}
func validateTLSConfig(config *EnvConfigSchema) error {
switch {
case config.TLSCertFile != "" && config.TLSKeyFile == "":
return errors.New("TLS_KEY_FILE must be set when TLS_CERT_FILE is set")
case config.TLSCertFile == "" && config.TLSKeyFile != "":
return errors.New("TLS_CERT_FILE must be set when TLS_KEY_FILE is set")
}
if config.TLSCertFile != "" && config.TLSKeyFile != "" {
if _, err := os.Stat(config.TLSCertFile); err != nil {
return fmt.Errorf("TLS_CERT_FILE not found: %w", err)
if ipNet.IP.To4() != nil {
return fmt.Errorf("range '%s' is not a valid IPv6 range", rangeStr)
}
}
if config.TLSCertFile != "" && config.TLSKeyFile != "" {
if _, err := os.Stat(config.TLSKeyFile); err != nil {
return fmt.Errorf("TLS_KEY_FILE not found: %w", err)
}
if config.AuditLogRetentionDays <= 0 {
return errors.New("AUDIT_LOG_RETENTION_DAYS must be greater than 0")
}
if config.StaticApiKey != "" && len(config.StaticApiKey) < 16 {
return errors.New("STATIC_API_KEY must be at least 16 characters long")
}
return nil
@@ -351,7 +288,6 @@ func resolveFileBasedEnvVariable(field reflect.Value, fieldType reflect.StructFi
return nil
}
// #nosec G703 - Path is passed by the admin
fileContent, err := os.ReadFile(envVarFileValue)
if err != nil {
return fmt.Errorf("failed to read file for env var %s: %w", envVarFileName, err)

View File

@@ -207,58 +207,6 @@ func TestParseEnvConfig(t *testing.T) {
require.Error(t, err)
assert.ErrorContains(t, err, "invalid FILE_BACKEND value")
})
t.Run("should fail when TLS cert is set without key", func(t *testing.T) {
EnvConfig = defaultConfig()
t.Setenv("DB_CONNECTION_STRING", "file:test.db")
t.Setenv("APP_URL", "http://localhost:3000")
t.Setenv("TLS_CERT", "/path/to/cert.pem")
err := parseAndValidateEnvConfig(t)
require.Error(t, err)
assert.ErrorContains(t, err, "TLS_KEY_FILE must be set when TLS_CERT_FILE is set")
})
t.Run("should fail when TLS key is set without cert", func(t *testing.T) {
EnvConfig = defaultConfig()
t.Setenv("DB_CONNECTION_STRING", "file:test.db")
t.Setenv("APP_URL", "http://localhost:3000")
t.Setenv("TLS_KEY", "/path/to/key.pem")
err := parseAndValidateEnvConfig(t)
require.Error(t, err)
assert.ErrorContains(t, err, "TLS_CERT_FILE must be set when TLS_KEY_FILE is set")
})
t.Run("should fail when TLS cert file does not exist", func(t *testing.T) {
EnvConfig = defaultConfig()
t.Setenv("DB_CONNECTION_STRING", "file:test.db")
t.Setenv("APP_URL", "http://localhost:3000")
t.Setenv("TLS_CERT", "/nonexistent/cert.pem")
keyFile := t.TempDir() + "/key.pem"
require.NoError(t, os.WriteFile(keyFile, []byte("key"), 0600))
t.Setenv("TLS_KEY", keyFile)
err := parseAndValidateEnvConfig(t)
require.Error(t, err)
assert.ErrorContains(t, err, "TLS_CERT_FILE not found")
})
t.Run("should fail when TLS key file does not exist", func(t *testing.T) {
EnvConfig = defaultConfig()
t.Setenv("DB_CONNECTION_STRING", "file:test.db")
t.Setenv("APP_URL", "http://localhost:3000")
certFile := t.TempDir() + "/cert.pem"
require.NoError(t, os.WriteFile(certFile, []byte("cert"), 0600))
t.Setenv("TLS_CERT", certFile)
t.Setenv("TLS_KEY", "/nonexistent/key.pem")
err := parseAndValidateEnvConfig(t)
require.Error(t, err)
assert.ErrorContains(t, err, "TLS_KEY_FILE not found")
})
}
func TestPrepareEnvConfig_FileBasedAndToLower(t *testing.T) {
@@ -272,7 +220,7 @@ func TestPrepareEnvConfig_FileBasedAndToLower(t *testing.T) {
require.NoError(t, err)
dbConnFile := tempDir + "/db_connection.txt"
dbConnContent := "postgres://user:pass@localhost/testdb" // #nosec G101 - test credential
dbConnContent := "postgres://user:pass@localhost/testdb"
err = os.WriteFile(dbConnFile, []byte(dbConnContent), 0600)
require.NoError(t, err)
@@ -306,26 +254,4 @@ func TestPrepareEnvConfig_FileBasedAndToLower(t *testing.T) {
require.NoError(t, err)
assert.Equal(t, binaryKeyContent, config.EncryptionKey)
})
t.Run("should load TLS cert and key file contents", func(t *testing.T) {
config := defaultConfig()
certFile := tempDir + "/cert.pem"
certContent := "-----BEGIN CERTIFICATE-----\ntest\n-----END CERTIFICATE-----"
err := os.WriteFile(certFile, []byte(certContent), 0600)
require.NoError(t, err)
keyFile := tempDir + "/key.pem"
keyContent := "-----BEGIN PRIVATE KEY-----\ntest\n-----END PRIVATE KEY-----"
err = os.WriteFile(keyFile, []byte(keyContent), 0600)
require.NoError(t, err)
t.Setenv("TLS_CERT_FILE", certFile)
t.Setenv("TLS_KEY_FILE", keyFile)
err = prepareEnvConfig(&config)
require.NoError(t, err)
assert.Equal(t, certContent, config.TLSCertFile)
assert.Equal(t, keyContent, config.TLSKeyFile)
})
}

View File

@@ -8,393 +8,443 @@ import (
type AppError interface {
error
HttpStatusCode() int
}
type AppErrorDescription interface {
AppError
Description() string
}
// Custom error types for various conditions
type AlreadyInUseError struct {
Property string
}
func (e AlreadyInUseError) Error() string {
func (e *AlreadyInUseError) Error() string {
return e.Property + " is already in use"
}
func (e AlreadyInUseError) HttpStatusCode() int { return http.StatusBadRequest }
func (e *AlreadyInUseError) HttpStatusCode() int { return http.StatusBadRequest }
func (e AlreadyInUseError) Is(target error) bool {
func (e *AlreadyInUseError) Is(target error) bool {
// Ignore the field property when checking if an error is of the type AlreadyInUseError
x := &AlreadyInUseError{}
return errors.As(target, &x)
}
type SetupNotAvailableError struct{}
type SetupAlreadyCompletedError struct{}
func (e SetupNotAvailableError) Error() string { return "not found" }
func (e SetupNotAvailableError) HttpStatusCode() int { return http.StatusNotFound }
func (e *SetupAlreadyCompletedError) Error() string { return "setup already completed" }
func (e *SetupAlreadyCompletedError) HttpStatusCode() int { return http.StatusConflict }
type TokenInvalidOrExpiredError struct{}
func (e TokenInvalidOrExpiredError) Error() string { return "token is invalid or expired" }
func (e TokenInvalidOrExpiredError) HttpStatusCode() int { return http.StatusUnauthorized }
func (e *TokenInvalidOrExpiredError) Error() string { return "token is invalid or expired" }
func (e *TokenInvalidOrExpiredError) HttpStatusCode() int { return http.StatusUnauthorized }
type DeviceCodeInvalid struct{}
func (e DeviceCodeInvalid) Error() string {
func (e *DeviceCodeInvalid) Error() string {
return "one time access code must be used on the device it was generated for"
}
func (e DeviceCodeInvalid) HttpStatusCode() int { return http.StatusUnauthorized }
func (e *DeviceCodeInvalid) HttpStatusCode() int { return http.StatusUnauthorized }
type TokenInvalidError struct{}
func (e TokenInvalidError) Error() string { return "Token is invalid" }
func (e TokenInvalidError) HttpStatusCode() int { return http.StatusUnauthorized }
func (e *TokenInvalidError) Error() string {
return "Token is invalid"
}
func (e *TokenInvalidError) HttpStatusCode() int { return http.StatusUnauthorized }
type OidcMissingAuthorizationError struct{}
func (e OidcMissingAuthorizationError) Error() string { return "missing authorization" }
func (e OidcMissingAuthorizationError) HttpStatusCode() int { return http.StatusForbidden }
func (e *OidcMissingAuthorizationError) Error() string { return "missing authorization" }
func (e *OidcMissingAuthorizationError) HttpStatusCode() int { return http.StatusForbidden }
type OidcGrantTypeNotSupportedError struct{}
func (e OidcGrantTypeNotSupportedError) Error() string { return "grant type not supported" }
func (e OidcGrantTypeNotSupportedError) HttpStatusCode() int { return http.StatusBadRequest }
func (e *OidcGrantTypeNotSupportedError) Error() string { return "grant type not supported" }
func (e *OidcGrantTypeNotSupportedError) HttpStatusCode() int { return http.StatusBadRequest }
type OidcMissingClientCredentialsError struct{}
func (e OidcMissingClientCredentialsError) Error() string { return "client id or secret not provided" }
func (e OidcMissingClientCredentialsError) HttpStatusCode() int { return http.StatusBadRequest }
func (e *OidcMissingClientCredentialsError) Error() string { return "client id or secret not provided" }
func (e *OidcMissingClientCredentialsError) HttpStatusCode() int { return http.StatusBadRequest }
type OidcClientSecretInvalidError struct{}
func (e OidcClientSecretInvalidError) Error() string { return "invalid client secret" }
func (e OidcClientSecretInvalidError) HttpStatusCode() int { return http.StatusUnauthorized }
func (e *OidcClientSecretInvalidError) Error() string { return "invalid client secret" }
func (e *OidcClientSecretInvalidError) HttpStatusCode() int { return http.StatusUnauthorized }
type OidcClientAssertionInvalidError struct{}
func (e OidcClientAssertionInvalidError) Error() string { return "invalid client assertion" }
func (e OidcClientAssertionInvalidError) HttpStatusCode() int { return http.StatusUnauthorized }
func (e *OidcClientAssertionInvalidError) Error() string { return "invalid client assertion" }
func (e *OidcClientAssertionInvalidError) HttpStatusCode() int { return http.StatusUnauthorized }
type OidcInvalidAuthorizationCodeError struct{}
func (e OidcInvalidAuthorizationCodeError) Error() string { return "invalid authorization code" }
func (e OidcInvalidAuthorizationCodeError) HttpStatusCode() int { return http.StatusBadRequest }
func (e *OidcInvalidAuthorizationCodeError) Error() string { return "invalid authorization code" }
func (e *OidcInvalidAuthorizationCodeError) HttpStatusCode() int { return http.StatusBadRequest }
type OidcClientNotFoundError struct{}
func (e OidcClientNotFoundError) Error() string { return "client not found" }
func (e OidcClientNotFoundError) HttpStatusCode() int { return http.StatusNotFound }
func (e *OidcClientNotFoundError) Error() string { return "client not found" }
func (e *OidcClientNotFoundError) HttpStatusCode() int { return http.StatusNotFound }
type OidcMissingCallbackURLError struct{}
func (e OidcMissingCallbackURLError) Error() string {
func (e *OidcMissingCallbackURLError) Error() string {
return "unable to detect callback url, it might be necessary for an admin to fix this"
}
func (e OidcMissingCallbackURLError) HttpStatusCode() int { return http.StatusBadRequest }
func (e *OidcMissingCallbackURLError) HttpStatusCode() int { return http.StatusBadRequest }
type OidcInvalidCallbackURLError struct{}
func (e OidcInvalidCallbackURLError) Error() string {
func (e *OidcInvalidCallbackURLError) Error() string {
return "invalid callback URL, it might be necessary for an admin to fix this"
}
func (e OidcInvalidCallbackURLError) HttpStatusCode() int { return http.StatusBadRequest }
func (e *OidcInvalidCallbackURLError) HttpStatusCode() int { return http.StatusBadRequest }
type FileTypeNotSupportedError struct{}
func (e FileTypeNotSupportedError) Error() string { return "file type not supported" }
func (e FileTypeNotSupportedError) HttpStatusCode() int { return http.StatusBadRequest }
func (e *FileTypeNotSupportedError) Error() string { return "file type not supported" }
func (e *FileTypeNotSupportedError) HttpStatusCode() int { return http.StatusBadRequest }
type FileTooLargeError struct {
MaxSize string
}
func (e FileTooLargeError) Error() string {
func (e *FileTooLargeError) Error() string {
return fmt.Sprintf("The file can't be larger than %s", e.MaxSize)
}
func (e FileTooLargeError) HttpStatusCode() int { return http.StatusRequestEntityTooLarge }
func (e *FileTooLargeError) HttpStatusCode() int { return http.StatusRequestEntityTooLarge }
type NotSignedInError struct{}
func (e NotSignedInError) Error() string { return "You are not signed in" }
func (e NotSignedInError) HttpStatusCode() int { return http.StatusUnauthorized }
func (e *NotSignedInError) Error() string { return "You are not signed in" }
func (e *NotSignedInError) HttpStatusCode() int { return http.StatusUnauthorized }
type MissingAccessToken struct{}
func (e MissingAccessToken) Error() string { return "Missing access token" }
func (e MissingAccessToken) HttpStatusCode() int { return http.StatusUnauthorized }
func (e *MissingAccessToken) Error() string { return "Missing access token" }
func (e *MissingAccessToken) HttpStatusCode() int { return http.StatusUnauthorized }
type MissingPermissionError struct{}
func (e MissingPermissionError) Error() string {
func (e *MissingPermissionError) Error() string {
return "You don't have permission to perform this action"
}
func (e MissingPermissionError) HttpStatusCode() int { return http.StatusForbidden }
func (e *MissingPermissionError) HttpStatusCode() int { return http.StatusForbidden }
type TooManyRequestsError struct{}
func (e TooManyRequestsError) Error() string { return "Too many requests" }
func (e TooManyRequestsError) HttpStatusCode() int { return http.StatusTooManyRequests }
func (e *TooManyRequestsError) Error() string {
return "Too many requests"
}
func (e *TooManyRequestsError) HttpStatusCode() int { return http.StatusTooManyRequests }
type UserIdNotProvidedError struct{}
func (e UserIdNotProvidedError) Error() string { return "User id not provided" }
func (e UserIdNotProvidedError) HttpStatusCode() int { return http.StatusBadRequest }
func (e *UserIdNotProvidedError) Error() string {
return "User id not provided"
}
func (e *UserIdNotProvidedError) HttpStatusCode() int { return http.StatusBadRequest }
type UserNotFoundError struct{}
func (e UserNotFoundError) Error() string { return "User not found" }
func (e UserNotFoundError) HttpStatusCode() int { return http.StatusNotFound }
func (e *UserNotFoundError) Error() string {
return "User not found"
}
func (e *UserNotFoundError) HttpStatusCode() int { return http.StatusNotFound }
type ClientIdOrSecretNotProvidedError struct{}
func (e ClientIdOrSecretNotProvidedError) Error() string { return "Client id or secret not provided" }
func (e ClientIdOrSecretNotProvidedError) HttpStatusCode() int { return http.StatusBadRequest }
func (e *ClientIdOrSecretNotProvidedError) Error() string {
return "Client id or secret not provided"
}
func (e *ClientIdOrSecretNotProvidedError) HttpStatusCode() int { return http.StatusBadRequest }
type WrongFileTypeError struct {
ExpectedFileType string
}
func (e WrongFileTypeError) Error() string {
func (e *WrongFileTypeError) Error() string {
return fmt.Sprintf("File must be of type %s", e.ExpectedFileType)
}
func (e WrongFileTypeError) HttpStatusCode() int { return http.StatusBadRequest }
func (e *WrongFileTypeError) HttpStatusCode() int { return http.StatusBadRequest }
type MissingSessionIdError struct{}
func (e MissingSessionIdError) Error() string { return "Missing session id" }
func (e MissingSessionIdError) HttpStatusCode() int { return http.StatusBadRequest }
func (e *MissingSessionIdError) Error() string {
return "Missing session id"
}
func (e *MissingSessionIdError) HttpStatusCode() int { return http.StatusBadRequest }
type ReservedCustomFieldError struct {
type ReservedClaimError struct {
Key string
}
func (e ReservedCustomFieldError) Error() string {
return fmt.Sprintf("Custom field %s is reserved and can't be used", e.Key)
func (e *ReservedClaimError) Error() string {
return fmt.Sprintf("Claim %s is reserved and can't be used", e.Key)
}
func (e ReservedCustomFieldError) HttpStatusCode() int { return http.StatusBadRequest }
func (e *ReservedClaimError) HttpStatusCode() int { return http.StatusBadRequest }
type DuplicateCustomFieldError struct {
type DuplicateClaimError struct {
Key string
}
func (e DuplicateCustomFieldError) Error() string {
return fmt.Sprintf("Custom field %s is already defined", e.Key)
func (e *DuplicateClaimError) Error() string {
return fmt.Sprintf("Claim %s is already defined", e.Key)
}
func (e DuplicateCustomFieldError) HttpStatusCode() int { return http.StatusBadRequest }
type CustomFieldValidationError struct {
Message string
}
func (e CustomFieldValidationError) Error() string { return e.Message }
func (e CustomFieldValidationError) HttpStatusCode() int { return http.StatusBadRequest }
func (e *DuplicateClaimError) HttpStatusCode() int { return http.StatusBadRequest }
type OidcInvalidCodeVerifierError struct{}
func (e OidcInvalidCodeVerifierError) Error() string { return "Invalid code verifier" }
func (e OidcInvalidCodeVerifierError) HttpStatusCode() int { return http.StatusBadRequest }
func (e *OidcInvalidCodeVerifierError) Error() string {
return "Invalid code verifier"
}
func (e *OidcInvalidCodeVerifierError) HttpStatusCode() int { return http.StatusBadRequest }
type OidcMissingCodeChallengeError struct{}
func (e OidcMissingCodeChallengeError) Error() string { return "Missing code challenge" }
func (e OidcMissingCodeChallengeError) HttpStatusCode() int { return http.StatusBadRequest }
func (e *OidcMissingCodeChallengeError) Error() string {
return "Missing code challenge"
}
func (e *OidcMissingCodeChallengeError) HttpStatusCode() int { return http.StatusBadRequest }
type LdapUserUpdateError struct{}
func (e LdapUserUpdateError) Error() string { return "LDAP users can't be updated" }
func (e LdapUserUpdateError) HttpStatusCode() int { return http.StatusForbidden }
func (e *LdapUserUpdateError) Error() string {
return "LDAP users can't be updated"
}
func (e *LdapUserUpdateError) HttpStatusCode() int { return http.StatusForbidden }
type LdapUserGroupUpdateError struct{}
func (e LdapUserGroupUpdateError) Error() string { return "LDAP user groups can't be updated" }
func (e LdapUserGroupUpdateError) HttpStatusCode() int { return http.StatusForbidden }
func (e *LdapUserGroupUpdateError) Error() string {
return "LDAP user groups can't be updated"
}
func (e *LdapUserGroupUpdateError) HttpStatusCode() int { return http.StatusForbidden }
type OidcAccessDeniedError struct{}
func (e OidcAccessDeniedError) Error() string { return "You're not allowed to access this service" }
func (e OidcAccessDeniedError) HttpStatusCode() int { return http.StatusForbidden }
func (e *OidcAccessDeniedError) Error() string {
return "You're not allowed to access this service"
}
func (e *OidcAccessDeniedError) HttpStatusCode() int { return http.StatusForbidden }
type OidcClientIdNotMatchingError struct{}
func (e OidcClientIdNotMatchingError) Error() string {
func (e *OidcClientIdNotMatchingError) Error() string {
return "Client id in request doesn't match client id in token"
}
func (e OidcClientIdNotMatchingError) HttpStatusCode() int { return http.StatusBadRequest }
func (e *OidcClientIdNotMatchingError) HttpStatusCode() int { return http.StatusBadRequest }
type OidcNoCallbackURLError struct{}
func (e OidcNoCallbackURLError) Error() string {
func (e *OidcNoCallbackURLError) Error() string {
return "No callback URL provided"
}
func (e OidcNoCallbackURLError) HttpStatusCode() int { return http.StatusBadRequest }
func (e *OidcNoCallbackURLError) HttpStatusCode() int { return http.StatusBadRequest }
type UiConfigDisabledError struct{}
func (e UiConfigDisabledError) Error() string {
func (e *UiConfigDisabledError) Error() string {
return "The configuration can't be changed since the UI configuration is disabled"
}
func (e UiConfigDisabledError) HttpStatusCode() int { return http.StatusForbidden }
func (e *UiConfigDisabledError) HttpStatusCode() int { return http.StatusForbidden }
type InvalidUUIDError struct{}
func (e InvalidUUIDError) Error() string { return "Invalid UUID" }
func (e InvalidUUIDError) HttpStatusCode() int { return http.StatusBadRequest }
func (e *InvalidUUIDError) Error() string {
return "Invalid UUID"
}
func (e *InvalidUUIDError) HttpStatusCode() int { return http.StatusBadRequest }
type OneTimeAccessDisabledError struct{}
func (e OneTimeAccessDisabledError) Error() string { return "One-time access is disabled" }
func (e OneTimeAccessDisabledError) HttpStatusCode() int { return http.StatusBadRequest }
func (e *OneTimeAccessDisabledError) Error() string {
return "One-time access is disabled"
}
func (e *OneTimeAccessDisabledError) HttpStatusCode() int { return http.StatusBadRequest }
type InvalidAPIKeyError struct{}
func (e InvalidAPIKeyError) Error() string { return "Invalid Api Key" }
func (e InvalidAPIKeyError) HttpStatusCode() int { return http.StatusUnauthorized }
func (e *InvalidAPIKeyError) Error() string {
return "Invalid Api Key"
}
func (e *InvalidAPIKeyError) HttpStatusCode() int { return http.StatusUnauthorized }
type NoAPIKeyProvidedError struct{}
func (e NoAPIKeyProvidedError) Error() string { return "No API Key Provided" }
func (e NoAPIKeyProvidedError) HttpStatusCode() int { return http.StatusUnauthorized }
func (e *NoAPIKeyProvidedError) Error() string {
return "No API Key Provided"
}
func (e *NoAPIKeyProvidedError) HttpStatusCode() int { return http.StatusUnauthorized }
type APIKeyNotFoundError struct{}
func (e APIKeyNotFoundError) Error() string { return "API Key Not Found" }
func (e APIKeyNotFoundError) HttpStatusCode() int { return http.StatusUnauthorized }
func (e *APIKeyNotFoundError) Error() string {
return "API Key Not Found"
}
func (e *APIKeyNotFoundError) HttpStatusCode() int { return http.StatusUnauthorized }
type APIKeyNotExpiredError struct{}
func (e APIKeyNotExpiredError) Error() string { return "API Key is not expired yet" }
func (e APIKeyNotExpiredError) HttpStatusCode() int { return http.StatusBadRequest }
func (e *APIKeyNotExpiredError) Error() string {
return "API Key is not expired yet"
}
func (e *APIKeyNotExpiredError) HttpStatusCode() int { return http.StatusBadRequest }
type APIKeyExpirationDateError struct{}
func (e APIKeyExpirationDateError) Error() string {
func (e *APIKeyExpirationDateError) Error() string {
return "API Key expiration time must be in the future"
}
func (e APIKeyExpirationDateError) HttpStatusCode() int { return http.StatusBadRequest }
func (e *APIKeyExpirationDateError) HttpStatusCode() int { return http.StatusBadRequest }
type APIKeyAuthNotAllowedError struct{}
func (e APIKeyAuthNotAllowedError) Error() string {
func (e *APIKeyAuthNotAllowedError) Error() string {
return "API key authentication is not allowed for this endpoint"
}
func (e APIKeyAuthNotAllowedError) HttpStatusCode() int { return http.StatusForbidden }
func (e *APIKeyAuthNotAllowedError) HttpStatusCode() int { return http.StatusForbidden }
type OidcInvalidRefreshTokenError struct{}
func (e OidcInvalidRefreshTokenError) Error() string { return "refresh token is invalid or expired" }
func (e OidcInvalidRefreshTokenError) HttpStatusCode() int { return http.StatusBadRequest }
func (e *OidcInvalidRefreshTokenError) Error() string {
return "refresh token is invalid or expired"
}
func (e *OidcInvalidRefreshTokenError) HttpStatusCode() int {
return http.StatusBadRequest
}
type OidcMissingRefreshTokenError struct{}
func (e OidcMissingRefreshTokenError) Error() string { return "refresh token is required" }
func (e OidcMissingRefreshTokenError) HttpStatusCode() int { return http.StatusBadRequest }
func (e *OidcMissingRefreshTokenError) Error() string {
return "refresh token is required"
}
func (e *OidcMissingRefreshTokenError) HttpStatusCode() int {
return http.StatusBadRequest
}
type OidcMissingAuthorizationCodeError struct{}
func (e OidcMissingAuthorizationCodeError) Error() string { return "authorization code is required" }
func (e OidcMissingAuthorizationCodeError) HttpStatusCode() int { return http.StatusBadRequest }
func (e *OidcMissingAuthorizationCodeError) Error() string {
return "authorization code is required"
}
func (e *OidcMissingAuthorizationCodeError) HttpStatusCode() int {
return http.StatusBadRequest
}
type UserDisabledError struct{}
func (e UserDisabledError) Error() string { return "User account is disabled" }
func (e UserDisabledError) HttpStatusCode() int { return http.StatusForbidden }
func (e *UserDisabledError) Error() string {
return "User account is disabled"
}
func (e *UserDisabledError) HttpStatusCode() int {
return http.StatusForbidden
}
type ValidationError struct{ Message string }
type ValidationError struct {
Message string
}
func (e ValidationError) Error() string { return e.Message }
func (e *ValidationError) Error() string {
return e.Message
}
func (e ValidationError) HttpStatusCode() int { return http.StatusBadRequest }
func (e *ValidationError) HttpStatusCode() int {
return http.StatusBadRequest
}
type OidcDeviceCodeExpiredError struct{}
func (e OidcDeviceCodeExpiredError) Error() string { return "device code has expired" }
func (e OidcDeviceCodeExpiredError) HttpStatusCode() int { return http.StatusBadRequest }
func (e *OidcDeviceCodeExpiredError) Error() string {
return "device code has expired"
}
func (e *OidcDeviceCodeExpiredError) HttpStatusCode() int {
return http.StatusBadRequest
}
type OidcInvalidDeviceCodeError struct{}
func (e OidcInvalidDeviceCodeError) Error() string { return "invalid device code" }
func (e OidcInvalidDeviceCodeError) HttpStatusCode() int { return http.StatusBadRequest }
func (e *OidcInvalidDeviceCodeError) Error() string {
return "invalid device code"
}
func (e *OidcInvalidDeviceCodeError) HttpStatusCode() int {
return http.StatusBadRequest
}
type OidcSlowDownError struct{}
func (e OidcSlowDownError) Error() string { return "polling too frequently" }
func (e OidcSlowDownError) HttpStatusCode() int { return http.StatusTooManyRequests }
func (e *OidcSlowDownError) Error() string {
return "polling too frequently"
}
func (e *OidcSlowDownError) HttpStatusCode() int {
return http.StatusTooManyRequests
}
type OidcAuthorizationPendingError struct{}
func (e OidcAuthorizationPendingError) Error() string { return "authorization is still pending" }
func (e OidcAuthorizationPendingError) HttpStatusCode() int { return http.StatusBadRequest }
func (e *OidcAuthorizationPendingError) Error() string {
return "authorization is still pending"
}
func (e *OidcAuthorizationPendingError) HttpStatusCode() int {
return http.StatusBadRequest
}
type ReauthenticationRequiredError struct{}
func (e ReauthenticationRequiredError) Error() string { return "reauthentication required" }
func (e ReauthenticationRequiredError) HttpStatusCode() int { return http.StatusUnauthorized }
func (e *ReauthenticationRequiredError) Error() string {
return "reauthentication required"
}
func (e *ReauthenticationRequiredError) HttpStatusCode() int {
return http.StatusUnauthorized
}
type OpenSignupDisabledError struct{}
func (e OpenSignupDisabledError) Error() string { return "Open user signup is not enabled" }
func (e *OpenSignupDisabledError) Error() string {
return "Open user signup is not enabled"
}
func (e OpenSignupDisabledError) HttpStatusCode() int { return http.StatusForbidden }
func (e *OpenSignupDisabledError) HttpStatusCode() int {
return http.StatusForbidden
}
type ClientIdAlreadyExistsError struct{}
func (e ClientIdAlreadyExistsError) Error() string { return "Client ID already in use" }
func (e *ClientIdAlreadyExistsError) Error() string {
return "Client ID already in use"
}
func (e ClientIdAlreadyExistsError) HttpStatusCode() int { return http.StatusBadRequest }
func (e *ClientIdAlreadyExistsError) HttpStatusCode() int {
return http.StatusBadRequest
}
type UserEmailNotSetError struct{}
func (e UserEmailNotSetError) Error() string { return "The user does not have an email address set" }
func (e *UserEmailNotSetError) Error() string {
return "The user does not have an email address set"
}
func (e UserEmailNotSetError) HttpStatusCode() int { return http.StatusBadRequest }
func (e *UserEmailNotSetError) HttpStatusCode() int {
return http.StatusBadRequest
}
type ImageNotFoundError struct{}
func (e ImageNotFoundError) Error() string { return "Image not found" }
func (e *ImageNotFoundError) Error() string {
return "Image not found"
}
func (e ImageNotFoundError) HttpStatusCode() int { return http.StatusNotFound }
func (e *ImageNotFoundError) HttpStatusCode() int {
return http.StatusNotFound
}
type InvalidEmailVerificationTokenError struct{}
func (e InvalidEmailVerificationTokenError) Error() string { return "Invalid email verification token" }
func (e InvalidEmailVerificationTokenError) HttpStatusCode() int { return http.StatusBadRequest }
// OIDC prompt parameter errors - used for redirect error responses
type OidcLoginRequiredError struct{}
func (e OidcLoginRequiredError) Error() string { return "login_required" }
func (e OidcLoginRequiredError) HttpStatusCode() int { return http.StatusBadRequest }
type OidcConsentRequiredError struct{}
func (e OidcConsentRequiredError) Error() string { return "consent_required" }
func (e OidcConsentRequiredError) HttpStatusCode() int { return http.StatusBadRequest }
type OidcInteractionRequiredError struct{}
func (e OidcInteractionRequiredError) Error() string { return "interaction_required" }
func (e OidcInteractionRequiredError) HttpStatusCode() int { return http.StatusBadRequest }
type OidcInvalidRequestError struct{ description string }
func NewOidcInvalidRequestError(description string) *OidcInvalidRequestError {
return &OidcInvalidRequestError{description: description}
func (e *InvalidEmailVerificationTokenError) Error() string {
return "Invalid email verification token"
}
func (e OidcInvalidRequestError) Error() string { return "invalid_request" }
func (e OidcInvalidRequestError) HttpStatusCode() int { return http.StatusBadRequest }
func (e OidcInvalidRequestError) Description() string { return e.description }
type OidcAccountSelectionRequiredError struct{}
func (e OidcAccountSelectionRequiredError) Error() string { return "account_selection_required" }
func (e OidcAccountSelectionRequiredError) HttpStatusCode() int { return http.StatusBadRequest }
func (e *InvalidEmailVerificationTokenError) HttpStatusCode() int {
return http.StatusBadRequest
}

View File

@@ -49,7 +49,7 @@ type AppConfigController struct {
// @Accept json
// @Produce json
// @Success 200 {array} dto.PublicAppConfigVariableDto
// @Router /api/application-configuration [get]
// @Router /application-configuration [get]
func (acc *AppConfigController) listAppConfigHandler(c *gin.Context) {
configuration := acc.appConfigService.ListAppConfig(false)
@@ -76,7 +76,7 @@ func (acc *AppConfigController) listAppConfigHandler(c *gin.Context) {
// @Accept json
// @Produce json
// @Success 200 {array} dto.AppConfigVariableDto
// @Router /api/application-configuration/all [get]
// @Router /application-configuration/all [get]
func (acc *AppConfigController) listAllAppConfigHandler(c *gin.Context) {
configuration := acc.appConfigService.ListAppConfig(true)

View File

@@ -63,7 +63,6 @@ func (alc *AuditLogController) listAuditLogsForUserHandler(c *gin.Context) {
// Add device information to the logs
for i, logsDto := range logsDtos {
logsDto.Device = alc.auditLogService.DeviceStringFromUserAgent(logs[i].UserAgent)
logsDto.ActorUsername = logsDto.Data["actorUsername"]
logsDtos[i] = logsDto
}
@@ -102,7 +101,6 @@ func (alc *AuditLogController) listAllAuditLogsHandler(c *gin.Context) {
for i, logsDto := range logsDtos {
logsDto.Device = alc.auditLogService.DeviceStringFromUserAgent(logs[i].UserAgent)
logsDto.Username = logs[i].User.Username
logsDto.ActorUsername = logsDto.Data["actorUsername"]
logsDtos[i] = logsDto
}

View File

@@ -0,0 +1,115 @@
package controller
import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/pocket-id/pocket-id/backend/internal/dto"
"github.com/pocket-id/pocket-id/backend/internal/middleware"
"github.com/pocket-id/pocket-id/backend/internal/service"
)
// NewCustomClaimController creates a new controller for custom claim management
// @Summary Custom claim management controller
// @Description Initializes all custom claim-related API endpoints
// @Tags Custom Claims
func NewCustomClaimController(group *gin.RouterGroup, authMiddleware *middleware.AuthMiddleware, customClaimService *service.CustomClaimService) {
wkc := &CustomClaimController{customClaimService: customClaimService}
customClaimsGroup := group.Group("/custom-claims")
customClaimsGroup.Use(authMiddleware.Add())
{
customClaimsGroup.GET("/suggestions", wkc.getSuggestionsHandler)
customClaimsGroup.PUT("/user/:userId", wkc.UpdateCustomClaimsForUserHandler)
customClaimsGroup.PUT("/user-group/:userGroupId", wkc.UpdateCustomClaimsForUserGroupHandler)
}
}
type CustomClaimController struct {
customClaimService *service.CustomClaimService
}
// getSuggestionsHandler godoc
// @Summary Get custom claim suggestions
// @Description Get a list of suggested custom claim names
// @Tags Custom Claims
// @Produce json
// @Success 200 {array} string "List of suggested custom claim names"
// @Router /api/custom-claims/suggestions [get]
func (ccc *CustomClaimController) getSuggestionsHandler(c *gin.Context) {
claims, err := ccc.customClaimService.GetSuggestions(c.Request.Context())
if err != nil {
_ = c.Error(err)
return
}
c.JSON(http.StatusOK, claims)
}
// UpdateCustomClaimsForUserHandler godoc
// @Summary Update custom claims for a user
// @Description Update or create custom claims for a specific user
// @Tags Custom Claims
// @Accept json
// @Produce json
// @Param userId path string true "User ID"
// @Param claims body []dto.CustomClaimCreateDto true "List of custom claims to set for the user"
// @Success 200 {array} dto.CustomClaimDto "Updated custom claims"
// @Router /api/custom-claims/user/{userId} [put]
func (ccc *CustomClaimController) UpdateCustomClaimsForUserHandler(c *gin.Context) {
var input []dto.CustomClaimCreateDto
if err := dto.ShouldBindWithNormalizedJSON(c, &input); err != nil {
_ = c.Error(err)
return
}
userId := c.Param("userId")
claims, err := ccc.customClaimService.UpdateCustomClaimsForUser(c.Request.Context(), userId, input)
if err != nil {
_ = c.Error(err)
return
}
var customClaimsDto []dto.CustomClaimDto
if err := dto.MapStructList(claims, &customClaimsDto); err != nil {
_ = c.Error(err)
return
}
c.JSON(http.StatusOK, customClaimsDto)
}
// UpdateCustomClaimsForUserGroupHandler godoc
// @Summary Update custom claims for a user group
// @Description Update or create custom claims for a specific user group
// @Tags Custom Claims
// @Accept json
// @Produce json
// @Param userGroupId path string true "User Group ID"
// @Param claims body []dto.CustomClaimCreateDto true "List of custom claims to set for the user group"
// @Success 200 {array} dto.CustomClaimDto "Updated custom claims"
// @Router /api/custom-claims/user-group/{userGroupId} [put]
func (ccc *CustomClaimController) UpdateCustomClaimsForUserGroupHandler(c *gin.Context) {
var input []dto.CustomClaimCreateDto
if err := dto.ShouldBindWithNormalizedJSON(c, &input); err != nil {
_ = c.Error(err)
return
}
userGroupId := c.Param("userGroupId")
claims, err := ccc.customClaimService.UpdateCustomClaimsForUserGroup(c.Request.Context(), userGroupId, input)
if err != nil {
_ = c.Error(err)
return
}
var customClaimsDto []dto.CustomClaimDto
if err := dto.MapStructList(claims, &customClaimsDto); err != nil {
_ = c.Error(err)
return
}
c.JSON(http.StatusOK, customClaimsDto)
}

View File

@@ -89,29 +89,13 @@ type OidcController struct {
// @Router /api/oidc/authorize [post]
func (oc *OidcController) authorizeHandler(c *gin.Context) {
var input dto.AuthorizeOidcClientRequestDto
err := c.ShouldBindJSON(&input)
if err != nil {
if err := c.ShouldBindJSON(&input); err != nil {
_ = c.Error(err)
return
}
code, callbackURL, err := oc.oidcService.Authorize(
c.Request.Context(),
input,
c.GetString("userID"),
c.GetString("authenticationMethod"),
c.ClientIP(),
c.Request.UserAgent(),
)
code, callbackURL, err := oc.oidcService.Authorize(c.Request.Context(), input, c.GetString("userID"), c.ClientIP(), c.Request.UserAgent())
if err != nil {
// Check if this is a prompt-related error that should be returned as a redirect error
if isOidcPromptError(err) {
c.JSON(http.StatusOK, gin.H{
"error": err.Error(),
"requiresRedirect": true,
})
return
}
_ = c.Error(err)
return
}
@@ -125,19 +109,6 @@ func (oc *OidcController) authorizeHandler(c *gin.Context) {
c.JSON(http.StatusOK, response)
}
// isOidcPromptError checks if an error is a prompt-related OIDC error that should trigger a redirect
func isOidcPromptError(err error) bool {
var loginReq *common.OidcLoginRequiredError
var consentReq *common.OidcConsentRequiredError
var interactionReq *common.OidcInteractionRequiredError
var accountSelectionReq *common.OidcAccountSelectionRequiredError
return errors.As(err, &loginReq) ||
errors.As(err, &consentReq) ||
errors.As(err, &interactionReq) ||
errors.As(err, &accountSelectionReq)
}
// authorizationConfirmationRequiredHandler godoc
// @Summary Check if authorization confirmation is required
// @Description Check if the user needs to confirm authorization for the client
@@ -202,8 +173,10 @@ func (oc *OidcController) createTokensHandler(c *gin.Context) {
return
}
// Check if the client ID / secret are passed in the Authorization header (RFC 6749)
parseBasicAuth(c.Request, &input.ClientSecret, &input.ClientID)
// Client id and secret can also be passed over the Authorization header
if input.ClientID == "" && input.ClientSecret == "" {
input.ClientID, input.ClientSecret, _ = utils.OAuthClientBasicAuth(c.Request)
}
tokens, err := oc.createTokens(c.Request.Context(), input)
@@ -311,12 +284,6 @@ func (oc *OidcController) EndSessionHandler(c *gin.Context) {
// The validation was successful, so we can log out and redirect the user to the callback URL without confirmation
cookie.AddAccessTokenCookie(c, 0, "")
// Callback URL can be empty if none is configured
if callbackURL == "" {
c.Redirect(http.StatusFound, common.EnvConfig.AppURL+"/logout")
return
}
logoutCallbackURL, _ := url.Parse(callbackURL)
if input.State != "" {
q := logoutCallbackURL.Query()
@@ -696,22 +663,6 @@ func (oc *OidcController) updateAllowedUserGroupsHandler(c *gin.Context) {
c.JSON(http.StatusOK, oidcClientDto)
}
// This method can modify the value of clientSecret and clientID
func parseBasicAuth(r *http.Request, clientSecret *string, clientID *string) {
// Client id and secret can also be passed via the Authorization header (RFC 6749, section 2.3.1 "client_secret_basic")
// When PKCE is used, some libraries send client_id in the body for the code_verifier binding while keeping the secret only in the Authorization header
// We therefore fall back to Basic auth whenever the secret is missing, not only when both fields are empty
if *clientSecret == "" {
basicID, basicSecret, ok := utils.OAuthClientBasicAuth(r)
if ok {
if *clientID == "" {
*clientID = basicID
}
*clientSecret = basicSecret
}
}
}
func (oc *OidcController) deviceAuthorizationHandler(c *gin.Context) {
// Per RFC 8628 (OAuth 2.0 Device Authorization Grant), parameters for the device authorization request MUST be sent in the body of the POST request
// Gin's "ShouldBind" by default reads from the query string too, so we need to reset all query string args before invoking ShouldBind
@@ -724,8 +675,10 @@ func (oc *OidcController) deviceAuthorizationHandler(c *gin.Context) {
return
}
// Check if the client ID / secret are passed in the Authorization header (RFC 6749)
parseBasicAuth(c.Request, &input.ClientSecret, &input.ClientID)
// Client id and secret can also be passed over the Authorization header
if input.ClientID == "" && input.ClientSecret == "" {
input.ClientID, input.ClientSecret, _ = utils.OAuthClientBasicAuth(c.Request)
}
response, err := oc.oidcService.CreateDeviceAuthorization(c.Request.Context(), input)
if err != nil {
@@ -848,14 +801,7 @@ func (oc *OidcController) verifyDeviceCodeHandler(c *gin.Context) {
ipAddress := c.ClientIP()
userAgent := c.Request.UserAgent()
err := oc.oidcService.VerifyDeviceCode(
c.Request.Context(),
userCode,
c.GetString("userID"),
c.GetString("authenticationMethod"),
ipAddress,
userAgent)
err := oc.oidcService.VerifyDeviceCode(c.Request.Context(), userCode, c.GetString("userID"), ipAddress, userAgent)
if err != nil {
_ = c.Error(err)
return
@@ -911,13 +857,7 @@ func (oc *OidcController) getClientPreviewHandler(c *gin.Context) {
return
}
preview, err := oc.oidcService.GetClientPreview(
c.Request.Context(),
clientID,
userID,
strings.Split(scopes, " "),
c.GetString("authenticationMethod"))
preview, err := oc.oidcService.GetClientPreview(c.Request.Context(), clientID, userID, strings.Split(scopes, " "))
if err != nil {
_ = c.Error(err)
return

View File

@@ -146,43 +146,6 @@ func TestCreateTokensHandler(t *testing.T) {
assert.Equal(t, 120, response.ExpiresIn)
})
t.Run("Uses Basic Auth Secret When Body Has Only Client ID (PKCE)", func(t *testing.T) {
// Some OIDC libraries (e.g. jumbojett/openid-connect-php) combine client_secret_basic with PKCE by sending client_id in the body alongside code_verifier while keeping the secret only in the Authorization header
// RFC 6749 §2.3.1 permits this; the body client_id must not block the Basic auth fallback for the secret.
var capturedInput dto.OidcCreateTokensDto
oc := &OidcController{
createTokens: func(_ context.Context, input dto.OidcCreateTokensDto) (service.CreatedTokens, error) {
capturedInput = input
return service.CreatedTokens{
AccessToken: "access-token",
IdToken: "id-token",
RefreshToken: "refresh-token",
ExpiresIn: 2 * time.Minute,
}, nil
},
}
basicAuth := "Basic " + base64.StdEncoding.EncodeToString([]byte("client-id:client-secret"))
c, recorder := createTestContext(
t,
"http://example.com/oidc/token",
url.Values{
"grant_type": {service.GrantTypeRefreshToken},
"refresh_token": {"input-refresh-token"},
"client_id": {"client-id"},
},
basicAuth,
false,
)
oc.createTokensHandler(c)
require.Empty(t, c.Errors)
assert.Equal(t, "client-id", capturedInput.ClientID)
assert.Equal(t, "client-secret", capturedInput.ClientSecret)
require.Equal(t, http.StatusOK, recorder.Code)
})
t.Run("Maps Authorization Pending Error", func(t *testing.T) {
oc := &OidcController{
createTokens: func(context.Context, dto.OidcCreateTokensDto) (service.CreatedTokens, error) {

View File

@@ -21,11 +21,10 @@ const defaultOneTimeAccessTokenDuration = 15 * time.Minute
// @Summary User management controller
// @Description Initializes all user-related API endpoints
// @Tags Users
func NewUserController(group *gin.RouterGroup, authMiddleware *middleware.AuthMiddleware, rateLimitMiddleware *middleware.RateLimitMiddleware, userService *service.UserService, oneTimeAccessService *service.OneTimeAccessService, webAuthnService *service.WebAuthnService, appConfigService *service.AppConfigService) {
func NewUserController(group *gin.RouterGroup, authMiddleware *middleware.AuthMiddleware, rateLimitMiddleware *middleware.RateLimitMiddleware, userService *service.UserService, oneTimeAccessService *service.OneTimeAccessService, appConfigService *service.AppConfigService) {
uc := UserController{
userService: userService,
oneTimeAccessService: oneTimeAccessService,
webAuthnService: webAuthnService,
appConfigService: appConfigService,
}
@@ -35,10 +34,8 @@ func NewUserController(group *gin.RouterGroup, authMiddleware *middleware.AuthMi
group.POST("/users", authMiddleware.Add(), uc.createUserHandler)
group.PUT("/users/:id", authMiddleware.Add(), uc.updateUserHandler)
group.GET("/users/:id/groups", authMiddleware.Add(), uc.getUserGroupsHandler)
group.GET("/users/:id/webauthn-credentials", authMiddleware.Add(), uc.listUserWebauthnCredentialsHandler)
group.PUT("/users/me", authMiddleware.WithAdminNotRequired().Add(), uc.updateCurrentUserHandler)
group.DELETE("/users/:id", authMiddleware.Add(), uc.deleteUserHandler)
group.DELETE("/users/:id/webauthn-credentials/:credentialId", authMiddleware.Add(), uc.deleteUserWebauthnCredentialHandler)
group.PUT("/users/:id/user-groups", authMiddleware.Add(), uc.updateUserGroups)
@@ -63,7 +60,6 @@ func NewUserController(group *gin.RouterGroup, authMiddleware *middleware.AuthMi
type UserController struct {
userService *service.UserService
oneTimeAccessService *service.OneTimeAccessService
webAuthnService *service.WebAuthnService
appConfigService *service.AppConfigService
}
@@ -91,36 +87,6 @@ func (uc *UserController) getUserGroupsHandler(c *gin.Context) {
c.JSON(http.StatusOK, groupsDto)
}
// listUserWebauthnCredentialsHandler godoc
// @Summary List user passkeys
// @Description Retrieve all WebAuthn credentials for a specific user
// @Tags Users
// @Param id path string true "User ID"
// @Success 200 {array} dto.WebauthnCredentialDto
// @Router /api/users/{id}/webauthn-credentials [get]
func (uc *UserController) listUserWebauthnCredentialsHandler(c *gin.Context) {
userID := c.Param("id")
if _, err := uc.userService.GetUser(c.Request.Context(), userID); err != nil {
_ = c.Error(err)
return
}
credentials, err := uc.webAuthnService.ListCredentials(c.Request.Context(), userID)
if err != nil {
_ = c.Error(err)
return
}
var credentialDtos []dto.WebauthnCredentialDto
if err := dto.MapStructList(credentials, &credentialDtos); err != nil {
_ = c.Error(err)
return
}
c.JSON(http.StatusOK, credentialDtos)
}
// listUsersHandler godoc
// @Summary List users
// @Description Get a paginated list of users with optional search and sorting
@@ -215,31 +181,6 @@ func (uc *UserController) deleteUserHandler(c *gin.Context) {
c.Status(http.StatusNoContent)
}
// deleteUserWebauthnCredentialHandler godoc
// @Summary Delete user passkey
// @Description Delete a specific WebAuthn credential for a user
// @Tags Users
// @Param id path string true "User ID"
// @Param credentialId path string true "Credential ID"
// @Success 204 "No Content"
// @Router /api/users/{id}/webauthn-credentials/{credentialId} [delete]
func (uc *UserController) deleteUserWebauthnCredentialHandler(c *gin.Context) {
err := uc.webAuthnService.DeleteCredential(
c.Request.Context(),
c.Param("id"),
c.Param("credentialId"),
c.ClientIP(),
c.Request.UserAgent(),
c.GetString("userID"),
)
if err != nil {
_ = c.Error(err)
return
}
c.Status(http.StatusNoContent)
}
// createUserHandler godoc
// @Summary Create user
// @Description Create a new user

View File

@@ -7,7 +7,6 @@ import (
"github.com/pocket-id/pocket-id/backend/internal/utils/cookie"
"github.com/gin-gonic/gin"
"github.com/pocket-id/pocket-id/backend/internal/common"
"github.com/pocket-id/pocket-id/backend/internal/dto"
"github.com/pocket-id/pocket-id/backend/internal/middleware"
"github.com/pocket-id/pocket-id/backend/internal/service"
@@ -31,7 +30,6 @@ func NewUserSignupController(group *gin.RouterGroup, authMiddleware *middleware.
group.GET("/signup-tokens", authMiddleware.Add(), usc.listSignupTokensHandler)
group.DELETE("/signup-tokens/:id", authMiddleware.Add(), usc.deleteSignupTokenHandler)
group.POST("/signup", rateLimitMiddleware.Add(rate.Every(1*time.Minute), 10), usc.signupHandler)
group.GET("/signup/setup", usc.checkInitialAdminSetupAvailable)
group.POST("/signup/setup", usc.signUpInitialAdmin)
}
@@ -41,21 +39,6 @@ type UserSignupController struct {
appConfigService *service.AppConfigService
}
func (usc *UserSignupController) checkInitialAdminSetupAvailable(c *gin.Context) {
setupCompleted, err := usc.userSignUpService.IsInitialAdminSetupCompleted(c.Request.Context())
if err != nil {
_ = c.Error(err)
return
}
if setupCompleted {
_ = c.Error(&common.SetupNotAvailableError{})
return
}
c.Status(http.StatusNoContent)
}
// signUpInitialAdmin godoc
// @Summary Sign up initial admin user
// @Description Sign up and generate setup access token for initial admin user

View File

@@ -137,7 +137,7 @@ func (wc *WebauthnController) deleteCredentialHandler(c *gin.Context) {
clientIP := c.ClientIP()
userAgent := c.Request.UserAgent()
err := wc.webAuthnService.DeleteCredential(c.Request.Context(), userID, credentialID, clientIP, userAgent, userID)
err := wc.webAuthnService.DeleteCredential(c.Request.Context(), userID, credentialID, clientIP, userAgent)
if err != nil {
_ = c.Error(err)
return

View File

@@ -91,7 +91,6 @@ func (wkc *WellKnownController) computeOIDCConfiguration() ([]byte, error) {
"id_token_signing_alg_values_supported": []string{alg.String()},
"authorization_response_iss_parameter_supported": true,
"code_challenge_methods_supported": []string{"plain", "S256"},
"prompt_values_supported": []string{"none", "login", "consent", "select_account"},
"token_endpoint_auth_methods_supported": []string{"client_secret_basic", "client_secret_post", "none"},
}
return json.Marshal(config)

View File

@@ -20,7 +20,7 @@ type AppConfigUpdateDto struct {
AllowOwnAccountEdit string `json:"allowOwnAccountEdit" binding:"required"`
AllowUserSignups string `json:"allowUserSignups" binding:"required,oneof=disabled withToken open"`
SignupDefaultUserGroupIDs string `json:"signupDefaultUserGroupIDs" binding:"omitempty,json"`
CustomFields string `json:"customFields" binding:"omitempty,json"`
SignupDefaultCustomClaims string `json:"signupDefaultCustomClaims" binding:"omitempty,json"`
AccentColor string `json:"accentColor"`
RequireUserEmail string `json:"requireUserEmail" binding:"required"`
SmtpHost string `json:"smtpHost"`

View File

@@ -8,13 +8,12 @@ type AuditLogDto struct {
ID string `json:"id"`
CreatedAt datatype.DateTime `json:"createdAt"`
Event string `json:"event"`
IpAddress string `json:"ipAddress"`
Country string `json:"country"`
City string `json:"city"`
Device string `json:"device"`
UserID string `json:"userID"`
Username string `json:"username"`
ActorUsername string `json:"actorUsername"`
Data map[string]string `json:"data"`
Event string `json:"event"`
IpAddress string `json:"ipAddress"`
Country string `json:"country"`
City string `json:"city"`
Device string `json:"device"`
UserID string `json:"userID"`
Username string `json:"username"`
Data map[string]string `json:"data"`
}

View File

@@ -0,0 +1,11 @@
package dto
type CustomClaimDto struct {
Key string `json:"key"`
Value string `json:"value"`
}
type CustomClaimCreateDto struct {
Key string `json:"key" binding:"required" unorm:"nfc"`
Value string `json:"value" binding:"required" unorm:"nfc"`
}

View File

@@ -1,42 +0,0 @@
package dto
type CustomFieldValueDto struct {
CustomFieldID string `json:"customFieldId"`
Key string `json:"key,omitempty"`
Value string `json:"value"`
}
type CustomFieldValueCreateDto struct {
CustomFieldID string `json:"customFieldId" binding:"required_without=Key" unorm:"nfc"`
Key string `json:"key,omitempty" unorm:"nfc"`
Value string `json:"value" unorm:"nfc"`
}
type CustomFieldType string
const (
CustomFieldTypeString CustomFieldType = "string"
CustomFieldTypeNumber CustomFieldType = "number"
CustomFieldTypeBoolean CustomFieldType = "boolean"
)
type CustomFieldTarget string
const (
CustomFieldTargetUser CustomFieldTarget = "user"
CustomFieldTargetGroup CustomFieldTarget = "group"
CustomFieldTargetBoth CustomFieldTarget = "both"
)
type CustomFieldDto struct {
ID string `json:"id" binding:"required,uuid"`
Key string `json:"key" binding:"required" unorm:"nfc"`
DisplayName string `json:"displayName" binding:"required" unorm:"nfc"`
Type CustomFieldType `json:"type" binding:"required,oneof=string number boolean"`
Target CustomFieldTarget `json:"target" binding:"required,oneof=user group both"`
Required bool `json:"required"`
UserEditable bool `json:"userEditable"`
DefaultValue string `json:"defaultValue" unorm:"nfc"`
ValidationRegex string `json:"validationRegex" binding:"regex" unorm:"nfc"`
ValidationErrorMessage string `json:"validationErrorMessage" unorm:"nfc"`
}

View File

@@ -33,8 +33,8 @@ type OidcClientWithAllowedGroupsCountDto struct {
type OidcClientUpdateDto struct {
Name string `json:"name" binding:"required,max=50" unorm:"nfc"`
CallbackURLs []string `json:"callbackURLs" binding:"omitempty,dive,callback_url_pattern"`
LogoutCallbackURLs []string `json:"logoutCallbackURLs" binding:"omitempty,dive,callback_url_pattern"`
CallbackURLs []string `json:"callbackURLs" binding:"omitempty,dive,callback_url"`
LogoutCallbackURLs []string `json:"logoutCallbackURLs" binding:"omitempty,dive,callback_url"`
IsPublic bool `json:"isPublic"`
PkceEnabled bool `json:"pkceEnabled"`
RequiresReauthentication bool `json:"requiresReauthentication"`
@@ -66,12 +66,11 @@ type OidcClientFederatedIdentityDto struct {
type AuthorizeOidcClientRequestDto struct {
ClientID string `json:"clientID" binding:"required"`
Scope string `json:"scope" binding:"required"`
CallbackURL string `json:"callbackURL" binding:"omitempty,callback_url"`
CallbackURL string `json:"callbackURL"`
Nonce string `json:"nonce"`
CodeChallenge string `json:"codeChallenge"`
CodeChallengeMethod string `json:"codeChallengeMethod"`
ReauthenticationToken string `json:"reauthenticationToken"`
Prompt string `json:"prompt"`
ResponseMode string `json:"responseMode" binding:"omitempty,response_mode"`
}

View File

@@ -1,10 +1,9 @@
package dto
type SignUpDto struct {
Username string `json:"username" binding:"required,username,min=1,max=50" unorm:"nfc"`
Email *string `json:"email" binding:"omitempty,email" unorm:"nfc"`
FirstName string `json:"firstName" binding:"max=50" unorm:"nfc"`
LastName string `json:"lastName" binding:"max=50" unorm:"nfc"`
Token string `json:"token"`
CustomFieldValues []CustomFieldValueCreateDto `json:"customFieldValues"`
Username string `json:"username" binding:"required,username,min=1,max=50" unorm:"nfc"`
Email *string `json:"email" binding:"omitempty,email" unorm:"nfc"`
FirstName string `json:"firstName" binding:"max=50" unorm:"nfc"`
LastName string `json:"lastName" binding:"max=50" unorm:"nfc"`
Token string `json:"token"`
}

View File

@@ -7,34 +7,33 @@ import (
)
type UserDto struct {
ID string `json:"id"`
Username string `json:"username"`
Email *string `json:"email"`
EmailVerified bool `json:"emailVerified"`
FirstName string `json:"firstName"`
LastName *string `json:"lastName"`
DisplayName string `json:"displayName"`
IsAdmin bool `json:"isAdmin"`
Locale *string `json:"locale"`
CustomFieldValues []CustomFieldValueDto `json:"customFieldValues"`
UserGroups []UserGroupMinimalDto `json:"userGroups"`
LdapID *string `json:"ldapId"`
Disabled bool `json:"disabled"`
ID string `json:"id"`
Username string `json:"username"`
Email *string `json:"email"`
EmailVerified bool `json:"emailVerified"`
FirstName string `json:"firstName"`
LastName *string `json:"lastName"`
DisplayName string `json:"displayName"`
IsAdmin bool `json:"isAdmin"`
Locale *string `json:"locale"`
CustomClaims []CustomClaimDto `json:"customClaims"`
UserGroups []UserGroupMinimalDto `json:"userGroups"`
LdapID *string `json:"ldapId"`
Disabled bool `json:"disabled"`
}
type UserCreateDto struct {
Username string `json:"username" binding:"required,username,min=1,max=50" unorm:"nfc"`
Email *string `json:"email" binding:"omitempty,email" unorm:"nfc"`
EmailVerified bool `json:"emailVerified"`
FirstName string `json:"firstName" binding:"max=50" unorm:"nfc"`
LastName string `json:"lastName" binding:"max=50" unorm:"nfc"`
DisplayName string `json:"displayName" binding:"max=100" unorm:"nfc"`
IsAdmin bool `json:"isAdmin"`
Locale *string `json:"locale"`
Disabled bool `json:"disabled"`
UserGroupIds []string `json:"userGroupIds"`
CustomFieldValues []CustomFieldValueCreateDto `json:"customFieldValues"`
LdapID string `json:"-"`
Username string `json:"username" binding:"required,username,min=1,max=50" unorm:"nfc"`
Email *string `json:"email" binding:"omitempty,email" unorm:"nfc"`
EmailVerified bool `json:"emailVerified"`
FirstName string `json:"firstName" binding:"max=50" unorm:"nfc"`
LastName string `json:"lastName" binding:"max=50" unorm:"nfc"`
DisplayName string `json:"displayName" binding:"max=100" unorm:"nfc"`
IsAdmin bool `json:"isAdmin"`
Locale *string `json:"locale"`
Disabled bool `json:"disabled"`
UserGroupIds []string `json:"userGroupIds"`
LdapID string `json:"-"`
}
func (u UserCreateDto) Validate() error {

View File

@@ -11,7 +11,7 @@ type UserGroupDto struct {
ID string `json:"id"`
FriendlyName string `json:"friendlyName"`
Name string `json:"name"`
CustomFieldValues []CustomFieldValueDto `json:"customFieldValues"`
CustomClaims []CustomClaimDto `json:"customClaims"`
LdapID *string `json:"ldapId"`
CreatedAt datatype.DateTime `json:"createdAt"`
Users []UserDto `json:"users"`
@@ -22,6 +22,7 @@ type UserGroupMinimalDto struct {
ID string `json:"id"`
FriendlyName string `json:"friendlyName"`
Name string `json:"name"`
CustomClaims []CustomClaimDto `json:"customClaims"`
UserCount int64 `json:"userCount"`
LdapID *string `json:"ldapId"`
CreatedAt datatype.DateTime `json:"createdAt"`
@@ -32,10 +33,9 @@ type UserGroupUpdateAllowedOidcClientsDto struct {
}
type UserGroupCreateDto struct {
FriendlyName string `json:"friendlyName" binding:"required,min=2,max=50" unorm:"nfc"`
Name string `json:"name" binding:"required,min=2,max=255" unorm:"nfc"`
CustomFieldValues []CustomFieldValueCreateDto `json:"customFieldValues"`
LdapID string `json:"-"`
FriendlyName string `json:"friendlyName" binding:"required,min=2,max=50" unorm:"nfc"`
Name string `json:"name" binding:"required,min=2,max=255" unorm:"nfc"`
LdapID string `json:"-"`
}
func (g UserGroupCreateDto) Validate() error {

View File

@@ -1,13 +1,9 @@
package dto
import (
"errors"
"net/url"
"regexp"
"strings"
"time"
"github.com/google/uuid"
"github.com/pocket-id/pocket-id/backend/internal/utils"
"github.com/gin-gonic/gin/binding"
@@ -23,58 +19,45 @@ var validateUsernameRegex = regexp.MustCompile("^[a-zA-Z0-9]([a-zA-Z0-9_.@-]*[a-
var validateClientIDRegex = regexp.MustCompile("^[a-zA-Z0-9._-]+$")
func init() {
engine := binding.Validator.Engine().(*validator.Validate)
v := binding.Validator.Engine().(*validator.Validate)
// Maximum allowed value for TTLs
const maxTTL = 31 * 24 * time.Hour
validators := map[string]validator.Func{
"username": func(fl validator.FieldLevel) bool {
return ValidateUsername(fl.Field().String())
},
"client_id": func(fl validator.FieldLevel) bool {
return ValidateClientID(fl.Field().String())
},
"regex": func(fl validator.FieldLevel) bool {
return ValidateRegex(fl.Field().String())
},
"uuid": func(fl validator.FieldLevel) bool {
return ValidateUUID(fl.Field().String())
},
"ttl": func(fl validator.FieldLevel) bool {
ttl, ok := fl.Field().Interface().(utils.JSONDuration)
if !ok {
return false
}
// Allow zero, which means the field wasn't set
return ttl.Duration == 0 || (ttl.Duration > time.Second && ttl.Duration <= maxTTL)
},
"callback_url": func(fl validator.FieldLevel) bool {
return ValidateCallbackURL(fl.Field().String())
},
"callback_url_pattern": func(fl validator.FieldLevel) bool {
return ValidateCallbackURLPattern(fl.Field().String())
},
"response_mode": func(fl validator.FieldLevel) bool {
return ValidateResponseMode(fl.Field().String())
},
if err := v.RegisterValidation("username", func(fl validator.FieldLevel) bool {
return ValidateUsername(fl.Field().String())
}); err != nil {
panic("Failed to register custom validation for username: " + err.Error())
}
for k, v := range validators {
err := engine.RegisterValidation(k, v)
if err != nil {
panic("Failed to register custom validation for " + k + ": " + err.Error())
}
}
}
func ValidateStruct(input any) error {
e, ok := binding.Validator.Engine().(interface {
Struct(any) error
})
if !ok {
return errors.New("validator does not implement the expected interface")
if err := v.RegisterValidation("client_id", func(fl validator.FieldLevel) bool {
return ValidateClientID(fl.Field().String())
}); err != nil {
panic("Failed to register custom validation for client_id: " + err.Error())
}
if err := v.RegisterValidation("ttl", func(fl validator.FieldLevel) bool {
ttl, ok := fl.Field().Interface().(utils.JSONDuration)
if !ok {
return false
}
// Allow zero, which means the field wasn't set
return ttl.Duration == 0 || (ttl.Duration > time.Second && ttl.Duration <= maxTTL)
}); err != nil {
panic("Failed to register custom validation for ttl: " + err.Error())
}
if err := v.RegisterValidation("callback_url", func(fl validator.FieldLevel) bool {
return ValidateCallbackURL(fl.Field().String())
}); err != nil {
panic("Failed to register custom validation for callback_url: " + err.Error())
}
if err := v.RegisterValidation("response_mode", func(fl validator.FieldLevel) bool {
return ValidateResponseMode(fl.Field().String())
}); err != nil {
panic("Failed to register custom validation for response_mode: " + err.Error())
}
return e.Struct(input)
}
// ValidateUsername validates username inputs
@@ -87,48 +70,18 @@ func ValidateClientID(clientID string) bool {
return validateClientIDRegex.MatchString(clientID)
}
// ValidateRegex validates that the input is either empty or a compilable regular expression.
func ValidateRegex(value string) bool {
if value == "" {
return true
}
_, err := regexp.Compile(value)
return err == nil
}
// ValidateUUID validates UUID inputs.
func ValidateUUID(value string) bool {
return uuid.Validate(value) == nil
}
// ValidateCallbackURL validates the input callback URL
func ValidateCallbackURL(str string) bool {
// Ensure the URL is a valid one and that the protocol is not "javascript:" or "data:"
u, err := url.Parse(str)
if err != nil {
return false
}
switch strings.ToLower(u.Scheme) {
case "javascript", "data":
return false
default:
return true
}
}
// ValidateCallbackURLPattern validates callback URL patterns, with support for wildcards
func ValidateCallbackURLPattern(raw string) bool {
// ValidateCallbackURL validates callback URLs with support for wildcards
func ValidateCallbackURL(raw string) bool {
err := utils.ValidateCallbackURLPattern(raw)
return err == nil
}
// ValidateResponseMode validates response_mode parameter
// If responseMode is present, it must be "form_post", "query", or "fragment"
// If responseMode is present, it must be "form_post" or "query"
// Empty responseMode is allowed (field not provided, use default)
func ValidateResponseMode(responseMode string) bool {
switch responseMode {
case "form_post", "query", "fragment":
case "form_post", "query":
return true
case "":
return true

View File

@@ -58,42 +58,6 @@ func TestValidateClientID(t *testing.T) {
}
}
func TestValidateRegex(t *testing.T) {
tests := []struct {
name string
input string
expected bool
}{
{"empty", "", true},
{"valid", "^EMP-[0-9]+$", true},
{"invalid", "[", false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
assert.Equal(t, tt.expected, ValidateRegex(tt.input))
})
}
}
func TestValidateUUID(t *testing.T) {
tests := []struct {
name string
input string
expected bool
}{
{"valid", "89bc9c8f-2cd8-4cfd-82c5-5fa14e874f03", true},
{"invalid", "field-1", false},
{"empty", "", false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
assert.Equal(t, tt.expected, ValidateUUID(tt.input))
})
}
}
func TestValidateResponseMode(t *testing.T) {
tests := []struct {
name string
@@ -102,9 +66,8 @@ func TestValidateResponseMode(t *testing.T) {
}{
{"valid form_post", "form_post", true},
{"valid query", "query", true},
{"valid fragment", "fragment", true},
{"valid empty", "", true},
{"invalid unknown", "unknown", false},
{"invalid fragment", "fragment", false},
}
for _, tt := range tests {
@@ -113,28 +76,3 @@ func TestValidateResponseMode(t *testing.T) {
})
}
}
func TestValidateCallbackURL(t *testing.T) {
tests := []struct {
name string
input string
expected bool
}{
{"valid https URL", "https://example.com/callback", true},
{"valid loopback URL", "http://127.0.0.1:49813/callback", true},
{"empty scheme", "//127.0.0.1:49813/callback", true},
{"valid custom scheme", "pocketid://callback", true},
{"invalid malformed URL", "http://[::1", false},
{"invalid missing scheme separator", "://example.com/callback", false},
{"rejects javascript scheme", "javascript:alert(1)", false},
{"rejects mixed case javascript scheme", "JavaScript:alert(1)", false},
{"rejects data scheme", "data:text/html;base64,PGgxPkhlbGxvPC9oMT4=", false},
{"rejects mixed case data scheme", "DaTa:text/html;base64,PGgxPkhlbGxvPC9oMT4=", false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
assert.Equal(t, tt.expected, ValidateCallbackURL(tt.input))
})
}
}

View File

@@ -10,7 +10,7 @@ type WebauthnCredentialDto struct {
Name string `json:"name"`
CredentialID string `json:"credentialID"`
AttestationType string `json:"attestationType"`
Transport []protocol.AuthenticatorTransport `json:"transport" swaggertype:"array,string"`
Transport []protocol.AuthenticatorTransport `json:"transport"`
BackupEligible bool `json:"backupEligible"`
BackupState bool `json:"backupState"`

View File

@@ -74,11 +74,10 @@ func (m *AuthMiddleware) WithApiKeyAuthDisabled() *AuthMiddleware {
func (m *AuthMiddleware) Add() gin.HandlerFunc {
return func(c *gin.Context) {
userID, isAdmin, authenticationMethod, err := m.jwtMiddleware.Verify(c, m.options.AdminRequired)
userID, isAdmin, err := m.jwtMiddleware.Verify(c, m.options.AdminRequired)
if err == nil {
c.Set("userID", userID)
c.Set("userIsAdmin", isAdmin)
c.Set("authenticationMethod", authenticationMethod)
if c.IsAborted() {
return
}

View File

@@ -37,16 +37,14 @@ func TestWithApiKeyAuthDisabled(t *testing.T) {
jwtService, err := service.NewJwtService(t.Context(), db, appConfigService)
require.NoError(t, err)
customFieldsValueService := service.NewCustomFieldValueService(db, appConfigService)
userService := service.NewUserService(db, jwtService, nil, nil, appConfigService, customFieldsValueService, nil, nil, nil)
userService := service.NewUserService(db, jwtService, nil, nil, appConfigService, nil, nil, nil, nil)
apiKeyService, err := service.NewApiKeyService(t.Context(), db, nil)
require.NoError(t, err)
authMiddleware := NewAuthMiddleware(apiKeyService, userService, jwtService)
user := createUserForAuthMiddlewareTest(t, db)
jwtToken, err := jwtService.GenerateAccessToken(user, "")
jwtToken, err := jwtService.GenerateAccessToken(user)
require.NoError(t, err)
_, apiKeyToken, err := apiKeyService.CreateApiKey(t.Context(), user.ID, dto.ApiKeyCreateDto{
@@ -62,7 +60,7 @@ func TestWithApiKeyAuthDisabled(t *testing.T) {
})
t.Run("rejects API key auth when API key auth is disabled", func(t *testing.T) {
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/api/protected", nil)
req := httptest.NewRequest(http.MethodGet, "/api/protected", nil)
req.Header.Set("X-API-Key", apiKeyToken)
recorder := httptest.NewRecorder()
@@ -77,7 +75,7 @@ func TestWithApiKeyAuthDisabled(t *testing.T) {
})
t.Run("allows JWT auth when API key auth is disabled", func(t *testing.T) {
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/api/protected", nil)
req := httptest.NewRequest(http.MethodGet, "/api/protected", nil)
req.Header.Set("Authorization", "Bearer "+jwtToken)
recorder := httptest.NewRecorder()
@@ -90,9 +88,10 @@ func TestWithApiKeyAuthDisabled(t *testing.T) {
func createUserForAuthMiddlewareTest(t *testing.T, db *gorm.DB) model.User {
t.Helper()
email := "auth@example.com"
user := model.User{
Username: "auth-user",
Email: new("auth@example.com"),
Email: &email,
FirstName: "Auth",
LastName: "User",
DisplayName: "Auth User",

View File

@@ -18,7 +18,7 @@ func TestCacheControlMiddlewareSetsDefault(t *testing.T) {
c.Status(http.StatusOK)
})
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/test", http.NoBody)
req := httptest.NewRequest(http.MethodGet, "/test", http.NoBody)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
@@ -36,7 +36,7 @@ func TestCacheControlMiddlewarePreservesExistingHeader(t *testing.T) {
c.Status(http.StatusOK)
})
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/custom", http.NoBody)
req := httptest.NewRequest(http.MethodGet, "/custom", http.NoBody)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)

View File

@@ -3,7 +3,6 @@ package middleware
import (
"crypto/rand"
"encoding/base64"
"strings"
"github.com/gin-gonic/gin"
)
@@ -26,7 +25,6 @@ func GetCSPNonce(c *gin.Context) string {
func (m *CspMiddleware) Add() gin.HandlerFunc {
return func(c *gin.Context) {
// Generate a random base64 nonce for this request
nonce := generateNonce()
c.Set("csp_nonce", nonce)
c.Writer.Header().Set("Content-Security-Policy", BuildCSP(nonce))
@@ -37,18 +35,10 @@ func (m *CspMiddleware) Add() gin.HandlerFunc {
func BuildCSP(nonce string, formActionExtra ...string) string {
formAction := "'self'"
if len(formActionExtra) > 0 {
b := strings.Builder{}
for _, extra := range formActionExtra {
if extra != "" {
b.WriteByte(' ')
b.WriteString(extra)
}
for _, extra := range formActionExtra {
if extra != "" {
formAction += " " + extra
}
formAction += b.String()
}
return "default-src 'self'; " +

View File

@@ -23,6 +23,7 @@ func (m *ErrorHandlerMiddleware) Add() gin.HandlerFunc {
return func(c *gin.Context) {
c.Next()
for _, err := range c.Errors {
// Check for record not found errors
if errors.Is(err, gorm.ErrRecordNotFound) {
errorResponse(c, http.StatusNotFound, "Record not found")
@@ -38,56 +39,30 @@ func (m *ErrorHandlerMiddleware) Add() gin.HandlerFunc {
}
// Check for slice validation errors
svErr, ok := errors.AsType[binding.SliceValidationError](err)
if ok {
if errors.As(svErr[0], &validationErrors) {
var sliceValidationErrors binding.SliceValidationError
if errors.As(err, &sliceValidationErrors) {
if errors.As(sliceValidationErrors[0], &validationErrors) {
message := handleValidationError(validationErrors)
errorResponse(c, http.StatusBadRequest, message)
return
}
}
// AppError with description
appDescErr, ok := errors.AsType[common.AppErrorDescription](err)
if ok {
errorResponseWithDescription(c, appDescErr.HttpStatusCode(), appDescErr.Error(), appDescErr.Description())
return
}
// AppError (without description)
appErr, ok := errors.AsType[common.AppError](err)
if ok {
var appErr common.AppError
if errors.As(err, &appErr) {
errorResponse(c, appErr.HttpStatusCode(), appErr.Error())
return
}
c.JSON(http.StatusInternalServerError, errorResponseBody{
Error: "Something went wrong",
})
c.JSON(http.StatusInternalServerError, gin.H{"error": "Something went wrong"})
}
}
}
type errorResponseBody struct {
Error string `json:"error"`
ErrorDescription string `json:"error_description,omitempty"`
}
func errorResponse(c *gin.Context, statusCode int, message string) {
// Capitalize the first letter of the message
message = strings.ToUpper(message[:1]) + message[1:]
c.JSON(statusCode, errorResponseBody{
Error: message,
})
}
func errorResponseWithDescription(c *gin.Context, statusCode int, message string, description string) {
// Capitalize the first letter of the message
message = strings.ToUpper(message[:1]) + message[1:]
c.JSON(statusCode, errorResponseBody{
Error: message,
ErrorDescription: description,
})
c.JSON(statusCode, gin.H{"error": message})
}
func handleValidationError(validationErrors validator.ValidationErrors) string {

View File

@@ -16,7 +16,7 @@ type headWriter struct {
func (w *headWriter) Write(b []byte) (int, error) {
w.size += len(b)
return len(b), nil
return w.size, nil
}
func HeadMiddleware() gin.HandlerFunc {

View File

@@ -20,7 +20,7 @@ func NewJwtAuthMiddleware(jwtService *service.JwtService, userService *service.U
func (m *JwtAuthMiddleware) Add(adminRequired bool) gin.HandlerFunc {
return func(c *gin.Context) {
userID, isAdmin, authenticationMethod, err := m.Verify(c, adminRequired)
userID, isAdmin, err := m.Verify(c, adminRequired)
if err != nil {
c.Abort()
_ = c.Error(err)
@@ -29,12 +29,11 @@ func (m *JwtAuthMiddleware) Add(adminRequired bool) gin.HandlerFunc {
c.Set("userID", userID)
c.Set("userIsAdmin", isAdmin)
c.Set("authenticationMethod", authenticationMethod)
c.Next()
}
}
func (m *JwtAuthMiddleware) Verify(c *gin.Context, adminRequired bool) (subject string, isAdmin bool, authenticationMethod string, err error) {
func (m *JwtAuthMiddleware) Verify(c *gin.Context, adminRequired bool) (subject string, isAdmin bool, err error) {
// Extract the token from the cookie
accessToken, err := c.Cookie(cookie.AccessTokenCookieName)
if err != nil {
@@ -42,37 +41,33 @@ func (m *JwtAuthMiddleware) Verify(c *gin.Context, adminRequired bool) (subject
var ok bool
_, accessToken, ok = strings.Cut(c.GetHeader("Authorization"), " ")
if !ok || accessToken == "" {
return "", false, "", &common.NotSignedInError{}
return "", false, &common.NotSignedInError{}
}
}
token, err := m.jwtService.VerifyAccessToken(accessToken)
if err != nil {
return "", false, "", &common.NotSignedInError{}
}
authenticationMethod, err = service.GetAuthenticationMethod(token)
if err != nil {
return "", false, "", &common.NotSignedInError{}
return "", false, &common.NotSignedInError{}
}
subject, ok := token.Subject()
if !ok {
_ = c.Error(&common.TokenInvalidError{})
return "", false, "", &common.TokenInvalidError{}
return
}
user, err := m.userService.GetUser(c, subject)
if err != nil {
return "", false, "", &common.NotSignedInError{}
return "", false, &common.NotSignedInError{}
}
if user.Disabled {
return "", false, "", &common.UserDisabledError{}
return "", false, &common.UserDisabledError{}
}
if adminRequired && !user.IsAdmin {
return "", false, "", &common.MissingPermissionError{}
return "", false, &common.MissingPermissionError{}
}
return subject, user.IsAdmin, authenticationMethod, nil
return subject, isAdmin, nil
}

View File

@@ -43,7 +43,7 @@ type AppConfig struct {
AllowOwnAccountEdit AppConfigVariable `key:"allowOwnAccountEdit,public"` // Public
AllowUserSignups AppConfigVariable `key:"allowUserSignups,public"` // Public
SignupDefaultUserGroupIDs AppConfigVariable `key:"signupDefaultUserGroupIDs"`
CustomFields AppConfigVariable `key:"customFields,public"` // Public
SignupDefaultCustomClaims AppConfigVariable `key:"signupDefaultCustomClaims"`
// Internal
InstanceID AppConfigVariable `key:"instanceId,internal"` // Internal
// Email

View File

@@ -0,0 +1,11 @@
package model
type CustomClaim struct {
Base
Key string
Value string
UserID *string
UserGroupID *string
}

View File

@@ -1,11 +0,0 @@
package model
type CustomFieldValue struct {
Base
CustomFieldID string
Value string
UserID *string
UserGroupID *string
}

View File

@@ -33,7 +33,6 @@ type OidcAuthorizationCode struct {
Code string
Scope string
AuthenticationMethod string
Nonce string
CodeChallenge *string
CodeChallengeMethodSha256 *bool
@@ -78,11 +77,9 @@ func (c OidcClient) HasDarkLogo() bool {
type OidcRefreshToken struct {
Base
Token string
IdTokenJti *string
ExpiresAt datatype.DateTime
Scope string
AuthenticationMethod string
Token string
ExpiresAt datatype.DateTime
Scope string
UserID string
User User
@@ -144,13 +141,12 @@ func (cu UrlList) Value() (driver.Value, error) {
type OidcDeviceCode struct {
Base
DeviceCode string
UserCode string
Scope string
AuthenticationMethod string
Nonce string
ExpiresAt datatype.DateTime
IsAuthorized bool
DeviceCode string
UserCode string
Scope string
Nonce string
ExpiresAt datatype.DateTime
IsAuthorized bool
UserID *string
User User

View File

@@ -26,9 +26,9 @@ type User struct {
Disabled bool `sortable:"true" filterable:"true"`
UpdatedAt *datatype.DateTime
CustomFieldValues []CustomFieldValue
UserGroups []UserGroup `gorm:"many2many:user_groups_users;"`
Credentials []WebauthnCredential
CustomClaims []CustomClaim
UserGroups []UserGroup `gorm:"many2many:user_groups_users;"`
Credentials []WebauthnCredential
}
func (u User) WebAuthnID() []byte { return []byte(u.ID) }

View File

@@ -13,7 +13,7 @@ type UserGroup struct {
LdapID *string
UpdatedAt *datatype.DateTime
Users []User `gorm:"many2many:user_groups_users;"`
CustomFieldValues []CustomFieldValue
CustomClaims []CustomClaim
AllowedOidcClients []OidcClient `gorm:"many2many:oidc_clients_allowed_user_groups;"`
}

View File

@@ -2,7 +2,6 @@ package service
import (
"context"
"encoding/json"
"errors"
"fmt"
"os"
@@ -68,7 +67,7 @@ func (s *AppConfigService) getDefaultDbConfig() *model.AppConfig {
AllowOwnAccountEdit: model.AppConfigVariable{Value: "true"},
AllowUserSignups: model.AppConfigVariable{Value: "disabled"},
SignupDefaultUserGroupIDs: model.AppConfigVariable{Value: "[]"},
CustomFields: model.AppConfigVariable{Value: "[]"},
SignupDefaultCustomClaims: model.AppConfigVariable{Value: "[]"},
AccentColor: model.AppConfigVariable{Value: "default"},
// Internal
InstanceID: model.AppConfigVariable{Value: ""},
@@ -159,87 +158,6 @@ func (s *AppConfigService) updateAppConfigUpdateDatabase(ctx context.Context, tx
return nil
}
func (s *AppConfigService) normalizeCustomFieldConfigUpdate(ctx context.Context, tx *gorm.DB, oldValue, newValue string) (string, error) {
if newValue == "" {
return "", nil
}
oldFields, err := ParseCustomFieldDefinitions(oldValue)
if err != nil {
return "", err
}
newFields, err := ParseCustomFieldDefinitions(newValue)
if err != nil {
return "", err
}
oldFieldsByID := make(map[string]dto.CustomFieldDto, len(oldFields))
for _, oldField := range oldFields {
oldFieldsByID[oldField.ID] = oldField
}
newFieldsByID := make(map[string]dto.CustomFieldDto, len(newFields))
for _, newField := range newFields {
newFieldsByID[newField.ID] = newField
oldField, ok := oldFieldsByID[newField.ID]
if !ok {
continue
}
if oldField.Type != newField.Type {
return "", &common.CustomFieldValidationError{Message: fmt.Sprintf("custom field %s type can't be changed", oldField.Key)}
}
// If the field existed before, but the appliesTo was changed so that it no longer applies to users/groups,
// then we need to delete the corresponding values on users/groups
for _, idType := range []idType{UserID, UserGroupID} {
oldApplies := customFieldAppliesTo(oldField, idType)
newApplies := customFieldAppliesTo(newField, idType)
if oldApplies && !newApplies {
if err := s.deleteCustomFieldValuesForFieldID(ctx, tx, idType, oldField.ID); err != nil {
return "", err
}
}
}
}
// Check if any fields were removed, and if so delete the corresponding values on users/groups
for _, oldField := range oldFields {
if _, ok := newFieldsByID[oldField.ID]; ok {
continue
}
for _, idType := range []idType{UserID, UserGroupID} {
if !customFieldAppliesTo(oldField, idType) {
continue
}
if err := s.deleteCustomFieldValuesForFieldID(ctx, tx, idType, oldField.ID); err != nil {
return "", err
}
}
}
normalizedValue, err := json.Marshal(newFields)
if err != nil {
return "", fmt.Errorf("failed to normalize custom fields JSON: %w", err)
}
return string(normalizedValue), nil
}
func (s *AppConfigService) deleteCustomFieldValuesForFieldID(ctx context.Context, tx *gorm.DB, idType idType, customFieldID string) error {
query := tx.WithContext(ctx).Model(&model.CustomFieldValue{})
switch idType {
case UserID:
query = query.Where("user_id IS NOT NULL")
case UserGroupID:
query = query.Where("user_group_id IS NOT NULL")
}
if err := query.Where("custom_field_id = ?", customFieldID).Delete(&model.CustomFieldValue{}).Error; err != nil {
return fmt.Errorf("failed to delete custom field values for field %s: %w", customFieldID, err)
}
return nil
}
func (s *AppConfigService) UpdateAppConfig(ctx context.Context, input dto.AppConfigUpdateDto) ([]model.AppConfigVariable, error) {
if common.EnvConfig.UiConfigDisabled {
return nil, &common.UiConfigDisabledError{}
@@ -261,11 +179,6 @@ func (s *AppConfigService) UpdateAppConfig(ctx context.Context, input dto.AppCon
return nil, fmt.Errorf("failed to reload config from database: %w", err)
}
input.CustomFields, err = s.normalizeCustomFieldConfigUpdate(ctx, tx, cfg.CustomFields.Value, input.CustomFields)
if err != nil {
return nil, err
}
defaultCfg := s.getDefaultDbConfig()
// Iterate through all the fields to update
@@ -352,9 +265,9 @@ func (s *AppConfigService) UpdateAppConfigValues(ctx context.Context, keysAndVal
// We update the in-memory data (in the cfg struct) and collect values to update in the database
// (Note the += 2, as we are iterating through key-value pairs)
dbUpdate := make([]model.AppConfigVariable, 0, len(keysAndValues)/2)
for i := 1; i < len(keysAndValues); i += 2 {
key := keysAndValues[i-1]
value := keysAndValues[i]
for i := 0; i < len(keysAndValues); i += 2 {
key := keysAndValues[i]
value := keysAndValues[i+1]
// Ensure that the field is valid
// We do this by grabbing the default value
@@ -495,7 +408,6 @@ func (s *AppConfigService) loadDbConfigFromEnv(ctx context.Context, tx *gorm.DB)
if attrs == "sensitive" {
fileName := os.Getenv(envVarName + "_FILE")
if fileName != "" {
// #nosec G703 - Value is provided by admin
b, err := os.ReadFile(fileName)
if err != nil {
return nil, fmt.Errorf("failed to read secret '%s' from file '%s': %w", envVarName, fileName, err)

View File

@@ -1,7 +1,6 @@
package service
import (
"encoding/json"
"sync/atomic"
"testing"
@@ -244,7 +243,7 @@ func TestUpdateAppConfigValues(t *testing.T) {
// Verify database was updated
var count int64
db.Model(&model.AppConfigVariable{}).Count(&count)
require.GreaterOrEqual(t, count, int64(3))
require.Equal(t, int64(3), count)
var appName, sessionDuration, smtpHost model.AppConfigVariable
err = db.Where("key = ?", "appName").First(&appName).Error
@@ -471,98 +470,4 @@ func TestUpdateAppConfig(t *testing.T) {
var uiConfigDisabledErr *common.UiConfigDisabledError
require.ErrorAs(t, err, &uiConfigDisabledErr)
})
t.Run("keeps custom field values when custom field key changes", func(t *testing.T) {
db := testutils.NewDatabaseForTest(t)
fieldID := "d20db690-c6fb-4b5b-8288-ac68eb80c6f4"
oldCustomFields := `[{"id":"d20db690-c6fb-4b5b-8288-ac68eb80c6f4","key":"department","displayName":"Department","type":"string","target":"user","required":false}]`
err := db.Model(&model.AppConfigVariable{}).Where("key = ?", "customFields").Update("value", oldCustomFields).Error
require.NoError(t, err)
user := model.User{Username: "test-user"}
err = db.Create(&user).Error
require.NoError(t, err)
err = db.Create(&model.CustomFieldValue{
CustomFieldID: fieldID,
Value: "Engineering",
UserID: &user.ID,
}).Error
require.NoError(t, err)
service := &AppConfigService{db: db}
err = service.LoadDbConfig(t.Context())
require.NoError(t, err)
newCustomFields := `[{"id":"d20db690-c6fb-4b5b-8288-ac68eb80c6f4","key":"team","displayName":"Team","type":"string","target":"user","required":false}]`
_, err = service.UpdateAppConfig(t.Context(), dto.AppConfigUpdateDto{
CustomFields: newCustomFields,
})
require.NoError(t, err)
var customFieldValue model.CustomFieldValue
err = db.Where("user_id = ?", user.ID).First(&customFieldValue).Error
require.NoError(t, err)
require.Equal(t, fieldID, customFieldValue.CustomFieldID)
require.Equal(t, "Engineering", customFieldValue.Value)
var storedConfig model.AppConfigVariable
err = db.Where("key = ?", "customFields").First(&storedConfig).Error
require.NoError(t, err)
var storedFields []dto.CustomFieldDto
err = json.Unmarshal([]byte(storedConfig.Value), &storedFields)
require.NoError(t, err)
require.Len(t, storedFields, 1)
require.Equal(t, fieldID, storedFields[0].ID)
require.Equal(t, "team", storedFields[0].Key)
})
t.Run("rejects custom field type changes", func(t *testing.T) {
db := testutils.NewDatabaseForTest(t)
oldCustomFields := `[{"id":"cda85ff5-9a22-40cc-8490-7b88593f6422","key":"department","displayName":"Department","type":"string","target":"user","required":false}]`
err := db.Model(&model.AppConfigVariable{}).Where("key = ?", "customFields").Update("value", oldCustomFields).Error
require.NoError(t, err)
service := &AppConfigService{db: db}
err = service.LoadDbConfig(t.Context())
require.NoError(t, err)
newCustomFields := `[{"id":"cda85ff5-9a22-40cc-8490-7b88593f6422","key":"department","displayName":"Department","type":"number","target":"user","required":false}]`
_, err = service.UpdateAppConfig(t.Context(), dto.AppConfigUpdateDto{
CustomFields: newCustomFields,
})
require.Error(t, err)
require.Contains(t, err.Error(), "type can't be changed")
})
t.Run("deletes custom field values when custom field is removed", func(t *testing.T) {
db := testutils.NewDatabaseForTest(t)
fieldID := "a99f05e6-57a0-468d-a8fe-98bd637cbf98"
oldCustomFields := `[{"id":"a99f05e6-57a0-468d-a8fe-98bd637cbf98","key":"department","displayName":"Department","type":"string","target":"user","required":false}]`
err := db.Model(&model.AppConfigVariable{}).Where("key = ?", "customFields").Update("value", oldCustomFields).Error
require.NoError(t, err)
user := model.User{Username: "test-user"}
err = db.Create(&user).Error
require.NoError(t, err)
err = db.Create(&model.CustomFieldValue{
CustomFieldID: fieldID,
Value: "Engineering",
UserID: &user.ID,
}).Error
require.NoError(t, err)
service := &AppConfigService{db: db}
err = service.LoadDbConfig(t.Context())
require.NoError(t, err)
_, err = service.UpdateAppConfig(t.Context(), dto.AppConfigUpdateDto{
CustomFields: "[]",
})
require.NoError(t, err)
var count int64
err = db.Model(&model.CustomFieldValue{}).Where("user_id = ?", user.ID).Count(&count).Error
require.NoError(t, err)
require.Zero(t, count)
})
}

View File

@@ -12,7 +12,6 @@ import (
"github.com/pocket-id/pocket-id/backend/internal/common"
"github.com/pocket-id/pocket-id/backend/internal/storage"
"github.com/pocket-id/pocket-id/backend/internal/utils"
imageutil "github.com/pocket-id/pocket-id/backend/internal/utils/image"
)
type AppImagesService struct {
@@ -69,12 +68,7 @@ func (s *AppImagesService) UpdateImage(ctx context.Context, file *multipart.File
}
defer fileReader.Close()
strippedReader, err := imageutil.StripMetadata(fileReader, fileType)
if err != nil {
return err
}
if err := s.storage.Save(ctx, imagePath, strippedReader); err != nil {
if err := s.storage.Save(ctx, imagePath, fileReader); err != nil {
return err
}

View File

@@ -3,7 +3,6 @@ package service
import (
"bytes"
"context"
"encoding/binary"
"io"
"io/fs"
"mime/multipart"
@@ -57,29 +56,6 @@ func TestAppImagesService_UpdateImage(t *testing.T) {
require.ErrorIs(t, err, fs.ErrNotExist)
}
func TestAppImagesService_UpdateImageStripsMetadata(t *testing.T) {
store, err := storage.NewFilesystemStorage(t.TempDir())
require.NoError(t, err)
service := NewAppImagesService(map[string]string{}, store)
fileHeader := newFileHeader(t, "logo.webp", webpFile(
webpChunk("VP8 ", []byte{1, 2, 3, 4}),
webpChunk("EXIF", []byte("secret")),
))
require.NoError(t, service.UpdateImage(context.Background(), fileHeader, "logoLight"))
reader, _, err := store.Open(context.Background(), path.Join("application-images", "logoLight.webp"))
require.NoError(t, err)
defer reader.Close()
payload, err := io.ReadAll(reader)
require.NoError(t, err)
assert.NotContains(t, string(payload), "secret")
assert.Contains(t, string(payload), "VP8 ")
}
func TestAppImagesService_ErrorsAndFlags(t *testing.T) {
store, err := storage.NewFilesystemStorage(t.TempDir())
require.NoError(t, err)
@@ -128,7 +104,7 @@ func newFileHeader(t *testing.T, filename string, content []byte) *multipart.Fil
require.NoError(t, writer.Close())
req := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/", body)
req := httptest.NewRequest(http.MethodPost, "/", body)
req.Header.Set("Content-Type", writer.FormDataContentType())
_, fileHeader, err := req.FormFile("file")
@@ -136,26 +112,3 @@ func newFileHeader(t *testing.T, filename string, content []byte) *multipart.Fil
return fileHeader
}
func webpFile(chunks ...[]byte) []byte {
var out bytes.Buffer
out.WriteString("RIFF")
out.Write([]byte{0, 0, 0, 0})
out.WriteString("WEBP")
for _, chunk := range chunks {
out.Write(chunk)
}
binary.LittleEndian.PutUint32(out.Bytes()[4:8], uint32(out.Len()-8)) //nolint:gosec
return out.Bytes()
}
func webpChunk(chunkType string, data []byte) []byte {
var out bytes.Buffer
out.WriteString(chunkType)
_ = binary.Write(&out, binary.LittleEndian, uint32(len(data))) //nolint:gosec
out.Write(data)
if len(data)%2 == 1 {
out.WriteByte(0)
}
return out.Bytes()
}

View File

@@ -154,7 +154,8 @@ func TestAppLockServiceAcquire(t *testing.T) {
db := testutils.NewDatabaseForTest(t)
service := newTestAppLockService(t, db)
err := db.Create(&model.KV{Key: lockKey, Value: new("this-is-not-json")}).Error
raw := "this-is-not-json"
err := db.Create(&model.KV{Key: lockKey, Value: &raw}).Error
require.NoError(t, err)
_, err = service.Acquire(context.Background(), false)

View File

@@ -92,7 +92,7 @@ func (s *AuditLogService) CreateNewSignInWithEmail(ctx context.Context, ipAddres
// If the user hasn't logged in from the same device before and email notifications are enabled, send an email
if s.appConfigService.GetDbConfig().EmailLoginNotificationEnabled.IsTrue() && count <= 1 {
// #nosec G118 - We use a background context here as this is running in a goroutine
// We use a background context here as this is running in a goroutine
//nolint:contextcheck
go func() {
span := trace.SpanFromContext(ctx)

View File

@@ -0,0 +1,260 @@
package service
import (
"context"
"github.com/pocket-id/pocket-id/backend/internal/common"
"github.com/pocket-id/pocket-id/backend/internal/dto"
"github.com/pocket-id/pocket-id/backend/internal/model"
"gorm.io/gorm"
)
type CustomClaimService struct {
db *gorm.DB
}
func NewCustomClaimService(db *gorm.DB) *CustomClaimService {
return &CustomClaimService{db: db}
}
// isReservedClaim checks if a claim key is reserved e.g. email, preferred_username
func isReservedClaim(key string) bool {
switch key {
case "given_name",
"family_name",
"name",
"email",
"preferred_username",
"display_name",
"groups",
TokenTypeClaim,
"sub",
"iss",
"aud",
"exp",
"iat",
"auth_time",
"nonce",
"acr",
"amr",
"azp",
"nbf",
"jti":
return true
default:
return false
}
}
// idType is the type of the id used to identify the user or user group
type idType string
const (
UserID idType = "user_id"
UserGroupID idType = "user_group_id"
)
// UpdateCustomClaimsForUser updates the custom claims for a user
func (s *CustomClaimService) UpdateCustomClaimsForUser(ctx context.Context, userID string, claims []dto.CustomClaimCreateDto) ([]model.CustomClaim, error) {
tx := s.db.Begin()
defer func() {
tx.Rollback()
}()
updatedClaims, err := s.updateCustomClaimsInternal(ctx, UserID, userID, claims, tx)
if err != nil {
return nil, err
}
err = tx.Commit().Error
if err != nil {
return nil, err
}
return updatedClaims, nil
}
// UpdateCustomClaimsForUserGroup updates the custom claims for a user group
func (s *CustomClaimService) UpdateCustomClaimsForUserGroup(ctx context.Context, userGroupID string, claims []dto.CustomClaimCreateDto) ([]model.CustomClaim, error) {
tx := s.db.Begin()
defer func() {
tx.Rollback()
}()
updatedClaims, err := s.updateCustomClaimsInternal(ctx, UserGroupID, userGroupID, claims, tx)
if err != nil {
return nil, err
}
err = tx.Commit().Error
if err != nil {
return nil, err
}
return updatedClaims, nil
}
// updateCustomClaimsInternal updates the custom claims for a user or user group within a transaction
func (s *CustomClaimService) updateCustomClaimsInternal(ctx context.Context, idType idType, value string, claims []dto.CustomClaimCreateDto, tx *gorm.DB) ([]model.CustomClaim, error) {
// Check for duplicate keys in the claims slice
seenKeys := make(map[string]struct{})
for _, claim := range claims {
if _, ok := seenKeys[claim.Key]; ok {
return nil, &common.DuplicateClaimError{Key: claim.Key}
}
seenKeys[claim.Key] = struct{}{}
}
var existingClaims []model.CustomClaim
err := tx.
WithContext(ctx).
Where(string(idType), value).
Find(&existingClaims).
Error
if err != nil {
return nil, err
}
// Delete claims that are not in the new list
for _, existingClaim := range existingClaims {
found := false
for _, claim := range claims {
if claim.Key == existingClaim.Key {
found = true
break
}
}
if !found {
err = tx.
WithContext(ctx).
Delete(&existingClaim).
Error
if err != nil {
return nil, err
}
}
}
// Add or update claims
for _, claim := range claims {
if isReservedClaim(claim.Key) {
return nil, &common.ReservedClaimError{Key: claim.Key}
}
customClaim := model.CustomClaim{
Key: claim.Key,
Value: claim.Value,
}
switch idType {
case UserID:
customClaim.UserID = &value
case UserGroupID:
customClaim.UserGroupID = &value
}
// Update the claim if it already exists or create a new one
err = tx.
WithContext(ctx).
Where(string(idType)+" = ? AND key = ?", value, claim.Key).
Assign(&customClaim).
FirstOrCreate(&model.CustomClaim{}).
Error
if err != nil {
return nil, err
}
}
// Get the updated claims
var updatedClaims []model.CustomClaim
err = tx.
WithContext(ctx).
Where(string(idType)+" = ?", value).
Find(&updatedClaims).
Error
if err != nil {
return nil, err
}
return updatedClaims, nil
}
func (s *CustomClaimService) GetCustomClaimsForUser(ctx context.Context, userID string, tx *gorm.DB) ([]model.CustomClaim, error) {
var customClaims []model.CustomClaim
err := tx.
WithContext(ctx).
Where("user_id = ?", userID).
Find(&customClaims).
Error
return customClaims, err
}
func (s *CustomClaimService) GetCustomClaimsForUserGroup(ctx context.Context, userGroupID string, tx *gorm.DB) ([]model.CustomClaim, error) {
var customClaims []model.CustomClaim
err := tx.
WithContext(ctx).
Where("user_group_id = ?", userGroupID).
Find(&customClaims).
Error
return customClaims, err
}
// GetCustomClaimsForUserWithUserGroups returns the custom claims of a user and all user groups the user is a member of,
// prioritizing the user's claims over user group claims with the same key.
func (s *CustomClaimService) GetCustomClaimsForUserWithUserGroups(ctx context.Context, userID string, tx *gorm.DB) ([]model.CustomClaim, error) {
// Get the custom claims of the user
customClaims, err := s.GetCustomClaimsForUser(ctx, userID, tx)
if err != nil {
return nil, err
}
// Store user's claims in a map to prioritize and prevent duplicates
claimsMap := make(map[string]model.CustomClaim)
for _, claim := range customClaims {
claimsMap[claim.Key] = claim
}
// Get all user groups of the user
var userGroupsOfUser []model.UserGroup
err = tx.
WithContext(ctx).
Preload("CustomClaims").
Joins("JOIN user_groups_users ON user_groups_users.user_group_id = user_groups.id").
Where("user_groups_users.user_id = ?", userID).
Find(&userGroupsOfUser).Error
if err != nil {
return nil, err
}
// Add only non-duplicate custom claims from user groups
for _, userGroup := range userGroupsOfUser {
for _, groupClaim := range userGroup.CustomClaims {
// Only add claim if it does not exist in the user's claims
if _, exists := claimsMap[groupClaim.Key]; !exists {
claimsMap[groupClaim.Key] = groupClaim
}
}
}
// Convert the claimsMap back to a slice
finalClaims := make([]model.CustomClaim, 0, len(claimsMap))
for _, claim := range claimsMap {
finalClaims = append(finalClaims, claim)
}
return finalClaims, nil
}
// GetSuggestions returns a list of custom claim keys that have been used before
func (s *CustomClaimService) GetSuggestions(ctx context.Context) ([]string, error) {
var customClaimsKeys []string
err := s.db.
WithContext(ctx).
Model(&model.CustomClaim{}).
Group("key").
Order("COUNT(*) DESC").
Pluck("key", &customClaimsKeys).Error
return customClaimsKeys, err
}

View File

@@ -1,566 +0,0 @@
package service
import (
"context"
"encoding/json"
"fmt"
"regexp"
"strconv"
"strings"
"github.com/pocket-id/pocket-id/backend/internal/common"
"github.com/pocket-id/pocket-id/backend/internal/dto"
"github.com/pocket-id/pocket-id/backend/internal/model"
"gorm.io/gorm"
)
type CustomFieldValueService struct {
db *gorm.DB
appConfigService *AppConfigService
}
func NewCustomFieldValueService(db *gorm.DB, appConfigService *AppConfigService) *CustomFieldValueService {
return &CustomFieldValueService{db: db, appConfigService: appConfigService}
}
func customFieldAppliesTo(field dto.CustomFieldDto, idType idType) bool {
switch field.Target {
case dto.CustomFieldTargetBoth:
return true
case dto.CustomFieldTargetUser:
return idType == UserID
case dto.CustomFieldTargetGroup:
return idType == UserGroupID
default:
return false
}
}
// isReservedOIDCClaim checks if a key is reserved by standard OIDC claims, e.g. email or preferred_username.
func isReservedOIDCClaim(key string) bool {
switch key {
case "given_name",
"family_name",
"name",
"email",
"email_verified",
"preferred_username",
"display_name",
"groups",
TokenTypeClaim,
"sub",
"iss",
"aud",
"exp",
"iat",
"auth_time",
"nonce",
"acr",
"amr",
"azp",
"nbf",
"jti":
return true
default:
return false
}
}
// idType is the type of the id used to identify the user or user group
type idType string
const (
UserID idType = "user_id"
UserGroupID idType = "user_group_id"
)
// UpdateCustomFieldValuesForUser updates the custom field values for a user.
func (s *CustomFieldValueService) UpdateCustomFieldValuesForUser(ctx context.Context, userID string, customFieldValues []dto.CustomFieldValueCreateDto) ([]model.CustomFieldValue, error) {
tx := s.db.Begin()
defer func() {
tx.Rollback()
}()
updatedCustomFieldValues, err := s.updateCustomFieldValuesInternal(ctx, UserID, userID, customFieldValues, tx)
if err != nil {
return nil, err
}
err = tx.Commit().Error
if err != nil {
return nil, err
}
return updatedCustomFieldValues, nil
}
// updateSelfEditableCustomFieldValuesForUser updates only the custom fields a user is allowed to edit themselves.
func (s *CustomFieldValueService) updateSelfEditableCustomFieldValuesForUser(ctx context.Context, userID string, customFieldValues []dto.CustomFieldValueCreateDto, tx *gorm.DB) ([]model.CustomFieldValue, error) {
fields, err := s.GetConfiguredCustomFieldsForTarget(UserID)
if err != nil {
return nil, err
}
editableFields := make([]dto.CustomFieldDto, 0, len(fields))
for _, field := range fields {
if !field.UserEditable {
continue
}
editableFields = append(editableFields, field)
}
return s.updateCustomFieldValuesForFields(ctx, UserID, userID, customFieldValues, editableFields, tx)
}
func (s *CustomFieldValueService) updateCustomFieldValuesForFields(ctx context.Context, idType idType, ownerID string, customFieldValues []dto.CustomFieldValueCreateDto, fields []dto.CustomFieldDto, tx *gorm.DB) ([]model.CustomFieldValue, error) {
normalizedCustomFieldValues, err := validateCustomFieldValuesAgainstFields(customFieldValues, fields)
if err != nil {
return nil, err
}
fieldIDs := make([]string, 0, len(fields))
for _, field := range fields {
fieldIDs = append(fieldIDs, field.ID)
}
valuesByFieldID := make(map[string]dto.CustomFieldValueCreateDto, len(normalizedCustomFieldValues))
fieldIDsToKeep := make([]string, 0, len(normalizedCustomFieldValues))
for _, customFieldValue := range normalizedCustomFieldValues {
valuesByFieldID[customFieldValue.CustomFieldID] = customFieldValue
fieldIDsToKeep = append(fieldIDsToKeep, customFieldValue.CustomFieldID)
}
if len(fieldIDs) > 0 {
deleteQuery := tx.WithContext(ctx).
Where(string(idType)+" = ? AND custom_field_id IN ?", ownerID, fieldIDs)
if len(fieldIDsToKeep) > 0 {
deleteQuery = deleteQuery.Where("custom_field_id NOT IN ?", fieldIDsToKeep)
}
if err := deleteQuery.Delete(&model.CustomFieldValue{}).Error; err != nil {
return nil, err
}
}
for _, customFieldValue := range valuesByFieldID {
value := model.CustomFieldValue{
CustomFieldID: customFieldValue.CustomFieldID,
Value: customFieldValue.Value,
}
switch idType {
case UserID:
value.UserID = &ownerID
case UserGroupID:
value.UserGroupID = &ownerID
}
if err := tx.
WithContext(ctx).
Where(string(idType)+" = ? AND custom_field_id = ?", ownerID, customFieldValue.CustomFieldID).
Assign(&value).
FirstOrCreate(&model.CustomFieldValue{}).
Error; err != nil {
return nil, err
}
}
switch idType {
case UserID:
return s.GetCustomFieldValuesForUser(ctx, ownerID, tx)
case UserGroupID:
return s.GetCustomFieldValuesForUserGroup(ctx, ownerID, tx)
default:
return nil, nil
}
}
// UpdateCustomFieldValuesForUserGroup updates the custom field values for a user group.
func (s *CustomFieldValueService) UpdateCustomFieldValuesForUserGroup(ctx context.Context, userGroupID string, customFieldValues []dto.CustomFieldValueCreateDto) ([]model.CustomFieldValue, error) {
tx := s.db.Begin()
defer func() {
tx.Rollback()
}()
updatedCustomFieldValues, err := s.updateCustomFieldValuesInternal(ctx, UserGroupID, userGroupID, customFieldValues, tx)
if err != nil {
return nil, err
}
err = tx.Commit().Error
if err != nil {
return nil, err
}
return updatedCustomFieldValues, nil
}
// updateCustomFieldValuesInternal updates the custom field values for a user or user group within a transaction.
func (s *CustomFieldValueService) updateCustomFieldValuesInternal(ctx context.Context, idType idType, value string, customFieldValues []dto.CustomFieldValueCreateDto, tx *gorm.DB) ([]model.CustomFieldValue, error) {
fields, err := s.GetConfiguredCustomFieldsForTarget(idType)
if err != nil {
return nil, err
}
customFieldValues, err = validateCustomFieldValuesAgainstFields(customFieldValues, fields)
if err != nil {
return nil, err
}
var existingCustomFieldValues []model.CustomFieldValue
err = tx.
WithContext(ctx).
Where(string(idType), value).
Find(&existingCustomFieldValues).
Error
if err != nil {
return nil, err
}
// Delete values that are not in the new list.
for _, existingCustomFieldValue := range existingCustomFieldValues {
found := false
for _, customFieldValue := range customFieldValues {
if customFieldValue.CustomFieldID == existingCustomFieldValue.CustomFieldID {
found = true
break
}
}
if !found {
err = tx.
WithContext(ctx).
Delete(&existingCustomFieldValue).
Error
if err != nil {
return nil, err
}
}
}
// Add or update custom field values.
for _, inputCustomFieldValue := range customFieldValues {
customFieldValue := model.CustomFieldValue{
CustomFieldID: inputCustomFieldValue.CustomFieldID,
Value: inputCustomFieldValue.Value,
}
switch idType {
case UserID:
customFieldValue.UserID = &value
case UserGroupID:
customFieldValue.UserGroupID = &value
}
// Update the value if it already exists or create a new one.
err = tx.
WithContext(ctx).
Where(string(idType)+" = ? AND custom_field_id = ?", value, inputCustomFieldValue.CustomFieldID).
Assign(&customFieldValue).
FirstOrCreate(&model.CustomFieldValue{}).
Error
if err != nil {
return nil, err
}
}
// Get the updated custom field values.
var updatedCustomFieldValues []model.CustomFieldValue
err = tx.
WithContext(ctx).
Where(string(idType)+" = ?", value).
Find(&updatedCustomFieldValues).
Error
if err != nil {
return nil, err
}
return updatedCustomFieldValues, nil
}
func (s *CustomFieldValueService) GetCustomFieldValuesForUser(ctx context.Context, userID string, tx *gorm.DB) ([]model.CustomFieldValue, error) {
var customFieldValues []model.CustomFieldValue
err := tx.
WithContext(ctx).
Where("user_id = ?", userID).
Find(&customFieldValues).
Error
if err != nil {
return nil, err
}
return s.applyDefaultCustomFieldValues(UserID, userID, customFieldValues)
}
func (s *CustomFieldValueService) GetCustomFieldValuesForUserGroup(ctx context.Context, userGroupID string, tx *gorm.DB) ([]model.CustomFieldValue, error) {
var customFieldValues []model.CustomFieldValue
err := tx.
WithContext(ctx).
Where("user_group_id = ?", userGroupID).
Find(&customFieldValues).
Error
if err != nil {
return nil, err
}
return s.applyDefaultCustomFieldValues(UserGroupID, userGroupID, customFieldValues)
}
// GetCustomFieldValuesForUserWithUserGroups returns the custom field values of a user and all user groups the user is a member of,
// prioritizing the user's values over user group values for the same custom field.
func (s *CustomFieldValueService) GetCustomFieldValuesForUserWithUserGroups(ctx context.Context, userID string, tx *gorm.DB) ([]model.CustomFieldValue, error) {
customFieldValues, err := s.GetCustomFieldValuesForUser(ctx, userID, tx)
if err != nil {
return nil, err
}
valuesByFieldID := make(map[string]model.CustomFieldValue)
for _, customFieldValue := range customFieldValues {
valuesByFieldID[customFieldValue.CustomFieldID] = customFieldValue
}
// Get all user groups of the user
var userGroupsOfUser []model.UserGroup
err = tx.
WithContext(ctx).
Preload("CustomFieldValues").
Joins("JOIN user_groups_users ON user_groups_users.user_group_id = user_groups.id").
Where("user_groups_users.user_id = ?", userID).
Find(&userGroupsOfUser).Error
if err != nil {
return nil, err
}
// Add only non-duplicate custom fields from user groups
for _, userGroup := range userGroupsOfUser {
groupCustomFieldValues, err := s.applyDefaultCustomFieldValues(UserGroupID, userGroup.ID, userGroup.CustomFieldValues)
if err != nil {
return nil, err
}
for _, groupCustomFieldValue := range groupCustomFieldValues {
if _, exists := valuesByFieldID[groupCustomFieldValue.CustomFieldID]; !exists {
valuesByFieldID[groupCustomFieldValue.CustomFieldID] = groupCustomFieldValue
}
}
}
finalCustomFieldValues := make([]model.CustomFieldValue, 0, len(valuesByFieldID))
for _, customFieldValue := range valuesByFieldID {
finalCustomFieldValues = append(finalCustomFieldValues, customFieldValue)
}
return finalCustomFieldValues, nil
}
func (s *CustomFieldValueService) applyDefaultCustomFieldValues(idType idType, ownerID string, customFieldValues []model.CustomFieldValue) ([]model.CustomFieldValue, error) {
fields, err := s.GetConfiguredCustomFieldsForTarget(idType)
if err != nil {
return nil, err
}
valuesByFieldID := make(map[string]struct{}, len(customFieldValues))
for _, customFieldValue := range customFieldValues {
valuesByFieldID[customFieldValue.CustomFieldID] = struct{}{}
}
effectiveCustomFieldValues := append([]model.CustomFieldValue{}, customFieldValues...)
for _, field := range fields {
if field.DefaultValue == "" {
continue
}
if _, ok := valuesByFieldID[field.ID]; ok {
continue
}
customFieldValue := model.CustomFieldValue{
CustomFieldID: field.ID,
Value: field.DefaultValue,
}
switch idType {
case UserID:
customFieldValue.UserID = &ownerID
case UserGroupID:
customFieldValue.UserGroupID = &ownerID
}
effectiveCustomFieldValues = append(effectiveCustomFieldValues, customFieldValue)
}
return effectiveCustomFieldValues, nil
}
func (s *CustomFieldValueService) GetConfiguredCustomFieldsForTarget(idType idType) ([]dto.CustomFieldDto, error) {
fields, err := ParseCustomFieldDefinitions(s.appConfigService.GetDbConfig().CustomFields.Value)
if err != nil {
return nil, err
}
filteredFields := make([]dto.CustomFieldDto, 0, len(fields))
for _, field := range fields {
if customFieldAppliesTo(field, idType) {
filteredFields = append(filteredFields, field)
}
}
return filteredFields, nil
}
func ParseCustomFieldDefinitions(value string) ([]dto.CustomFieldDto, error) {
if value == "" {
return nil, nil
}
var fields []dto.CustomFieldDto
if err := json.Unmarshal([]byte(value), &fields); err != nil {
return nil, &common.CustomFieldValidationError{Message: fmt.Sprintf("invalid custom fields JSON: %v", err)}
}
seenIDs := make(map[string]struct{}, len(fields))
seenKeys := make(map[string]struct{}, len(fields))
for i, field := range fields {
field.Key = strings.TrimSpace(field.Key)
fields[i].Key = field.Key
if err := dto.ValidateStruct(field); err != nil {
return nil, &common.CustomFieldValidationError{Message: fmt.Sprintf("custom field %s is invalid: %v", field.Key, err)}
}
if _, ok := seenIDs[field.ID]; ok {
return nil, &common.CustomFieldValidationError{Message: fmt.Sprintf("custom field id %s is already defined", field.ID)}
}
seenIDs[field.ID] = struct{}{}
if isReservedOIDCClaim(field.Key) {
return nil, &common.ReservedCustomFieldError{Key: field.Key}
}
if _, ok := seenKeys[field.Key]; ok {
return nil, &common.DuplicateCustomFieldError{Key: field.Key}
}
seenKeys[field.Key] = struct{}{}
if field.ValidationRegex != "" {
if field.Type != dto.CustomFieldTypeString {
return nil, &common.CustomFieldValidationError{Message: fmt.Sprintf("custom field %s can only use regex validation for text values", field.Key)}
}
}
if field.Required && field.DefaultValue == "" {
return nil, &common.CustomFieldValidationError{Message: fmt.Sprintf("custom field %s requires a default value", field.Key)}
}
if field.DefaultValue != "" {
if err := validateCustomFieldValue(dto.CustomFieldValueCreateDto{CustomFieldID: field.ID, Value: field.DefaultValue}, field); err != nil {
return nil, err
}
}
}
return fields, nil
}
func validateCustomFieldValuesAgainstFields(customFieldValues []dto.CustomFieldValueCreateDto, fields []dto.CustomFieldDto) ([]dto.CustomFieldValueCreateDto, error) {
fieldsByID := make(map[string]dto.CustomFieldDto, len(fields))
for _, field := range fields {
fieldsByID[field.ID] = field
}
valuesByFieldID := make(map[string]dto.CustomFieldValueCreateDto, len(customFieldValues))
for _, customFieldValue := range customFieldValues {
field, ok := fieldsByID[customFieldValue.CustomFieldID]
if !ok {
continue
}
customFieldValue.CustomFieldID = field.ID
customFieldValue.Key = field.Key
if _, ok := valuesByFieldID[customFieldValue.CustomFieldID]; ok {
return nil, &common.DuplicateCustomFieldError{Key: field.Key}
}
if field.Type != dto.CustomFieldTypeBoolean && customFieldValue.Value == "" && !field.Required {
continue
}
if err := validateCustomFieldValue(customFieldValue, field); err != nil {
return nil, err
}
valuesByFieldID[customFieldValue.CustomFieldID] = customFieldValue
}
normalizedCustomFieldValues := make([]dto.CustomFieldValueCreateDto, 0, len(valuesByFieldID))
for _, field := range fields {
customFieldValue, ok := valuesByFieldID[field.ID]
if ok {
normalizedCustomFieldValues = append(normalizedCustomFieldValues, customFieldValue)
continue
}
if field.DefaultValue != "" {
normalizedCustomFieldValues = append(normalizedCustomFieldValues, dto.CustomFieldValueCreateDto{
CustomFieldID: field.ID,
Key: field.Key,
Value: field.DefaultValue,
})
continue
}
if field.Required {
return nil, &common.CustomFieldValidationError{Message: fmt.Sprintf("custom field %s is required", field.Key)}
}
}
return normalizedCustomFieldValues, nil
}
func validateCustomFieldValue(customFieldValue dto.CustomFieldValueCreateDto, field dto.CustomFieldDto) error {
if field.Required && field.Type != dto.CustomFieldTypeBoolean && customFieldValue.Value == "" {
return &common.CustomFieldValidationError{Message: fmt.Sprintf("custom field %s is required", field.Key)}
}
switch field.Type {
case dto.CustomFieldTypeString:
if field.ValidationRegex != "" {
matches, err := regexp.MatchString(field.ValidationRegex, customFieldValue.Value)
if err != nil {
return &common.CustomFieldValidationError{Message: fmt.Sprintf("custom field %s has invalid validation regex: %v", field.Key, err)}
}
if !matches {
if field.ValidationErrorMessage != "" {
return &common.CustomFieldValidationError{Message: field.ValidationErrorMessage}
}
return &common.CustomFieldValidationError{Message: fmt.Sprintf("custom field %s does not match the required format", field.Key)}
}
}
return nil
case dto.CustomFieldTypeNumber:
if _, err := strconv.ParseFloat(customFieldValue.Value, 64); err != nil {
return &common.CustomFieldValidationError{Message: fmt.Sprintf("custom field %s must be a number", field.Key)}
}
case dto.CustomFieldTypeBoolean:
if _, err := strconv.ParseBool(customFieldValue.Value); err != nil {
return &common.CustomFieldValidationError{Message: fmt.Sprintf("custom field %s must be a boolean", field.Key)}
}
}
return nil
}
func customFieldValueTokenValue(customFieldValue model.CustomFieldValue, field *dto.CustomFieldDto) (any, error) {
if field != nil {
switch field.Type {
case dto.CustomFieldTypeString:
return customFieldValue.Value, nil
case dto.CustomFieldTypeNumber:
value, err := strconv.ParseFloat(customFieldValue.Value, 64)
if err != nil {
return nil, err
}
return value, nil
case dto.CustomFieldTypeBoolean:
value, err := strconv.ParseBool(customFieldValue.Value)
if err != nil {
return nil, err
}
return value, nil
}
}
var jsonValue any
if err := json.Unmarshal([]byte(customFieldValue.Value), &jsonValue); err == nil {
return jsonValue, nil
}
return customFieldValue.Value, nil
}

View File

@@ -1,183 +0,0 @@
package service
import (
"testing"
"github.com/pocket-id/pocket-id/backend/internal/dto"
"github.com/pocket-id/pocket-id/backend/internal/model"
testutils "github.com/pocket-id/pocket-id/backend/internal/utils/testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestParseCustomFieldDefinitionsValidatesRegex(t *testing.T) {
_, err := ParseCustomFieldDefinitions(`[{"id":"89bc9c8f-2cd8-4cfd-82c5-5fa14e874f03","key":"department","displayName":"Department","type":"string","target":"user","required":false,"validationRegex":"["}]`)
require.Error(t, err)
assert.Contains(t, err.Error(), "invalid validation regex")
_, err = ParseCustomFieldDefinitions(`[{"id":"353555d9-7de8-4320-a10f-5ca4c122a363","key":"age","displayName":"Age","type":"number","target":"user","required":false,"validationRegex":"^[0-9]+$"}]`)
require.Error(t, err)
assert.Contains(t, err.Error(), "can only use regex validation for text values")
_, err = ParseCustomFieldDefinitions(`[{"id":"fe2bc740-6193-4ef2-b1e6-2408a691a98c","key":"department","displayName":"Department","type":"string","target":"user","required":true}]`)
require.Error(t, err)
assert.Contains(t, err.Error(), "requires a default value")
_, err = ParseCustomFieldDefinitions(`[{"id":"be42096c-3dc0-4a9c-8074-086b9f866286","key":"department","displayName":"Department","type":"string","target":"user","required":true,"validationRegex":"^ENG-[0-9]+$","defaultValue":"Sales"}]`)
require.Error(t, err)
assert.Contains(t, err.Error(), "does not match the required format")
}
func TestParseCustomFieldDefinitionsValidatesKey(t *testing.T) {
fields, err := ParseCustomFieldDefinitions(`[{"id":"36c1e786-c9e9-4daf-ab51-502ab8efc9ea","key":" department ","displayName":"Department","type":"string","target":"user","required":false}]`)
require.NoError(t, err)
require.Len(t, fields, 1)
assert.Equal(t, "department", fields[0].Key)
_, err = ParseCustomFieldDefinitions(`[{"id":"c0e41fb3-59c7-488a-8edb-57e94e9f15ac","key":" ","displayName":"Empty","type":"string","target":"user","required":false}]`)
require.Error(t, err)
assert.Contains(t, err.Error(), "custom field key is required")
_, err = ParseCustomFieldDefinitions(`[{"id":"8b2ff8eb-bcf5-4866-b690-1a5b6f9da56c","key":"email","displayName":"Email","type":"string","target":"user","required":false}]`)
require.Error(t, err)
assert.Contains(t, err.Error(), "reserved")
}
func TestValidateCustomFieldValuesAgainstFieldsAppliesRegex(t *testing.T) {
fields := []dto.CustomFieldDto{
{
ID: "4ca0513e-e223-4900-8c5e-303acac4d021",
Key: "employee_id",
DisplayName: "Employee ID",
Type: dto.CustomFieldTypeString,
ValidationRegex: "^EMP-[0-9]+$",
ValidationErrorMessage: "Employee ID must start with EMP-",
},
}
_, err := validateCustomFieldValuesAgainstFields([]dto.CustomFieldValueCreateDto{
{CustomFieldID: "4ca0513e-e223-4900-8c5e-303acac4d021", Value: "INVALID"},
}, fields)
require.Error(t, err)
assert.Equal(t, "Employee ID must start with EMP-", err.Error())
values, err := validateCustomFieldValuesAgainstFields([]dto.CustomFieldValueCreateDto{
{CustomFieldID: "4ca0513e-e223-4900-8c5e-303acac4d021", Value: "EMP-123"},
}, fields)
require.NoError(t, err)
require.Len(t, values, 1)
assert.Equal(t, "4ca0513e-e223-4900-8c5e-303acac4d021", values[0].CustomFieldID)
assert.Equal(t, "EMP-123", values[0].Value)
}
func TestValidateCustomFieldValuesAgainstFieldsUsesDefaultValue(t *testing.T) {
fields := []dto.CustomFieldDto{
{
ID: "4225f448-f189-47d5-97d6-90292cc5bf9e",
Key: "department",
DisplayName: "Department",
Type: dto.CustomFieldTypeString,
Required: true,
DefaultValue: "Engineering",
},
{
ID: "398c23a4-c2e7-4b87-b6df-ed6bf1810579",
Key: "active",
DisplayName: "Active",
Type: dto.CustomFieldTypeBoolean,
Required: true,
DefaultValue: "false",
},
}
values, err := validateCustomFieldValuesAgainstFields(nil, fields)
require.NoError(t, err)
require.Len(t, values, 2)
assert.Equal(t, dto.CustomFieldValueCreateDto{CustomFieldID: "4225f448-f189-47d5-97d6-90292cc5bf9e", Key: "department", Value: "Engineering"}, values[0])
assert.Equal(t, dto.CustomFieldValueCreateDto{CustomFieldID: "398c23a4-c2e7-4b87-b6df-ed6bf1810579", Key: "active", Value: "false"}, values[1])
}
func TestGetCustomFieldValuesAppliesDefaultValues(t *testing.T) {
db := testutils.NewDatabaseForTest(t)
user := model.User{Username: "alice", FirstName: "Alice", DisplayName: "Alice"}
require.NoError(t, db.Create(&user).Error)
group := model.UserGroup{Name: "engineering", FriendlyName: "Engineering"}
require.NoError(t, db.Create(&group).Error)
require.NoError(t, db.Model(&user).Association("UserGroups").Append(&group))
appConfigService := NewTestAppConfigService(&model.AppConfig{
CustomFields: model.AppConfigVariable{Value: `[
{"id":"81b3c82a-46c8-49c0-9559-a31df8586ef1","key":"department","displayName":"Department","type":"string","target":"user","required":false,"defaultValue":"Engineering"},
{"id":"b064d601-bc94-4ecf-a5cb-b783f3de0281","key":"group_label","displayName":"Group label","type":"string","target":"group","required":false,"defaultValue":"Employee"}
]`},
})
service := NewCustomFieldValueService(db, appConfigService)
userValues, err := service.GetCustomFieldValuesForUser(t.Context(), user.ID, db)
require.NoError(t, err)
require.Len(t, userValues, 1)
assert.Equal(t, "81b3c82a-46c8-49c0-9559-a31df8586ef1", userValues[0].CustomFieldID)
assert.Equal(t, "Engineering", userValues[0].Value)
require.NotNil(t, userValues[0].UserID)
groupValues, err := service.GetCustomFieldValuesForUserGroup(t.Context(), group.ID, db)
require.NoError(t, err)
require.Len(t, groupValues, 1)
assert.Equal(t, "b064d601-bc94-4ecf-a5cb-b783f3de0281", groupValues[0].CustomFieldID)
assert.Equal(t, "Employee", groupValues[0].Value)
require.NotNil(t, groupValues[0].UserGroupID)
combinedValues, err := service.GetCustomFieldValuesForUserWithUserGroups(t.Context(), user.ID, db)
require.NoError(t, err)
require.Len(t, combinedValues, 2)
valuesByFieldID := map[string]string{}
for _, value := range combinedValues {
valuesByFieldID[value.CustomFieldID] = value.Value
}
assert.Equal(t, "Engineering", valuesByFieldID["81b3c82a-46c8-49c0-9559-a31df8586ef1"])
assert.Equal(t, "Employee", valuesByFieldID["b064d601-bc94-4ecf-a5cb-b783f3de0281"])
}
func TestUpdateSelfEditableCustomFieldValuesForUser(t *testing.T) {
db := testutils.NewDatabaseForTest(t)
user := model.User{Username: "alice", FirstName: "Alice", DisplayName: "Alice"}
require.NoError(t, db.Create(&user).Error)
require.NoError(t, db.Create([]model.CustomFieldValue{
{UserID: &user.ID, CustomFieldID: "608a9c35-2330-433d-bf33-c46f065d5d06", Value: "old"},
{UserID: &user.ID, CustomFieldID: "8501e000-09bb-428c-8be3-b0d3b0c682fd", Value: "admin"},
}).Error)
appConfigService := NewTestAppConfigService(&model.AppConfig{
CustomFields: model.AppConfigVariable{Value: `[
{"id":"608a9c35-2330-433d-bf33-c46f065d5d06","key":"nickname","displayName":"Nickname","type":"string","target":"user","required":false,"userEditable":true},
{"id":"8501e000-09bb-428c-8be3-b0d3b0c682fd","key":"cost_center","displayName":"Cost center","type":"string","target":"user","required":false,"userEditable":false}
]`},
})
service := NewCustomFieldValueService(db, appConfigService)
tx := db.Begin()
updatedValues, err := service.updateSelfEditableCustomFieldValuesForUser(t.Context(), user.ID, []dto.CustomFieldValueCreateDto{
{CustomFieldID: "608a9c35-2330-433d-bf33-c46f065d5d06", Value: "new"},
}, tx)
require.NoError(t, err)
require.NoError(t, tx.Commit().Error)
valuesByFieldID := map[string]string{}
for _, value := range updatedValues {
valuesByFieldID[value.CustomFieldID] = value.Value
}
assert.Equal(t, "new", valuesByFieldID["608a9c35-2330-433d-bf33-c46f065d5d06"])
assert.Equal(t, "admin", valuesByFieldID["8501e000-09bb-428c-8be3-b0d3b0c682fd"])
tx = db.Begin()
_, err = service.updateSelfEditableCustomFieldValuesForUser(t.Context(), user.ID, []dto.CustomFieldValueCreateDto{
{CustomFieldID: "invalid", Value: "user"},
}, tx)
require.Error(t, err)
assert.Contains(t, err.Error(), "not configured")
tx.Rollback()
var costCenter model.CustomFieldValue
require.NoError(t, db.Where("user_id = ? AND custom_field_id = ?", user.ID, "8501e000-09bb-428c-8be3-b0d3b0c682fd").First(&costCenter).Error)
assert.Equal(t, "admin", costCenter.Value)
}

View File

@@ -8,7 +8,6 @@ import (
"crypto/elliptic"
"crypto/rand"
"encoding/base64"
"encoding/json"
"fmt"
"log/slog"
"path"
@@ -18,7 +17,6 @@ import (
"github.com/lestrrat-go/jwx/v3/jwa"
"github.com/lestrrat-go/jwx/v3/jwk"
"github.com/lestrrat-go/jwx/v3/jwt"
"github.com/pocket-id/pocket-id/backend/internal/dto"
"gorm.io/gorm"
"github.com/pocket-id/pocket-id/backend/internal/common"
@@ -247,22 +245,20 @@ func (s *TestService) SeedDatabase(baseURL string) error {
authCodes := []model.OidcAuthorizationCode{
{
Code: "auth-code",
Scope: "openid profile",
Nonce: "nonce",
AuthenticationMethod: AuthenticationMethodPhishingResistant,
ExpiresAt: datatype.DateTime(time.Now().Add(1 * time.Hour)),
UserID: users[0].ID,
ClientID: oidcClients[0].ID,
Code: "auth-code",
Scope: "openid profile",
Nonce: "nonce",
ExpiresAt: datatype.DateTime(time.Now().Add(1 * time.Hour)),
UserID: users[0].ID,
ClientID: oidcClients[0].ID,
},
{
Code: "federated",
Scope: "openid profile",
Nonce: "nonce",
AuthenticationMethod: AuthenticationMethodPhishingResistant,
ExpiresAt: datatype.DateTime(time.Now().Add(1 * time.Hour)),
UserID: users[1].ID,
ClientID: oidcClients[3].ID,
Code: "federated",
Scope: "openid profile",
Nonce: "nonce",
ExpiresAt: datatype.DateTime(time.Now().Add(1 * time.Hour)),
UserID: users[1].ID,
ClientID: oidcClients[3].ID,
},
}
for _, authCode := range authCodes {
@@ -272,13 +268,11 @@ func (s *TestService) SeedDatabase(baseURL string) error {
}
refreshToken := model.OidcRefreshToken{
Token: utils.CreateSha256Hash("ou87UDg249r1StBLYkMEqy9TXDbV5HmGuDpMcZDo"),
IdTokenJti: new("dd75f6f6-ce0a-44b7-a645-7de390ccd2fa"),
AuthenticationMethod: AuthenticationMethodPhishingResistant,
ExpiresAt: datatype.DateTime(time.Now().Add(24 * time.Hour)),
Scope: "openid profile email",
UserID: users[0].ID,
ClientID: oidcClients[0].ID,
Token: utils.CreateSha256Hash("ou87UDg249r1StBLYkMEqy9TXDbV5HmGuDpMcZDo"),
ExpiresAt: datatype.DateTime(time.Now().Add(24 * time.Hour)),
Scope: "openid profile email",
UserID: users[0].ID,
ClientID: oidcClients[0].ID,
}
if err := tx.Create(&refreshToken).Error; err != nil {
return err
@@ -565,56 +559,6 @@ func (s *TestService) ResetAppConfig(ctx context.Context) error {
return err
}
// Add custom fields
customFields := []dto.CustomFieldDto{
{
ID: "189356b1-57f3-4c14-bd59-3ae1132a36d1",
Key: "department",
Type: "string",
UserEditable: true,
DisplayName: "Department",
Target: "user",
ValidationRegex: "^[A-Za-z]+$",
ValidationErrorMessage: "Department must only contain letters",
},
{
ID: "0b68d19a-bb72-4750-84b4-2f0992f5200c",
Key: "nickname",
Type: "string",
UserEditable: true,
Required: true,
DisplayName: "Nickname",
Target: "user",
DefaultValue: "to-remove",
},
{
ID: "8d081fd8-6a51-45a1-8051-04c3b043f5bd",
Key: "elevatedRights",
Type: "boolean",
DisplayName: "Elevated Rights",
Target: "group",
},
{
ID: "3d7c6054-e146-48cb-b2d3-7d7897dcbc51",
Key: "internalId",
Type: "number",
DefaultValue: "0",
UserEditable: false,
DisplayName: "Internal ID",
Target: "both",
Required: true,
},
}
customFieldsJSON, err := json.Marshal(&customFields)
if err != nil {
return err
}
err = s.appConfigService.UpdateAppConfigValues(ctx, "customFields", string(customFieldsJSON))
if err != nil {
return err
}
// Reload the app config from the database after resetting the values
err = s.appConfigService.LoadDbConfig(ctx)
if err != nil {

View File

@@ -32,15 +32,6 @@ const (
// RefreshTokenClaim is the claim used for the refresh token's value
RefreshTokenClaim = "rt"
// AuthenticationMethodsClaim is the claim used to identify how the user authenticated
AuthenticationMethodsClaim = "amr"
// AuthenticationMethodPhishingResistant identifies phishing-resistant authentication, such as passkeys
AuthenticationMethodPhishingResistant = "phr"
// AuthenticationMethodOneTimePassword identifies one-time password/code authentication
AuthenticationMethodOneTimePassword = "otp"
// OAuthAccessTokenJWTType identifies a JWT as an OAuth access token
OAuthAccessTokenJWTType = "oauth-access-token" //nolint:gosec
@@ -196,8 +187,7 @@ func (s *JwtService) SetKey(privateKey jwk.Key) error {
return nil
}
func (s *JwtService) GenerateAccessToken(user model.User, authenticationMethod string) (string, error) {
func (s *JwtService) GenerateAccessToken(user model.User) (string, error) {
now := time.Now()
token, err := jwt.NewBuilder().
Subject(user.ID).
@@ -225,11 +215,6 @@ func (s *JwtService) GenerateAccessToken(user model.User, authenticationMethod s
return "", fmt.Errorf("failed to set 'isAdmin' claim in token: %w", err)
}
err = SetAuthenticationMethods(token, authenticationMethod)
if err != nil {
return "", fmt.Errorf("failed to set '%s' claim in token: %w", AuthenticationMethodsClaim, err)
}
alg, _ := s.privateKey.Algorithm()
signed, err := jwt.Sign(token, jwt.WithKey(alg, s.privateKey))
if err != nil {
@@ -258,65 +243,59 @@ func (s *JwtService) VerifyAccessToken(tokenString string) (jwt.Token, error) {
}
// BuildIDToken creates an ID token with all claims
func (s *JwtService) BuildIDToken(userClaims map[string]any, clientID string, nonce string, authenticationMethod string) (jwt.Token, string, error) {
func (s *JwtService) BuildIDToken(userClaims map[string]any, clientID string, nonce string) (jwt.Token, error) {
now := time.Now()
jti := uuid.New().String()
token, err := jwt.NewBuilder().
Expiration(now.Add(1 * time.Hour)).
IssuedAt(now).
Issuer(s.envConfig.AppURL).
JwtID(jti).
JwtID(uuid.New().String()).
Build()
if err != nil {
return nil, "", fmt.Errorf("failed to build token: %w", err)
return nil, fmt.Errorf("failed to build token: %w", err)
}
err = SetAudienceString(token, clientID)
if err != nil {
return nil, "", fmt.Errorf("failed to set 'aud' claim in token: %w", err)
return nil, fmt.Errorf("failed to set 'aud' claim in token: %w", err)
}
err = SetTokenType(token, IDTokenJWTType)
if err != nil {
return nil, "", fmt.Errorf("failed to set 'type' claim in token: %w", err)
}
err = SetAuthenticationMethods(token, authenticationMethod)
if err != nil {
return nil, "", fmt.Errorf("failed to set '%s' claim in token: %w", AuthenticationMethodsClaim, err)
return nil, fmt.Errorf("failed to set 'type' claim in token: %w", err)
}
for k, v := range userClaims {
err = token.Set(k, v)
if err != nil {
return nil, "", fmt.Errorf("failed to set claim '%s': %w", k, err)
return nil, fmt.Errorf("failed to set claim '%s': %w", k, err)
}
}
if nonce != "" {
err = token.Set("nonce", nonce)
if err != nil {
return nil, "", fmt.Errorf("failed to set claim 'nonce': %w", err)
return nil, fmt.Errorf("failed to set claim 'nonce': %w", err)
}
}
return token, jti, nil
return token, nil
}
// GenerateIDToken creates and signs an ID token
func (s *JwtService) GenerateIDToken(userClaims map[string]any, clientID string, nonce string, authenticationMethod string) (signedToken, jti string, err error) {
token, jti, err := s.BuildIDToken(userClaims, clientID, nonce, authenticationMethod)
func (s *JwtService) GenerateIDToken(userClaims map[string]any, clientID string, nonce string) (string, error) {
token, err := s.BuildIDToken(userClaims, clientID, nonce)
if err != nil {
return "", "", err
return "", 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 "", fmt.Errorf("failed to sign token: %w", err)
}
return string(signed), jti, nil
return string(signed), nil
}
func (s *JwtService) VerifyIdToken(tokenString string, acceptExpiredTokens bool) (jwt.Token, error) {
@@ -353,7 +332,7 @@ func (s *JwtService) VerifyIdToken(tokenString string, acceptExpiredTokens bool)
}
// BuildOAuthAccessToken creates an OAuth access token with all claims
func (s *JwtService) BuildOAuthAccessToken(user model.User, clientID string, authenticationMethod string) (jwt.Token, error) {
func (s *JwtService) BuildOAuthAccessToken(user model.User, clientID string) (jwt.Token, error) {
now := time.Now()
token, err := jwt.NewBuilder().
Subject(user.ID).
@@ -376,17 +355,12 @@ func (s *JwtService) BuildOAuthAccessToken(user model.User, clientID string, aut
return nil, fmt.Errorf("failed to set 'type' claim in token: %w", err)
}
err = SetAuthenticationMethods(token, authenticationMethod)
if err != nil {
return nil, fmt.Errorf("failed to set '%s' claim in token: %w", AuthenticationMethodsClaim, err)
}
return token, nil
}
// GenerateOAuthAccessToken creates and signs an OAuth access token
func (s *JwtService) GenerateOAuthAccessToken(user model.User, clientID string, authenticationMethod string) (string, error) {
token, err := s.BuildOAuthAccessToken(user, clientID, authenticationMethod)
func (s *JwtService) GenerateOAuthAccessToken(user model.User, clientID string) (string, error) {
token, err := s.BuildOAuthAccessToken(user, clientID)
if err != nil {
return "", err
}
@@ -560,27 +534,6 @@ func GetIsAdmin(token jwt.Token) (bool, error) {
return isAdmin, nil
}
// GetAuthenticationMethod returns the first authentication method in the "amr" claim in the token
func GetAuthenticationMethod(token jwt.Token) (string, error) {
if !token.Has(AuthenticationMethodsClaim) {
return "", nil
}
var rawAuthenticationMethods []any
err := token.Get(AuthenticationMethodsClaim, &rawAuthenticationMethods)
if err != nil {
return "", fmt.Errorf("failed to get '%s' claim from token: %w", AuthenticationMethodsClaim, err)
}
if len(rawAuthenticationMethods) == 0 {
return "", nil
}
authenticationMethod, ok := rawAuthenticationMethods[0].(string)
if !ok {
return "", fmt.Errorf("invalid '%s' claim in token: expected array of strings", AuthenticationMethodsClaim)
}
return authenticationMethod, nil
}
// SetTokenType sets the "type" claim in the token
func SetTokenType(token jwt.Token, tokenType string) error {
if tokenType == "" {
@@ -598,14 +551,6 @@ func SetIsAdmin(token jwt.Token, isAdmin bool) error {
return token.Set(IsAdminClaim, isAdmin)
}
// SetAuthenticationMethods sets the authentication method references claim in the token
func SetAuthenticationMethods(token jwt.Token, authenticationMethod string) error {
if authenticationMethod == "" {
return nil
}
return token.Set(AuthenticationMethodsClaim, []string{authenticationMethod})
}
// 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 {

View File

@@ -174,7 +174,6 @@ func TestJwtService_Init(t *testing.T) {
_ = 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) {
@@ -309,7 +308,7 @@ func TestGenerateVerifyAccessToken(t *testing.T) {
IsAdmin: false,
}
tokenString, err := service.GenerateAccessToken(user, "")
tokenString, err := service.GenerateAccessToken(user)
require.NoError(t, err, "Failed to generate access token")
assert.NotEmpty(t, tokenString, "Token should not be empty")
@@ -322,9 +321,6 @@ func TestGenerateVerifyAccessToken(t *testing.T) {
isAdmin, err := GetIsAdmin(claims)
_ = assert.NoError(t, err, "Failed to get isAdmin claim") &&
assert.False(t, isAdmin, "isAdmin should be false")
authenticationMethod, err := GetAuthenticationMethod(claims)
_ = assert.NoError(t, err, "Failed to get amr claim") &&
assert.Empty(t, authenticationMethod, "amr should be empty when not specified")
audience, ok := claims.Audience()
_ = assert.True(t, ok, "Audience not found in token") &&
assert.Equal(t, []string{service.envConfig.AppURL}, audience, "Audience should contain the app URL")
@@ -348,7 +344,7 @@ func TestGenerateVerifyAccessToken(t *testing.T) {
IsAdmin: true,
}
tokenString, err := service.GenerateAccessToken(adminUser, "")
tokenString, err := service.GenerateAccessToken(adminUser)
require.NoError(t, err, "Failed to generate access token")
claims, err := service.VerifyAccessToken(tokenString)
@@ -362,24 +358,6 @@ func TestGenerateVerifyAccessToken(t *testing.T) {
assert.Equal(t, adminUser.ID, subject, "Token subject should match user ID")
})
t.Run("sets authentication method references claim when provided", func(t *testing.T) {
service, _, _ := setupJwtService(t, mockConfig)
user := model.User{
Base: model.Base{ID: "user-with-auth-method"},
}
tokenString, err := service.GenerateAccessToken(user, AuthenticationMethodPhishingResistant)
require.NoError(t, err, "Failed to generate access token")
claims, err := service.VerifyAccessToken(tokenString)
require.NoError(t, err, "Failed to verify generated token")
authenticationMethod, err := GetAuthenticationMethod(claims)
_ = assert.NoError(t, err, "Failed to get amr claim") &&
assert.Equal(t, AuthenticationMethodPhishingResistant, authenticationMethod, "amr should match")
})
t.Run("uses session duration from config", func(t *testing.T) {
customMockConfig := NewTestAppConfigService(&model.AppConfig{
SessionDuration: model.AppConfigVariable{Value: "30"}, // 30 minutes
@@ -390,7 +368,7 @@ func TestGenerateVerifyAccessToken(t *testing.T) {
Base: model.Base{ID: "user456"},
}
tokenString, err := service.GenerateAccessToken(user, "")
tokenString, err := service.GenerateAccessToken(user)
require.NoError(t, err, "Failed to generate access token")
claims, err := service.VerifyAccessToken(tokenString)
@@ -418,7 +396,7 @@ func TestGenerateVerifyAccessToken(t *testing.T) {
IsAdmin: true,
}
tokenString, err := service.GenerateAccessToken(user, "")
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")
@@ -455,7 +433,7 @@ func TestGenerateVerifyAccessToken(t *testing.T) {
IsAdmin: true,
}
tokenString, err := service.GenerateAccessToken(user, "")
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")
@@ -492,7 +470,7 @@ func TestGenerateVerifyAccessToken(t *testing.T) {
IsAdmin: true,
}
tokenString, err := service.GenerateAccessToken(user, "")
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")
@@ -530,10 +508,9 @@ func TestGenerateVerifyIdToken(t *testing.T) {
}
const clientID = "test-client-123"
tokenString, jti, err := service.GenerateIDToken(userClaims, clientID, "", "")
tokenString, err := service.GenerateIDToken(userClaims, clientID, "")
require.NoError(t, err, "Failed to generate ID token")
assert.NotEmpty(t, tokenString, "Token should not be empty")
assert.Regexp(t, uuidRegexPattern, jti, "Returned JWT ID is not a UUID")
claims, err := service.VerifyIdToken(tokenString, false)
require.NoError(t, err, "Failed to verify generated ID token")
@@ -550,7 +527,6 @@ func TestGenerateVerifyIdToken(t *testing.T) {
jwtID, ok := claims.JwtID()
_ = assert.True(t, ok, "JWT ID not found in token") &&
assert.Regexp(t, uuidRegexPattern, jwtID, "JWT ID is not a UUID")
assert.Equal(t, jti, jwtID, "Returned JWT ID should match token claim")
expectedExp := time.Now().Add(1 * time.Hour)
expiration, ok := claims.Expiration()
@@ -617,7 +593,7 @@ func TestGenerateVerifyIdToken(t *testing.T) {
const clientID = "test-client-456"
nonce := "random-nonce-value"
tokenString, _, err := service.GenerateIDToken(userClaims, clientID, nonce, "")
tokenString, err := service.GenerateIDToken(userClaims, clientID, nonce)
require.NoError(t, err, "Failed to generate ID token with nonce")
publicKey, err := service.GetPublicJWK()
@@ -638,7 +614,7 @@ func TestGenerateVerifyIdToken(t *testing.T) {
userClaims := map[string]any{
"sub": "user789",
}
tokenString, _, err := service.GenerateIDToken(userClaims, "client-789", "", "")
tokenString, err := service.GenerateIDToken(userClaims, "client-789", "")
require.NoError(t, err, "Failed to generate ID token")
service.envConfig.AppURL = "https://wrong-issuer.com"
@@ -664,7 +640,7 @@ func TestGenerateVerifyIdToken(t *testing.T) {
}
const clientID = "eddsa-client-123"
tokenString, _, err := service.GenerateIDToken(userClaims, clientID, "", "")
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")
@@ -701,7 +677,7 @@ func TestGenerateVerifyIdToken(t *testing.T) {
}
const clientID = "ecdsa-client-123"
tokenString, _, err := service.GenerateIDToken(userClaims, clientID, "", "")
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")
@@ -739,7 +715,7 @@ func TestGenerateVerifyIdToken(t *testing.T) {
}
const clientID = "rsa-client-123"
tokenString, _, err := service.GenerateIDToken(userClaims, clientID, "", "")
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")
@@ -769,7 +745,7 @@ func TestGenerateVerifyOAuthAccessToken(t *testing.T) {
}
const clientID = "test-client-123"
tokenString, err := service.GenerateOAuthAccessToken(user, clientID, "")
tokenString, err := service.GenerateOAuthAccessToken(user, clientID)
require.NoError(t, err, "Failed to generate OAuth access token")
assert.NotEmpty(t, tokenString, "Token should not be empty")
@@ -796,25 +772,6 @@ func TestGenerateVerifyOAuthAccessToken(t *testing.T) {
assert.InDelta(t, 0, timeDiff, 1.0, "Token should expire in approximately 1 hour")
})
t.Run("sets authentication method references claim when provided", func(t *testing.T) {
service, _, _ := setupJwtService(t, mockConfig)
user := model.User{
Base: model.Base{ID: "oauth-amr-user"},
}
const clientID = "test-client-amr"
tokenString, err := service.GenerateOAuthAccessToken(user, clientID, AuthenticationMethodPhishingResistant)
require.NoError(t, err, "Failed to generate OAuth access token")
claims, err := service.VerifyOAuthAccessToken(tokenString)
require.NoError(t, err, "Failed to verify generated OAuth access token")
authenticationMethod, err := GetAuthenticationMethod(claims)
_ = assert.NoError(t, err, "Failed to get amr claim") &&
assert.Equal(t, AuthenticationMethodPhishingResistant, authenticationMethod, "amr should match")
})
t.Run("fails verification for expired token", func(t *testing.T) {
service, _, _ := setupJwtService(t, mockConfig)
@@ -848,7 +805,7 @@ func TestGenerateVerifyOAuthAccessToken(t *testing.T) {
user := model.User{Base: model.Base{ID: "user789"}}
const clientID = "test-client-789"
tokenString, err := service1.GenerateOAuthAccessToken(user, clientID, "")
tokenString, err := service1.GenerateOAuthAccessToken(user, clientID)
require.NoError(t, err, "Failed to generate OAuth access token")
_, err = service2.VerifyOAuthAccessToken(tokenString)
@@ -871,7 +828,7 @@ func TestGenerateVerifyOAuthAccessToken(t *testing.T) {
}
const clientID = "eddsa-oauth-client"
tokenString, err := service.GenerateOAuthAccessToken(user, clientID, "")
tokenString, err := service.GenerateOAuthAccessToken(user, clientID)
require.NoError(t, err, "Failed to generate OAuth access token with key")
assert.NotEmpty(t, tokenString, "Token should not be empty")
@@ -908,7 +865,7 @@ func TestGenerateVerifyOAuthAccessToken(t *testing.T) {
}
const clientID = "ecdsa-oauth-client"
tokenString, err := service.GenerateOAuthAccessToken(user, clientID, "")
tokenString, err := service.GenerateOAuthAccessToken(user, clientID)
require.NoError(t, err, "Failed to generate OAuth access token with key")
assert.NotEmpty(t, tokenString, "Token should not be empty")
@@ -945,7 +902,7 @@ func TestGenerateVerifyOAuthAccessToken(t *testing.T) {
}
const clientID = "rsa-oauth-client"
tokenString, err := service.GenerateOAuthAccessToken(user, clientID, "")
tokenString, err := service.GenerateOAuthAccessToken(user, clientID)
require.NoError(t, err, "Failed to generate OAuth access token with key")
assert.NotEmpty(t, tokenString, "Token should not be empty")

View File

@@ -216,69 +216,8 @@ func (s *LdapService) applyAdminGroupMembership(desiredUsers []ldapDesiredUser,
}
}
func (s *LdapService) getLDAPCustomFields(idType idType) ([]dto.CustomFieldDto, error) {
fields, err := ParseCustomFieldDefinitions(s.appConfigService.GetDbConfig().CustomFields.Value)
if err != nil {
return nil, err
}
ldapFields := make([]dto.CustomFieldDto, 0, len(fields))
for _, field := range fields {
if !customFieldAppliesTo(field, idType) {
continue
}
ldapFields = append(ldapFields, field)
}
return ldapFields, nil
}
func appendLDAPCustomFieldAttributes(searchAttrs []string, fields []dto.CustomFieldDto) []string {
seenAttrs := make(map[string]struct{}, len(searchAttrs)+len(fields))
for _, attr := range searchAttrs {
if attr == "" {
continue
}
seenAttrs[attr] = struct{}{}
}
for _, field := range fields {
if _, ok := seenAttrs[field.Key]; ok {
continue
}
searchAttrs = append(searchAttrs, field.Key)
seenAttrs[field.Key] = struct{}{}
}
return searchAttrs
}
func customFieldValuesFromLDAPEntry(entry *ldap.Entry, fields []dto.CustomFieldDto) []dto.CustomFieldValueCreateDto {
if len(fields) == 0 {
return nil
}
customFieldValues := make([]dto.CustomFieldValueCreateDto, 0, len(fields))
for _, field := range fields {
value := entry.GetAttributeValue(field.Key)
if value == "" {
continue
}
customFieldValues = append(customFieldValues, dto.CustomFieldValueCreateDto{
CustomFieldID: field.ID,
Value: value,
})
}
return customFieldValues
}
func (s *LdapService) fetchGroupsFromLDAP(ctx context.Context, client ldapClient, usernamesByDN map[string]string) (desiredGroups []ldapDesiredGroup, ldapGroupIDs map[string]struct{}, err error) {
dbConfig := s.appConfigService.GetDbConfig()
customFields, err := s.getLDAPCustomFields(UserGroupID)
if err != nil {
return nil, nil, err
}
// Query LDAP for all groups we want to manage
searchAttrs := []string{
@@ -286,7 +225,6 @@ func (s *LdapService) fetchGroupsFromLDAP(ctx context.Context, client ldapClient
dbConfig.LdapAttributeGroupUniqueIdentifier.Value,
dbConfig.LdapAttributeGroupMember.Value,
}
searchAttrs = appendLDAPCustomFieldAttributes(searchAttrs, customFields)
searchReq := ldap.NewSearchRequest(
dbConfig.LdapBase.Value,
@@ -329,10 +267,9 @@ func (s *LdapService) fetchGroupsFromLDAP(ctx context.Context, client ldapClient
}
syncGroup := dto.UserGroupCreateDto{
Name: value.GetAttributeValue(dbConfig.LdapAttributeGroupName.Value),
FriendlyName: value.GetAttributeValue(dbConfig.LdapAttributeGroupName.Value),
LdapID: ldapID,
CustomFieldValues: customFieldValuesFromLDAPEntry(value, customFields),
Name: value.GetAttributeValue(dbConfig.LdapAttributeGroupName.Value),
FriendlyName: value.GetAttributeValue(dbConfig.LdapAttributeGroupName.Value),
LdapID: ldapID,
}
dto.Normalize(&syncGroup)
@@ -354,10 +291,6 @@ func (s *LdapService) fetchGroupsFromLDAP(ctx context.Context, client ldapClient
func (s *LdapService) fetchUsersFromLDAP(ctx context.Context, client ldapClient) (desiredUsers []ldapDesiredUser, ldapUserIDs map[string]struct{}, usernamesByDN map[string]string, err error) {
dbConfig := s.appConfigService.GetDbConfig()
customFields, err := s.getLDAPCustomFields(UserID)
if err != nil {
return nil, nil, nil, err
}
// Query LDAP for all users we want to manage
searchAttrs := []string{
@@ -371,7 +304,6 @@ func (s *LdapService) fetchUsersFromLDAP(ctx context.Context, client ldapClient)
dbConfig.LdapAttributeUserProfilePicture.Value,
dbConfig.LdapAttributeUserDisplayName.Value,
}
searchAttrs = appendLDAPCustomFieldAttributes(searchAttrs, customFields)
// Filters must start and finish with ()!
searchReq := ldap.NewSearchRequest(
@@ -410,13 +342,12 @@ func (s *LdapService) fetchUsersFromLDAP(ctx context.Context, client ldapClient)
ldapUserIDs[ldapID] = struct{}{}
newUser := dto.UserCreateDto{
Username: value.GetAttributeValue(dbConfig.LdapAttributeUserUsername.Value),
Email: utils.PtrOrNil(value.GetAttributeValue(dbConfig.LdapAttributeUserEmail.Value)),
EmailVerified: true,
FirstName: value.GetAttributeValue(dbConfig.LdapAttributeUserFirstName.Value),
LastName: value.GetAttributeValue(dbConfig.LdapAttributeUserLastName.Value),
DisplayName: value.GetAttributeValue(dbConfig.LdapAttributeUserDisplayName.Value),
CustomFieldValues: customFieldValuesFromLDAPEntry(value, customFields),
Username: value.GetAttributeValue(dbConfig.LdapAttributeUserUsername.Value),
Email: utils.PtrOrNil(value.GetAttributeValue(dbConfig.LdapAttributeUserEmail.Value)),
EmailVerified: true,
FirstName: value.GetAttributeValue(dbConfig.LdapAttributeUserFirstName.Value),
LastName: value.GetAttributeValue(dbConfig.LdapAttributeUserLastName.Value),
DisplayName: value.GetAttributeValue(dbConfig.LdapAttributeUserDisplayName.Value),
// Admin status is computed after groups are loaded so it can use the
// configured group member attribute instead of a hard-coded memberOf.
IsAdmin: false,
@@ -460,12 +391,6 @@ func (s *LdapService) resolveGroupMemberUsername(ctx context.Context, client lda
return norm.NFC.String(username)
}
// posixGroup (and similar) stores bare usernames in memberUid, not DNs. Treat any value
// that is not a valid DN as the username directly — see https://github.com/pocket-id/pocket-id/issues/1408
if _, err := ldap.ParseDN(member); err != nil {
return norm.NFC.String(member)
}
// As a fallback, query LDAP for the referenced entry
userSearchReq := ldap.NewSearchRequest(
member,
@@ -492,11 +417,6 @@ func (s *LdapService) resolveGroupMemberUsername(ctx context.Context, client lda
}
func (s *LdapService) reconcileGroups(ctx context.Context, tx *gorm.DB, desiredGroups []ldapDesiredGroup, ldapGroupIDs map[string]struct{}) error {
customFields, err := s.getLDAPCustomFields(UserGroupID)
if err != nil {
return err
}
// Load the current LDAP-managed state from the database
ldapGroupsInDB, ldapGroupsByID, err := s.loadLDAPGroupsInDB(ctx, tx)
if err != nil {
@@ -522,38 +442,28 @@ func (s *LdapService) reconcileGroups(ctx context.Context, tx *gorm.DB, desiredG
}
databaseGroup := ldapGroupsByID[desiredGroup.ldapID]
var groupID string
if databaseGroup.ID == "" {
newGroup, err := s.groupService.createInternal(ctx, desiredGroup.input, tx)
if err != nil {
return fmt.Errorf("failed to create group '%s': %w", desiredGroup.input.Name, err)
}
ldapGroupsByID[desiredGroup.ldapID] = newGroup
groupID = newGroup.ID
_, err = s.groupService.updateUsersInternal(ctx, newGroup.ID, memberUserIDs, tx)
if err != nil {
return fmt.Errorf("failed to sync users for group '%s': %w", desiredGroup.input.Name, err)
}
} else {
groupID = databaseGroup.ID
_, err = s.groupService.updateInternal(ctx, databaseGroup.ID, desiredGroup.input, true, tx)
if err != nil {
return fmt.Errorf("failed to update group '%s': %w", desiredGroup.input.Name, err)
}
_, err = s.groupService.updateUsersInternal(ctx, databaseGroup.ID, memberUserIDs, tx)
if err != nil {
return fmt.Errorf("failed to sync users for group '%s': %w", desiredGroup.input.Name, err)
}
continue
}
if len(customFields) > 0 {
_, err = s.groupService.customFieldValueService.updateCustomFieldValuesForFields(ctx, UserGroupID, groupID, desiredGroup.input.CustomFieldValues, customFields, tx)
if err != nil {
return fmt.Errorf("failed to sync custom fields for group '%s': %w", desiredGroup.input.Name, err)
}
_, err = s.groupService.updateInternal(ctx, databaseGroup.ID, desiredGroup.input, true, tx)
if err != nil {
return fmt.Errorf("failed to update group '%s': %w", desiredGroup.input.Name, err)
}
_, err = s.groupService.updateUsersInternal(ctx, databaseGroup.ID, memberUserIDs, tx)
if err != nil {
return fmt.Errorf("failed to sync users for group '%s': %w", desiredGroup.input.Name, err)
}
}
@@ -584,10 +494,6 @@ func (s *LdapService) reconcileGroups(ctx context.Context, tx *gorm.DB, desiredG
//nolint:gocognit
func (s *LdapService) reconcileUsers(ctx context.Context, tx *gorm.DB, desiredUsers []ldapDesiredUser, ldapUserIDs map[string]struct{}) (savePictures []savePicture, deleteFiles []string, err error) {
dbConfig := s.appConfigService.GetDbConfig()
customFields, err := s.getLDAPCustomFields(UserID)
if err != nil {
return nil, nil, err
}
// Load the current LDAP-managed state from the database
ldapUsersInDB, ldapUsersByID, _, err := s.loadLDAPUsersInDB(ctx, tx)
@@ -639,11 +545,6 @@ func (s *LdapService) reconcileUsers(ctx context.Context, tx *gorm.DB, desiredUs
}
}
_, err = s.userService.customFieldValueService.updateCustomFieldValuesForFields(ctx, UserID, userID, desiredUser.input.CustomFieldValues, customFields, tx)
if err != nil {
return nil, nil, fmt.Errorf("failed to sync custom fields for user '%s': %w", desiredUser.input.Username, err)
}
if desiredUser.picture != "" {
savePictures = append(savePictures, savePicture{
userID: userID,
@@ -676,7 +577,8 @@ func (s *LdapService) reconcileUsers(ctx context.Context, tx *gorm.DB, desiredUs
err = s.userService.deleteUserInternal(ctx, tx, user.ID, true)
if err != nil {
if _, ok := errors.AsType[*common.LdapUserUpdateError](err); ok {
target := &common.LdapUserUpdateError{}
if errors.As(err, &target) {
return nil, nil, fmt.Errorf("failed to delete user %s: LDAP user must be disabled before deletion", user.Username)
}
return nil, nil, fmt.Errorf("failed to delete user %s: %w", user.Username, err)

View File

@@ -141,93 +141,6 @@ func TestLdapServiceSyncAllReconcilesUsersAndGroups(t *testing.T) {
assert.ElementsMatch(t, []string{"alice", "bob"}, usernames(team.Users))
}
func TestLdapServiceSyncAllImportsCustomFieldsFromLDAP(t *testing.T) {
appCfg := defaultTestLDAPAppConfig()
appCfg.CustomFields = model.AppConfigVariable{Value: `[
{"id":"5b6f0cb7-2865-4c2e-9795-4c81e3725f21","key":"quota","displayName":"Quota","type":"string","target":"user","required":false},
{"id":"5085ac6f-a1d4-4cb8-bd6b-40d68b8f0644","key":"mailboxTemplate","displayName":"Mailbox template","type":"string","target":"user","required":false},
{"id":"9a98fcfb-1d0b-46a3-b028-3c43694b1771","key":"nextcloudQuota","displayName":"Group quota","type":"string","target":"group","required":false}
]`}
service, db := newTestLdapServiceWithAppConfig(t, appCfg, newFakeLDAPClient(
ldapSearchResult(
ldapEntry("uid=alice,ou=people,dc=example,dc=com", map[string][]string{
"entryUUID": {"u-alice"},
"uid": {"alice"},
"mail": {"alice@example.com"},
"givenName": {"Alice"},
"sn": {"Jones"},
"displayName": {""},
"quota": {"10 GB"},
"mailboxTemplate": {"standard"},
}),
),
ldapSearchResult(
ldapEntry("cn=team,ou=groups,dc=example,dc=com", map[string][]string{
"entryUUID": {"g-team"},
"cn": {"team"},
"member": {"uid=alice,ou=people,dc=example,dc=com"},
"nextcloudQuota": {"100 GB"},
}),
),
))
require.NoError(t, service.SyncAll(t.Context()))
var alice model.User
require.NoError(t, db.Preload("CustomFieldValues").First(&alice, "ldap_id = ?", "u-alice").Error)
userValues := customFieldValuesByID(alice.CustomFieldValues)
assert.Equal(t, "10 GB", userValues["5b6f0cb7-2865-4c2e-9795-4c81e3725f21"])
assert.Equal(t, "standard", userValues["5085ac6f-a1d4-4cb8-bd6b-40d68b8f0644"])
var group model.UserGroup
require.NoError(t, db.Preload("CustomFieldValues").First(&group, "ldap_id = ?", "g-team").Error)
groupValues := customFieldValuesByID(group.CustomFieldValues)
assert.Equal(t, "100 GB", groupValues["9a98fcfb-1d0b-46a3-b028-3c43694b1771"])
}
// Regression: posixGroup uses memberUid (bare uid values), not member DNs — issue #1408.
func TestLdapServiceSyncAllMapsPosixGroupMemberUid(t *testing.T) {
appCfg := defaultTestLDAPAppConfig()
appCfg.LdapUserGroupSearchFilter = model.AppConfigVariable{Value: "(objectClass=posixGroup)"}
appCfg.LdapAttributeGroupMember = model.AppConfigVariable{Value: "memberUid"}
service, db := newTestLdapServiceWithAppConfig(t, appCfg, newFakeLDAPClient(
ldapSearchResult(
ldapEntry("uid=alice,ou=users,dc=example,dc=com", map[string][]string{
"entryUUID": {"u-alice"},
"uid": {"alice"},
"mail": {"alice@example.com"},
"givenName": {"Alice"},
"sn": {"Jones"},
"displayName": {""},
}),
ldapEntry("uid=bob,ou=users,dc=example,dc=com", map[string][]string{
"entryUUID": {"u-bob"},
"uid": {"bob"},
"mail": {"bob@example.com"},
"givenName": {"Bob"},
"sn": {"Brown"},
"displayName": {""},
}),
),
ldapSearchResult(
ldapEntry("cn=users,ou=groups,dc=example,dc=com", map[string][]string{
"entryUUID": {"g-users"},
"cn": {"users"},
"memberUid": {"alice", "bob", "unknown"},
}),
),
))
require.NoError(t, service.SyncAll(t.Context()))
var group model.UserGroup
require.NoError(t, db.Preload("Users").First(&group, "ldap_id = ?", "g-users").Error)
assert.Equal(t, "users", group.Name)
assert.ElementsMatch(t, []string{"alice", "bob"}, usernames(group.Users))
}
func TestLdapServiceSyncAllHandlesDuplicateLDAPIDsInSingleRun(t *testing.T) {
service, db := newTestLdapService(t, newFakeLDAPClient(
ldapSearchResult(
@@ -363,15 +276,14 @@ func newTestLdapServiceWithAppConfig(t *testing.T, appConfigModel *model.AppConf
appConfig := NewTestAppConfigService(appConfigModel)
customFieldValueService := NewCustomFieldValueService(db, appConfig)
groupService := NewUserGroupService(db, appConfig, customFieldValueService, nil)
groupService := NewUserGroupService(db, appConfig, nil)
userService := NewUserService(
db,
nil,
nil,
nil,
appConfig,
customFieldValueService,
NewCustomClaimService(db),
NewAppImagesService(map[string]string{}, fileStorage),
nil,
fileStorage,
@@ -413,7 +325,7 @@ func newFakeLDAPClient(userResult, groupResult *ldap.SearchResult) ldapClient {
switch searchRequest.Filter {
case "(objectClass=person)":
return userResult, nil
case "(objectClass=groupOfNames)", "(objectClass=posixGroup)":
case "(objectClass=groupOfNames)":
return groupResult, nil
default:
return &ldap.SearchResult{}, nil
@@ -451,15 +363,6 @@ func usernames(users []model.User) []string {
return result
}
func customFieldValuesByID(values []model.CustomFieldValue) map[string]string {
result := make(map[string]string, len(values))
for _, value := range values {
result[value.CustomFieldID] = value.Value
}
return result
}
func TestGetDNProperty(t *testing.T) {
tests := []struct {
name string

View File

@@ -6,6 +6,7 @@ import (
"crypto/subtle"
"crypto/tls"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
@@ -33,7 +34,6 @@ import (
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
"github.com/pocket-id/pocket-id/backend/internal/storage"
"github.com/pocket-id/pocket-id/backend/internal/utils"
imageutil "github.com/pocket-id/pocket-id/backend/internal/utils/image"
)
const (
@@ -50,13 +50,13 @@ const (
)
type OidcService struct {
db *gorm.DB
jwtService *JwtService
appConfigService *AppConfigService
auditLogService *AuditLogService
customFieldValueService *CustomFieldValueService
webAuthnService *WebAuthnService
scimService *ScimService
db *gorm.DB
jwtService *JwtService
appConfigService *AppConfigService
auditLogService *AuditLogService
customClaimService *CustomClaimService
webAuthnService *WebAuthnService
scimService *ScimService
httpClient *http.Client
jwkCache *jwk.Cache
@@ -69,22 +69,22 @@ func NewOidcService(
jwtService *JwtService,
appConfigService *AppConfigService,
auditLogService *AuditLogService,
customFieldValueService *CustomFieldValueService,
customClaimService *CustomClaimService,
webAuthnService *WebAuthnService,
scimService *ScimService,
httpClient *http.Client,
fileStorage storage.FileStorage,
) (s *OidcService, err error) {
s = &OidcService{
db: db,
jwtService: jwtService,
appConfigService: appConfigService,
auditLogService: auditLogService,
customFieldValueService: customFieldValueService,
webAuthnService: webAuthnService,
scimService: scimService,
httpClient: httpClient,
fileStorage: fileStorage,
db: db,
jwtService: jwtService,
appConfigService: appConfigService,
auditLogService: auditLogService,
customClaimService: customClaimService,
webAuthnService: webAuthnService,
scimService: scimService,
httpClient: httpClient,
fileStorage: fileStorage,
}
// Note: we don't pass the HTTP Client with OTel instrumented to this because requests are always made in background and not tied to a specific trace
@@ -123,9 +123,11 @@ func (s *OidcService) getJWKCache(ctx context.Context) (*jwk.Cache, error) {
)
}
func (s *OidcService) Authorize(ctx context.Context, input dto.AuthorizeOidcClientRequestDto, userID string, authenticationMethod string, ipAddress, userAgent string) (string, string, error) {
func (s *OidcService) Authorize(ctx context.Context, input dto.AuthorizeOidcClientRequestDto, userID, ipAddress, userAgent string) (string, string, error) {
tx := s.db.Begin()
defer tx.Rollback()
defer func() {
tx.Rollback()
}()
var client model.OidcClient
err := tx.
@@ -137,47 +139,27 @@ func (s *OidcService) Authorize(ctx context.Context, input dto.AuthorizeOidcClie
return "", "", err
}
if client.RequiresReauthentication {
if input.ReauthenticationToken == "" {
return "", "", &common.ReauthenticationRequiredError{}
}
err = s.webAuthnService.ConsumeReauthenticationToken(ctx, tx, input.ReauthenticationToken, userID)
if err != nil {
return "", "", err
}
}
// If the client is not public, the code challenge must be provided
if client.IsPublic && input.CodeChallenge == "" {
return "", "", &common.OidcMissingCodeChallengeError{}
}
// Validate the callback URL before any prompt checks, so that prompt-related
// error responses never contain an unvalidated redirect target
// Get the callback URL of the client. Return an error if the provided callback URL is not allowed
callbackURL, err := s.getCallbackURL(&client, input.CallbackURL, tx, ctx)
if err != nil {
return "", "", err
}
// Parse prompt parameter (space-delimited list per OIDC spec)
promptValues := parsePromptParameter(input.Prompt)
hasPromptNone := slices.Contains(promptValues, "none")
hasPromptLogin := slices.Contains(promptValues, "login")
hasPromptConsent := slices.Contains(promptValues, "consent")
hasPromptSelectAccount := slices.Contains(promptValues, "select_account")
// Validate prompt parameter conflicts early.
// Per OIDC Core §3.1.2.6, prompt=none must not be combined with any
// value that requires user interaction.
if hasPromptNone && (hasPromptConsent || hasPromptLogin || hasPromptSelectAccount) {
return "", "", common.NewOidcInvalidRequestError("prompt type 'none' cannot be combined with others")
}
// prompt=select_account is handled entirely in the UI
// Pocket ID holds one session per browser, so the frontend renders the current user as the sole selectable account and then calls Authorize normally.
// If prompt=login is specified or the client requires reauthentication, check the reauthentication token
if hasPromptLogin || client.RequiresReauthentication {
if input.ReauthenticationToken == "" {
return "", "", &common.ReauthenticationRequiredError{}
}
err = s.webAuthnService.ConsumeReauthenticationToken(ctx, tx, input.ReauthenticationToken, userID)
if err != nil {
return "", "", err
}
}
// Check if the user group is allowed to authorize the client
var user model.User
err = tx.
@@ -193,48 +175,27 @@ func (s *OidcService) Authorize(ctx context.Context, input dto.AuthorizeOidcClie
return "", "", &common.OidcAccessDeniedError{}
}
// Handle prompt=none - if consent would be required, we can't show UI
if hasPromptNone {
hasAlreadyAuthorized, err := s.hasAuthorizedClientInternal(ctx, input.ClientID, userID, input.Scope, tx)
if err != nil {
return "", "", err
}
if !hasAlreadyAuthorized {
return "", "", &common.OidcConsentRequiredError{}
}
}
hasAlreadyAuthorizedClient, err := s.createAuthorizedClientInternal(ctx, userID, input.ClientID, input.Scope, tx)
if err != nil {
return "", "", err
}
// Create the authorization code
code, err := s.createAuthorizationCode(ctx, input.ClientID, userID, input.Scope, authenticationMethod, input.Nonce, input.CodeChallenge, input.CodeChallengeMethod, tx)
code, err := s.createAuthorizationCode(ctx, input.ClientID, userID, input.Scope, input.Nonce, input.CodeChallenge, input.CodeChallengeMethod, tx)
if err != nil {
return "", "", err
}
// Log the authorization event
if hasAlreadyAuthorizedClient {
s.auditLogService.Create(
ctx, model.AuditLogEventClientAuthorization,
ipAddress, userAgent, userID,
model.AuditLogData{"clientName": client.Name},
tx,
)
s.auditLogService.Create(ctx, model.AuditLogEventClientAuthorization, ipAddress, userAgent, userID, model.AuditLogData{"clientName": client.Name}, tx)
} else {
s.auditLogService.Create(
ctx, model.AuditLogEventNewClientAuthorization,
ipAddress, userAgent, userID,
model.AuditLogData{"clientName": client.Name},
tx,
)
s.auditLogService.Create(ctx, model.AuditLogEventNewClientAuthorization, ipAddress, userAgent, userID, model.AuditLogData{"clientName": client.Name}, tx)
}
err = tx.Commit().Error
if err != nil {
return "", "", fmt.Errorf("failed to commit transaction: %w", err)
return "", "", err
}
return code, callbackURL, nil
@@ -353,17 +314,17 @@ func (s *OidcService) createTokenFromDeviceCode(ctx context.Context, input dto.O
}
// Explicitly use the input clientID for the audience claim to ensure consistency
idToken, idTokenJti, err := s.jwtService.GenerateIDToken(userClaims, input.ClientID, deviceAuth.Nonce, deviceAuth.AuthenticationMethod)
idToken, err := s.jwtService.GenerateIDToken(userClaims, input.ClientID, deviceAuth.Nonce)
if err != nil {
return CreatedTokens{}, err
}
refreshToken, err := s.createRefreshToken(ctx, input.ClientID, *deviceAuth.UserID, deviceAuth.Scope, deviceAuth.AuthenticationMethod, idTokenJti, tx)
refreshToken, err := s.createRefreshToken(ctx, input.ClientID, *deviceAuth.UserID, deviceAuth.Scope, tx)
if err != nil {
return CreatedTokens{}, err
}
accessToken, err := s.jwtService.GenerateOAuthAccessToken(deviceAuth.User, input.ClientID, deviceAuth.AuthenticationMethod)
accessToken, err := s.jwtService.GenerateOAuthAccessToken(deviceAuth.User, input.ClientID)
if err != nil {
return CreatedTokens{}, err
}
@@ -404,7 +365,7 @@ func (s *OidcService) createTokenFromClientCredentials(ctx context.Context, inpu
audClaim = input.Resource
}
accessToken, err := s.jwtService.GenerateOAuthAccessToken(dummyUser, audClaim, "")
accessToken, err := s.jwtService.GenerateOAuthAccessToken(dummyUser, audClaim)
if err != nil {
return CreatedTokens{}, err
}
@@ -452,20 +413,18 @@ func (s *OidcService) createTokenFromAuthorizationCode(ctx context.Context, inpu
return CreatedTokens{}, err
}
authenticationMethod := authorizationCodeMetaData.AuthenticationMethod
idToken, idTokenJti, err := s.jwtService.GenerateIDToken(userClaims, input.ClientID, authorizationCodeMetaData.Nonce, authenticationMethod)
idToken, err := s.jwtService.GenerateIDToken(userClaims, input.ClientID, authorizationCodeMetaData.Nonce)
if err != nil {
return CreatedTokens{}, err
}
// Generate a refresh token
refreshToken, err := s.createRefreshToken(ctx, input.ClientID, authorizationCodeMetaData.UserID, authorizationCodeMetaData.Scope, authenticationMethod, idTokenJti, tx)
refreshToken, err := s.createRefreshToken(ctx, input.ClientID, authorizationCodeMetaData.UserID, authorizationCodeMetaData.Scope, tx)
if err != nil {
return CreatedTokens{}, err
}
accessToken, err := s.jwtService.GenerateOAuthAccessToken(authorizationCodeMetaData.User, input.ClientID, authenticationMethod)
accessToken, err := s.jwtService.GenerateOAuthAccessToken(authorizationCodeMetaData.User, input.ClientID)
if err != nil {
return CreatedTokens{}, err
}
@@ -542,46 +501,8 @@ func (s *OidcService) createTokenFromRefreshToken(ctx context.Context, input dto
return CreatedTokens{}, &common.OidcInvalidRefreshTokenError{}
}
if storedRefreshToken.User.Disabled {
return CreatedTokens{}, &common.OidcInvalidRefreshTokenError{}
}
var authorizedClient model.UserAuthorizedOidcClient
err = tx.
WithContext(ctx).
Where("user_id = ? AND client_id = ?", storedRefreshToken.UserID, input.ClientID).
First(&authorizedClient).
Error
if errors.Is(err, gorm.ErrRecordNotFound) {
err = tx.WithContext(ctx).Delete(&storedRefreshToken).Error
if err != nil {
return CreatedTokens{}, err
}
err = tx.Commit().Error
if err != nil {
return CreatedTokens{}, err
}
return CreatedTokens{}, &common.OidcInvalidRefreshTokenError{}
} else if err != nil {
return CreatedTokens{}, err
}
if client.IsGroupRestricted {
err = tx.WithContext(ctx).Model(client).Association("AllowedUserGroups").Find(&client.AllowedUserGroups)
if err != nil {
return CreatedTokens{}, err
}
}
if !IsUserGroupAllowedToAuthorize(storedRefreshToken.User, *client) {
return CreatedTokens{}, &common.OidcAccessDeniedError{}
}
// Generate a new access token
authenticationMethods := storedRefreshToken.AuthenticationMethod
accessToken, err := s.jwtService.GenerateOAuthAccessToken(storedRefreshToken.User, input.ClientID, authenticationMethods)
accessToken, err := s.jwtService.GenerateOAuthAccessToken(storedRefreshToken.User, input.ClientID)
if err != nil {
return CreatedTokens{}, err
}
@@ -594,13 +515,13 @@ func (s *OidcService) createTokenFromRefreshToken(ctx context.Context, input dto
// Generate a new ID token
// There's no nonce here because we don't have one with the refresh token, but that's not required
idToken, idTokenJti, err := s.jwtService.GenerateIDToken(userClaims, input.ClientID, "", authenticationMethods)
idToken, err := s.jwtService.GenerateIDToken(userClaims, input.ClientID, "")
if err != nil {
return CreatedTokens{}, err
}
// Generate a new refresh token and invalidate the old one
newRefreshToken, err := s.createRefreshToken(ctx, input.ClientID, storedRefreshToken.UserID, storedRefreshToken.Scope, authenticationMethods, idTokenJti, tx)
newRefreshToken, err := s.createRefreshToken(ctx, input.ClientID, storedRefreshToken.UserID, storedRefreshToken.Scope, tx)
if err != nil {
return CreatedTokens{}, err
}
@@ -745,10 +666,11 @@ func (s *OidcService) introspectRefreshToken(ctx context.Context, clientID strin
).
First(&storedRefreshToken).
Error
if errors.Is(err, gorm.ErrRecordNotFound) {
introspectDto.Active = false
return introspectDto, nil
} else if err != nil {
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
introspectDto.Active = false
return introspectDto, nil
}
return introspectDto, err
}
@@ -1062,12 +984,7 @@ func (s *OidcService) UpdateClientLogo(ctx context.Context, clientID string, fil
return err
}
defer reader.Close()
strippedReader, err := imageutil.StripMetadata(reader, fileType)
if err != nil {
return err
}
err = s.fileStorage.Save(ctx, imagePath, strippedReader)
err = s.fileStorage.Save(ctx, imagePath, reader)
if err != nil {
return err
}
@@ -1200,7 +1117,7 @@ func (s *OidcService) UpdateAllowedUserGroups(ctx context.Context, id string, in
}
// ValidateEndSession returns the logout callback URL for the client if all the validations pass
func (s *OidcService) ValidateEndSession(ctx context.Context, input dto.OidcLogoutDto, userID string) (callbackURL string, err error) {
func (s *OidcService) ValidateEndSession(ctx context.Context, input dto.OidcLogoutDto, userID string) (string, error) {
// If no ID token hint is provided, return an error
if input.IdTokenHint == "" {
return "", &common.TokenInvalidError{}
@@ -1222,22 +1139,9 @@ func (s *OidcService) ValidateEndSession(ctx context.Context, input dto.OidcLogo
return "", &common.OidcClientIdNotMatchingError{}
}
subject, ok := token.Subject()
if !ok || subject != userID {
return "", &common.TokenInvalidError{}
}
idTokenJti, ok := token.JwtID()
if !ok {
return "", &common.TokenInvalidError{}
}
tx := s.db.Begin()
defer tx.Rollback()
// Check if the user has authorized the client before
var userAuthorizedOIDCClient model.UserAuthorizedOidcClient
err = tx.
err = s.db.
WithContext(ctx).
Preload("Client").
First(&userAuthorizedOIDCClient, "client_id = ? AND user_id = ?", clientID[0], userID).
@@ -1246,40 +1150,26 @@ func (s *OidcService) ValidateEndSession(ctx context.Context, input dto.OidcLogo
return "", &common.OidcMissingAuthorizationError{}
}
// If the client has a callback URL, validate it
if len(userAuthorizedOIDCClient.Client.LogoutCallbackURLs) > 0 {
callbackURL, err = s.getLogoutCallbackURL(&userAuthorizedOIDCClient.Client, input.PostLogoutRedirectUri)
if err != nil {
return "", err
}
// If the client has no logout callback URLs, return an error
if len(userAuthorizedOIDCClient.Client.LogoutCallbackURLs) == 0 {
return "", &common.OidcNoCallbackURLError{}
}
err = tx.
WithContext(ctx).
Where("user_id = ? AND client_id = ? AND id_token_jti = ?", userID, clientID[0], idTokenJti).
Delete(&model.OidcRefreshToken{}).
Error
callbackURL, err := s.getLogoutCallbackURL(&userAuthorizedOIDCClient.Client, input.PostLogoutRedirectUri)
if err != nil {
return "", err
}
if err := tx.Commit().Error; err != nil {
return "", fmt.Errorf("failed to commit transaction: %w", err)
}
return callbackURL, nil
}
func (s *OidcService) createAuthorizationCode(ctx context.Context, clientID string, userID string, scope string, authenticationMethod string, nonce string, codeChallenge string, codeChallengeMethod string, tx *gorm.DB) (string, error) {
func (s *OidcService) createAuthorizationCode(ctx context.Context, clientID string, userID string, scope string, nonce string, codeChallenge string, codeChallengeMethod string, tx *gorm.DB) (string, error) {
randomString, err := utils.GenerateRandomAlphanumericString(32)
if err != nil {
return "", err
}
codeChallengeMethodSha256, err := codeChallengeMethodIsSha256(codeChallengeMethod)
if err != nil {
return "", err
}
codeChallengeMethodSha256 := strings.ToUpper(codeChallengeMethod) == "S256"
oidcAuthorizationCode := model.OidcAuthorizationCode{
ExpiresAt: datatype.DateTime(time.Now().Add(15 * time.Minute)),
@@ -1287,7 +1177,6 @@ func (s *OidcService) createAuthorizationCode(ctx context.Context, clientID stri
ClientID: clientID,
UserID: userID,
Scope: scope,
AuthenticationMethod: authenticationMethod,
Nonce: nonce,
CodeChallenge: &codeChallenge,
CodeChallengeMethodSha256: &codeChallengeMethodSha256,
@@ -1304,19 +1193,6 @@ func (s *OidcService) createAuthorizationCode(ctx context.Context, clientID stri
return randomString, nil
}
func codeChallengeMethodIsSha256(codeChallengeMethod string) (bool, error) {
switch strings.ToUpper(codeChallengeMethod) {
case "":
return false, nil
case "PLAIN":
return false, nil
case "S256":
return true, nil
default:
return false, common.NewOidcInvalidRequestError("code challenge method not supported")
}
}
func validateCodeVerifier(codeVerifier, codeChallenge string, codeChallengeMethodSha256 bool) bool {
if codeVerifier == "" || codeChallenge == "" {
return false
@@ -1444,7 +1320,7 @@ func (s *OidcService) CreateDeviceAuthorization(ctx context.Context, input dto.O
}, nil
}
func (s *OidcService) VerifyDeviceCode(ctx context.Context, userCode string, userID string, authenticationMethod string, ipAddress string, userAgent string) error {
func (s *OidcService) VerifyDeviceCode(ctx context.Context, userCode string, userID string, ipAddress string, userAgent string) error {
tx := s.db.Begin()
defer func() {
tx.Rollback()
@@ -1493,7 +1369,6 @@ func (s *OidcService) VerifyDeviceCode(ctx context.Context, userCode string, use
}
deviceAuth.UserID = &userID
deviceAuth.AuthenticationMethod = authenticationMethod
deviceAuth.IsAuthorized = true
err = tx.
@@ -1610,15 +1485,6 @@ func (s *OidcService) RevokeAuthorizedClient(ctx context.Context, userID string,
return err
}
err = tx.
WithContext(ctx).
Where("user_id = ? AND client_id = ?", userID, clientID).
Delete(&model.OidcRefreshToken{}).
Error
if err != nil {
return err
}
err = tx.Commit().Error
if err != nil {
return err
@@ -1706,7 +1572,7 @@ func (s *OidcService) ListAccessibleOidcClients(ctx context.Context, userID stri
return dtos, response, err
}
func (s *OidcService) createRefreshToken(ctx context.Context, clientID string, userID string, scope string, authenticationMethod string, idTokenJti string, tx *gorm.DB) (string, error) {
func (s *OidcService) createRefreshToken(ctx context.Context, clientID string, userID string, scope string, tx *gorm.DB) (string, error) {
refreshToken, err := utils.GenerateRandomAlphanumericString(40)
if err != nil {
return "", err
@@ -1717,13 +1583,11 @@ func (s *OidcService) createRefreshToken(ctx context.Context, clientID string, u
refreshTokenHash := utils.CreateSha256Hash(refreshToken)
m := model.OidcRefreshToken{
ExpiresAt: datatype.DateTime(time.Now().Add(RefreshTokenDuration)),
Token: refreshTokenHash,
IdTokenJti: &idTokenJti,
ClientID: clientID,
UserID: userID,
Scope: scope,
AuthenticationMethod: authenticationMethod,
ExpiresAt: datatype.DateTime(time.Now().Add(RefreshTokenDuration)),
Token: refreshTokenHash,
ClientID: clientID,
UserID: userID,
Scope: scope,
}
err = tx.
@@ -1853,18 +1717,14 @@ func (s *OidcService) jwkSetForURL(ctx context.Context, url string) (set jwk.Set
// We set a timeout because otherwise Register will keep trying in case of errors
registerCtx, registerCancel := context.WithTimeout(ctx, 15*time.Second)
defer registerCancel()
registerOptions := []jwk.RegisterOption{
jwk.WithMaxInterval(24 * time.Hour),
jwk.WithMinInterval(15 * time.Minute),
jwk.WithWaitReady(true),
}
if s.httpClient != nil {
registerOptions = append(registerOptions, jwk.WithHTTPClient(s.httpClient))
}
// We need to register the URL
err = s.jwkCache.Register(registerCtx, url, registerOptions...)
err = s.jwkCache.Register(
registerCtx,
url,
jwk.WithMaxInterval(24*time.Hour),
jwk.WithMinInterval(15*time.Minute),
jwk.WithWaitReady(true),
)
// In case of race conditions (two goroutines calling jwkCache.Register at the same time), it's possible we can get a conflict anyways, so we ignore that error
if err != nil && !errors.Is(err, httprc.ErrResourceAlreadyExists()) {
return nil, fmt.Errorf("failed to register JWK set: %w", err)
@@ -1943,7 +1803,7 @@ func (s *OidcService) verifyClientAssertionFromFederatedIdentities(ctx context.C
return nil
}
func (s *OidcService) GetClientPreview(ctx context.Context, clientID string, userID string, scopes []string, authenticationMethod string) (*dto.OidcClientPreviewDto, error) {
func (s *OidcService) GetClientPreview(ctx context.Context, clientID string, userID string, scopes []string) (*dto.OidcClientPreviewDto, error) {
tx := s.db.Begin()
defer func() {
tx.Rollback()
@@ -1979,12 +1839,12 @@ func (s *OidcService) GetClientPreview(ctx context.Context, clientID string, use
return nil, err
}
idToken, _, err := s.jwtService.BuildIDToken(userClaims, clientID, "", authenticationMethod)
idToken, err := s.jwtService.BuildIDToken(userClaims, clientID, "")
if err != nil {
return nil, err
}
accessToken, err := s.jwtService.BuildOAuthAccessToken(user, clientID, authenticationMethod)
accessToken, err := s.jwtService.BuildOAuthAccessToken(user, clientID)
if err != nil {
return nil, err
}
@@ -2042,51 +1902,23 @@ func (s *OidcService) getUserClaims(ctx context.Context, user *model.User, scope
}
if slices.Contains(scopes, "profile") {
// We need to fetch the user and group fields first because we need the key of the custom key later
userFields, err := s.customFieldValueService.GetConfiguredCustomFieldsForTarget(UserID)
if err != nil {
return nil, err
}
userFieldsByID := make(map[string]dto.CustomFieldDto, len(userFields))
for _, customField := range userFields {
userFieldsByID[customField.ID] = customField
}
groupFields, err := s.customFieldValueService.GetConfiguredCustomFieldsForTarget(UserGroupID)
if err != nil {
return nil, err
}
groupFieldsByID := make(map[string]dto.CustomFieldDto, len(groupFields))
for _, customField := range groupFields {
groupFieldsByID[customField.ID] = customField
}
// Fetch the actual values of the custom fields
customFieldValues, err := s.customFieldValueService.GetCustomFieldValuesForUserWithUserGroups(ctx, user.ID, tx)
// Add custom claims
customClaims, err := s.customClaimService.GetCustomClaimsForUserWithUserGroups(ctx, user.ID, tx)
if err != nil {
return nil, err
}
for _, customFieldValue := range customFieldValues {
var customField *dto.CustomFieldDto
var claimKey string
if customFieldValue.UserID != nil {
if field, ok := userFieldsByID[customFieldValue.CustomFieldID]; ok {
customField = &field
claimKey = field.Key
}
} else if customFieldValue.UserGroupID != nil {
if field, ok := groupFieldsByID[customFieldValue.CustomFieldID]; ok {
customField = &field
claimKey = field.Key
}
for _, customClaim := range customClaims {
// The value of the custom claim can be a JSON object or a string
var jsonValue any
err := json.Unmarshal([]byte(customClaim.Value), &jsonValue)
if err == nil {
// It's JSON, so we store it as an object
claims[customClaim.Key] = jsonValue
} else {
// Marshaling failed, so we store it as a string
claims[customClaim.Key] = customClaim.Value
}
value, err := customFieldValueTokenValue(customFieldValue, customField)
if err != nil {
return nil, err
}
claims[claimKey] = value
}
// Add profile claims
@@ -2211,16 +2043,8 @@ func (s *OidcService) downloadAndSaveLogoFromURL(parentCtx context.Context, clie
darkSuffix = "-dark"
}
limitReader := utils.NewLimitReader(resp.Body, maxLogoSize+1)
strippedReader, err := imageutil.StripMetadata(limitReader, ext)
if errors.Is(err, utils.ErrSizeExceeded) {
return errLogoTooLarge
} else if err != nil {
return err
}
imagePath := path.Join("oidc-client-images", clientID+darkSuffix+"."+ext)
err = s.fileStorage.Save(ctx, imagePath, strippedReader)
err = s.fileStorage.Save(ctx, imagePath, utils.NewLimitReader(resp.Body, maxLogoSize+1))
if errors.Is(err, utils.ErrSizeExceeded) {
return errLogoTooLarge
} else if err != nil {
@@ -2300,11 +2124,3 @@ func (s *OidcService) GetClientScimServiceProvider(ctx context.Context, clientID
return provider, nil
}
// parsePromptParameter parses the OIDC prompt parameter which is a space-delimited list of values
func parsePromptParameter(prompt string) []string {
if prompt == "" {
return []string{}
}
return strings.Fields(prompt)
}

View File

@@ -10,7 +10,6 @@ import (
"encoding/json"
"io"
"net/http"
"slices"
"strconv"
"strings"
"testing"
@@ -21,12 +20,10 @@ import (
"github.com/lestrrat-go/jwx/v3/jwt"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
"github.com/pocket-id/pocket-id/backend/internal/common"
"github.com/pocket-id/pocket-id/backend/internal/dto"
"github.com/pocket-id/pocket-id/backend/internal/model"
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
"github.com/pocket-id/pocket-id/backend/internal/storage"
testutils "github.com/pocket-id/pocket-id/backend/internal/utils/testing"
)
@@ -565,179 +562,6 @@ func TestOidcService_verifyClientCredentialsInternal(t *testing.T) {
})
}
func TestOidcServiceRefreshTokenAuthorizationState(t *testing.T) {
newFixture := func(t *testing.T, isGroupRestricted bool) (*OidcService, *gorm.DB, model.User, model.OidcClient, string, string, *model.UserGroup) {
t.Helper()
db := testutils.NewDatabaseForTest(t)
common.EnvConfig.EncryptionKey = []byte("0123456789abcdef0123456789abcdef")
mockConfig := NewTestAppConfigService(&model.AppConfig{
SessionDuration: model.AppConfigVariable{Value: "60"},
})
jwtService, err := NewJwtService(t.Context(), db, mockConfig)
require.NoError(t, err)
service := &OidcService{
db: db,
jwtService: jwtService,
appConfigService: mockConfig,
}
user := model.User{
Username: "refresh-token-user",
Email: new("refresh-token-user@example.com"),
EmailVerified: true,
FirstName: "Refresh",
LastName: "User",
}
require.NoError(t, db.Create(&user).Error)
client, err := service.CreateClient(t.Context(), dto.OidcClientCreateDto{
OidcClientUpdateDto: dto.OidcClientUpdateDto{
Name: "Refresh Token Client",
CallbackURLs: []string{"https://example.com/callback"},
IsGroupRestricted: isGroupRestricted,
},
}, user.ID)
require.NoError(t, err)
clientSecret, err := service.CreateClientSecret(t.Context(), client.ID)
require.NoError(t, err)
var userGroup *model.UserGroup
if isGroupRestricted {
group := model.UserGroup{
FriendlyName: "Allowed Group",
Name: "allowed-group",
}
require.NoError(t, db.Create(&group).Error)
require.NoError(t, db.Model(&user).Association("UserGroups").Append(&group))
require.NoError(t, db.Model(&client).Association("AllowedUserGroups").Append(&group))
userGroup = &group
}
scope := "openid profile email groups"
require.NoError(t, db.Create(&model.UserAuthorizedOidcClient{
UserID: user.ID,
ClientID: client.ID,
Scope: scope,
}).Error)
refreshToken, err := service.createRefreshToken(t.Context(), client.ID, user.ID, scope, AuthenticationMethodPhishingResistant, "03f94e54-53c4-42f8-afe5-918ffd97a30e", db)
require.NoError(t, err)
return service, db, user, client, clientSecret, refreshToken, userGroup
}
refreshInput := func(client model.OidcClient, clientSecret string, refreshToken string) dto.OidcCreateTokensDto {
return dto.OidcCreateTokensDto{
GrantType: GrantTypeRefreshToken,
RefreshToken: refreshToken,
ClientID: client.ID,
ClientSecret: clientSecret,
}
}
t.Run("rejects refresh token after authorization revocation", func(t *testing.T) {
service, db, user, client, clientSecret, refreshToken, _ := newFixture(t, false)
err := service.RevokeAuthorizedClient(t.Context(), user.ID, client.ID)
require.NoError(t, err)
var refreshTokenCount int64
require.NoError(t, db.Model(&model.OidcRefreshToken{}).
Where("user_id = ? AND client_id = ?", user.ID, client.ID).
Count(&refreshTokenCount).Error)
assert.Zero(t, refreshTokenCount)
_, err = service.createTokenFromRefreshToken(t.Context(), refreshInput(client, clientSecret, refreshToken))
require.Error(t, err)
require.ErrorIs(t, err, &common.OidcInvalidRefreshTokenError{})
})
t.Run("rejects and deletes stale refresh token without authorization record", func(t *testing.T) {
service, db, user, client, clientSecret, refreshToken, _ := newFixture(t, false)
require.NoError(t, db.
Where("user_id = ? AND client_id = ?", user.ID, client.ID).
Delete(&model.UserAuthorizedOidcClient{}).Error)
_, err := service.createTokenFromRefreshToken(t.Context(), refreshInput(client, clientSecret, refreshToken))
require.Error(t, err)
require.ErrorIs(t, err, &common.OidcInvalidRefreshTokenError{})
var refreshTokenCount int64
require.NoError(t, db.Model(&model.OidcRefreshToken{}).
Where("user_id = ? AND client_id = ?", user.ID, client.ID).
Count(&refreshTokenCount).Error)
assert.Zero(t, refreshTokenCount)
})
t.Run("rejects refresh token for disabled user", func(t *testing.T) {
service, db, user, client, clientSecret, refreshToken, _ := newFixture(t, false)
require.NoError(t, db.Model(&model.User{}).
Where("id = ?", user.ID).
Update("disabled", true).Error)
_, err := service.createTokenFromRefreshToken(t.Context(), refreshInput(client, clientSecret, refreshToken))
require.Error(t, err)
require.ErrorIs(t, err, &common.OidcInvalidRefreshTokenError{})
})
t.Run("rejects refresh token after user leaves allowed group", func(t *testing.T) {
service, db, user, client, clientSecret, refreshToken, userGroup := newFixture(t, true)
require.NotNil(t, userGroup)
require.NoError(t, db.Model(&user).Association("UserGroups").Delete(userGroup))
_, err := service.createTokenFromRefreshToken(t.Context(), refreshInput(client, clientSecret, refreshToken))
require.Error(t, err)
require.ErrorIs(t, err, &common.OidcAccessDeniedError{})
})
}
func TestOidcServiceAuthenticationMethodsPersistence(t *testing.T) {
mockConfig := NewTestAppConfigService(&model.AppConfig{
SessionDuration: model.AppConfigVariable{Value: "60"},
})
jwtService, db, _ := setupJwtService(t, mockConfig)
service := &OidcService{
db: db,
jwtService: jwtService,
}
authenticationMethod := AuthenticationMethodPhishingResistant
t.Run("stores authentication method on authorization codes", func(t *testing.T) {
code, err := service.createAuthorizationCode(
t.Context(),
"amr-client",
"amr-user",
"openid profile",
authenticationMethod,
"",
"",
"",
db,
)
require.NoError(t, err)
var authorizationCode model.OidcAuthorizationCode
require.NoError(t, db.First(&authorizationCode, "code = ?", code).Error)
assert.Equal(t, authenticationMethod, authorizationCode.AuthenticationMethod)
})
t.Run("stores authentication methods on refresh tokens", func(t *testing.T) {
_, err := service.createRefreshToken(t.Context(), "amr-client", "amr-user", "openid profile", authenticationMethod, "03f94e54-53c4-42f8-afe5-918ffd97a30e", db)
require.NoError(t, err)
var refreshToken model.OidcRefreshToken
require.NoError(t, db.First(&refreshToken, "client_id = ? AND user_id = ?", "amr-client", "amr-user").Error)
assert.Equal(t, authenticationMethod, refreshToken.AuthenticationMethod)
})
}
func TestValidateCodeVerifier_Plain(t *testing.T) {
require.False(t, validateCodeVerifier("", "", false))
require.False(t, validateCodeVerifier("", "", true))
@@ -763,61 +587,6 @@ func TestValidateCodeVerifier_Plain(t *testing.T) {
})
}
func TestCodeChallengeMethodIsSha256(t *testing.T) {
tests := []struct {
name string
method string
wantSha256 bool
wantErr bool
}{
{
name: "omitted defaults to plain",
method: "",
wantSha256: false,
},
{
name: "plain",
method: "plain",
wantSha256: false,
},
{
name: "plain case insensitive",
method: "PLAIN",
wantSha256: false,
},
{
name: "s256",
method: "S256",
wantSha256: true,
},
{
name: "s256 case insensitive",
method: "s256",
wantSha256: true,
},
{
name: "unknown method",
method: "S384",
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := codeChallengeMethodIsSha256(tt.method)
if tt.wantErr {
require.Error(t, err)
var invalidRequest *common.OidcInvalidRequestError
require.ErrorAs(t, err, &invalidRequest)
return
}
require.NoError(t, err)
assert.Equal(t, tt.wantSha256, got)
})
}
}
func TestOidcService_updateClientLogoType(t *testing.T) {
// Create a test database
db := testutils.NewDatabaseForTest(t)
@@ -951,8 +720,6 @@ func TestOidcService_updateClientLogoType(t *testing.T) {
}
func TestOidcService_downloadAndSaveLogoFromURL(t *testing.T) {
const publicLogoHost = "https://8.8.8.8"
// Create a test database
db := testutils.NewDatabaseForTest(t)
@@ -998,7 +765,7 @@ func TestOidcService_downloadAndSaveLogoFromURL(t *testing.T) {
// Create a mock HTTP client with responses
mockResponses := map[string]*http.Response{
//nolint:bodyclose
publicLogoHost + "/logo.png": pngResponse,
"https://example.com/logo.png": pngResponse,
}
httpClient := &http.Client{
Transport: &testutils.MockRoundTripper{
@@ -1014,7 +781,7 @@ func TestOidcService_downloadAndSaveLogoFromURL(t *testing.T) {
}
// Download and save the logo
err := s.downloadAndSaveLogoFromURL(t.Context(), client.ID, publicLogoHost+"/logo.png", true)
err := s.downloadAndSaveLogoFromURL(t.Context(), client.ID, "https://example.com/logo.png", true)
require.NoError(t, err)
// Verify the file was saved
@@ -1043,7 +810,7 @@ func TestOidcService_downloadAndSaveLogoFromURL(t *testing.T) {
mockResponses := map[string]*http.Response{
//nolint:bodyclose
publicLogoHost + "/dark-logo.webp": webpResponse,
"https://example.com/dark-logo.webp": webpResponse,
}
httpClient := &http.Client{
Transport: &testutils.MockRoundTripper{
@@ -1058,7 +825,7 @@ func TestOidcService_downloadAndSaveLogoFromURL(t *testing.T) {
}
// Download and save the dark logo
err := s.downloadAndSaveLogoFromURL(t.Context(), client.ID, publicLogoHost+"/dark-logo.webp", false)
err := s.downloadAndSaveLogoFromURL(t.Context(), client.ID, "https://example.com/dark-logo.webp", false)
require.NoError(t, err)
// Verify the dark logo file was saved
@@ -1082,7 +849,7 @@ func TestOidcService_downloadAndSaveLogoFromURL(t *testing.T) {
mockResponses := map[string]*http.Response{
//nolint:bodyclose
publicLogoHost + "/icon.svg": testutils.NewMockResponse(http.StatusOK, string(svgContent)),
"https://example.com/icon.svg": testutils.NewMockResponse(http.StatusOK, string(svgContent)),
}
httpClient := &http.Client{
Transport: &testutils.MockRoundTripper{
@@ -1096,7 +863,7 @@ func TestOidcService_downloadAndSaveLogoFromURL(t *testing.T) {
httpClient: httpClient,
}
err := s.downloadAndSaveLogoFromURL(t.Context(), client.ID, publicLogoHost+"/icon.svg", true)
err := s.downloadAndSaveLogoFromURL(t.Context(), client.ID, "https://example.com/icon.svg", true)
require.NoError(t, err)
// Verify SVG file was saved
@@ -1113,7 +880,7 @@ func TestOidcService_downloadAndSaveLogoFromURL(t *testing.T) {
mockResponses := map[string]*http.Response{
//nolint:bodyclose
publicLogoHost + "/logo": jpgResponse,
"https://example.com/logo": jpgResponse,
}
httpClient := &http.Client{
Transport: &testutils.MockRoundTripper{
@@ -1127,7 +894,7 @@ func TestOidcService_downloadAndSaveLogoFromURL(t *testing.T) {
httpClient: httpClient,
}
err := s.downloadAndSaveLogoFromURL(t.Context(), client.ID, publicLogoHost+"/logo", true)
err := s.downloadAndSaveLogoFromURL(t.Context(), client.ID, "https://example.com/logo", true)
require.NoError(t, err)
// Verify JPG file was saved (jpeg extension is normalized to jpg)
@@ -1149,7 +916,7 @@ func TestOidcService_downloadAndSaveLogoFromURL(t *testing.T) {
t.Run("Returns error for non-200 status code", func(t *testing.T) {
mockResponses := map[string]*http.Response{
//nolint:bodyclose
publicLogoHost + "/not-found.png": testutils.NewMockResponse(http.StatusNotFound, "Not Found"),
"https://example.com/not-found.png": testutils.NewMockResponse(http.StatusNotFound, "Not Found"),
}
httpClient := &http.Client{
Transport: &testutils.MockRoundTripper{
@@ -1163,7 +930,7 @@ func TestOidcService_downloadAndSaveLogoFromURL(t *testing.T) {
httpClient: httpClient,
}
err := s.downloadAndSaveLogoFromURL(t.Context(), client.ID, publicLogoHost+"/not-found.png", true)
err := s.downloadAndSaveLogoFromURL(t.Context(), client.ID, "https://example.com/not-found.png", true)
require.Error(t, err)
require.ErrorContains(t, err, "failed to fetch logo")
})
@@ -1179,7 +946,7 @@ func TestOidcService_downloadAndSaveLogoFromURL(t *testing.T) {
mockResponses := map[string]*http.Response{
//nolint:bodyclose
publicLogoHost + "/large.png": largeResponse,
"https://example.com/large.png": largeResponse,
}
httpClient := &http.Client{
Transport: &testutils.MockRoundTripper{
@@ -1193,7 +960,7 @@ func TestOidcService_downloadAndSaveLogoFromURL(t *testing.T) {
httpClient: httpClient,
}
err := s.downloadAndSaveLogoFromURL(t.Context(), client.ID, publicLogoHost+"/large.png", true)
err := s.downloadAndSaveLogoFromURL(t.Context(), client.ID, "https://example.com/large.png", true)
require.Error(t, err)
require.ErrorIs(t, err, errLogoTooLarge)
})
@@ -1205,7 +972,7 @@ func TestOidcService_downloadAndSaveLogoFromURL(t *testing.T) {
mockResponses := map[string]*http.Response{
//nolint:bodyclose
publicLogoHost + "/file.txt": textResponse,
"https://example.com/file.txt": textResponse,
}
httpClient := &http.Client{
Transport: &testutils.MockRoundTripper{
@@ -1219,7 +986,7 @@ func TestOidcService_downloadAndSaveLogoFromURL(t *testing.T) {
httpClient: httpClient,
}
err := s.downloadAndSaveLogoFromURL(t.Context(), client.ID, publicLogoHost+"/file.txt", true)
err := s.downloadAndSaveLogoFromURL(t.Context(), client.ID, "https://example.com/file.txt", true)
require.Error(t, err)
var fileTypeErr *common.FileTypeNotSupportedError
require.ErrorAs(t, err, &fileTypeErr)
@@ -1232,7 +999,7 @@ func TestOidcService_downloadAndSaveLogoFromURL(t *testing.T) {
mockResponses := map[string]*http.Response{
//nolint:bodyclose
publicLogoHost + "/logo.png": pngResponse,
"https://example.com/logo.png": pngResponse,
}
httpClient := &http.Client{
Transport: &testutils.MockRoundTripper{
@@ -1246,170 +1013,8 @@ func TestOidcService_downloadAndSaveLogoFromURL(t *testing.T) {
httpClient: httpClient,
}
err := s.downloadAndSaveLogoFromURL(t.Context(), "non-existent-client-id", publicLogoHost+"/logo.png", true)
err := s.downloadAndSaveLogoFromURL(t.Context(), "non-existent-client-id", "https://example.com/logo.png", true)
require.Error(t, err)
require.ErrorContains(t, err, "failed to look up client")
})
}
func TestOidcService_ValidateEndSessionDeletesMatchingRefreshToken(t *testing.T) {
db := testutils.NewDatabaseForTest(t)
common.EnvConfig.EncryptionKey = []byte("0123456789abcdef0123456789abcdef")
mockConfig := NewTestAppConfigService(&model.AppConfig{
SessionDuration: model.AppConfigVariable{Value: "60"},
})
mockJwtService, err := NewJwtService(t.Context(), db, mockConfig)
require.NoError(t, err)
oidcService := &OidcService{
db: db,
jwtService: mockJwtService,
}
userID := "test-user-123"
clientID := "test-client-456"
otherClientID := "other-client-789"
otherIDTokenJti := "ac653f42-4781-49f2-bc7c-cc44503c3a1a" //nolint:gosec
userEmail := "test@example.com"
user := model.User{
Base: model.Base{ID: userID},
Email: &userEmail,
}
require.NoError(t, db.Create(&user).Error)
client := model.OidcClient{
Base: model.Base{ID: clientID},
Name: "Test Client",
LogoutCallbackURLs: []string{"https://example.com/logout"},
}
require.NoError(t, db.Create(&client).Error)
otherClient := model.OidcClient{
Base: model.Base{ID: otherClientID},
Name: "Other Client",
LogoutCallbackURLs: []string{"https://other.example.com/logout"},
}
require.NoError(t, db.Create(&otherClient).Error)
require.NoError(t, db.Create(&model.UserAuthorizedOidcClient{
UserID: userID,
ClientID: clientID,
}).Error)
userClaims := map[string]any{
"sub": userID,
"name": "Test User",
"email": userEmail,
}
idToken, idTokenJti, err := mockJwtService.GenerateIDToken(userClaims, clientID, "", "")
require.NoError(t, err)
refreshTokens := []model.OidcRefreshToken{
{
Token: "matching-refresh-token",
UserID: userID,
ClientID: clientID,
IdTokenJti: &idTokenJti,
ExpiresAt: datatype.DateTime(time.Now().Add(time.Hour)),
Scope: "openid profile",
},
{
Token: "same-client-different-session",
UserID: userID,
ClientID: clientID,
IdTokenJti: &otherIDTokenJti,
ExpiresAt: datatype.DateTime(time.Now().Add(time.Hour)),
Scope: "openid profile",
},
{
Token: "other-client-same-jti",
UserID: userID,
ClientID: otherClientID,
IdTokenJti: &idTokenJti,
ExpiresAt: datatype.DateTime(time.Now().Add(time.Hour)),
Scope: "openid profile",
},
{
Token: "legacy-refresh-token",
UserID: userID,
ClientID: clientID,
ExpiresAt: datatype.DateTime(time.Now().Add(time.Hour)),
Scope: "openid profile",
},
}
require.NoError(t, db.Create(&refreshTokens).Error)
callbackURL, err := oidcService.ValidateEndSession(t.Context(), dto.OidcLogoutDto{
IdTokenHint: idToken,
ClientId: clientID,
PostLogoutRedirectUri: "https://example.com/logout",
}, userID)
require.NoError(t, err)
assert.Equal(t, "https://example.com/logout", callbackURL)
var remainingTokens []model.OidcRefreshToken
require.NoError(t, db.Order("token").Find(&remainingTokens).Error)
remainingTokenValues := make([]string, len(remainingTokens))
for i, token := range remainingTokens {
remainingTokenValues[i] = token.Token
}
assert.ElementsMatch(t, []string{
"legacy-refresh-token",
"other-client-same-jti",
"same-client-different-session",
}, remainingTokenValues)
}
// Tests for prompt parameter parsing and handling
func TestParsePromptParameter(t *testing.T) {
t.Run("empty prompt returns empty slice", func(t *testing.T) {
result := parsePromptParameter("")
assert.Equal(t, []string{}, result)
})
t.Run("single prompt value", func(t *testing.T) {
result := parsePromptParameter("none")
assert.Equal(t, []string{"none"}, result)
})
t.Run("multiple prompt values space-delimited", func(t *testing.T) {
result := parsePromptParameter("login consent")
assert.Equal(t, []string{"login", "consent"}, result)
})
t.Run("multiple prompt values with extra spaces", func(t *testing.T) {
result := parsePromptParameter(" none login ")
assert.Equal(t, []string{"none", "login"}, result)
})
}
func TestPromptParameterConflicts(t *testing.T) {
tests := []struct {
name string
prompt string
expectConflict bool
}{
{"none alone is valid", "none", false},
{"login alone is valid", "login", false},
{"consent alone is valid", "consent", false},
{"login consent is valid", "login consent", false},
{"none consent conflicts", "none consent", true},
{"none login conflicts", "none login", true},
{"none select_account conflicts", "none select_account", true},
{"none consent login conflicts", "none consent login", true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
values := parsePromptParameter(tt.prompt)
hasNone := slices.Contains(values, "none")
hasConsent := slices.Contains(values, "consent")
hasLogin := slices.Contains(values, "login")
hasSelectAccount := slices.Contains(values, "select_account")
conflict := hasNone && (hasConsent || hasLogin || hasSelectAccount)
assert.Equal(t, tt.expectConflict, conflict)
})
}
}

View File

@@ -97,7 +97,7 @@ func (s *OneTimeAccessService) requestOneTimeAccessEmailInternal(ctx context.Con
return nil, err
}
// #nosec G118 - We use a background context here as this is running in a goroutine
// We use a background context here as this is running in a goroutine
//nolint:contextcheck
go func() {
span := trace.SpanFromContext(ctx)
@@ -197,7 +197,7 @@ func (s *OneTimeAccessService) ExchangeOneTimeAccessToken(ctx context.Context, t
return model.User{}, "", &common.DeviceCodeInvalid{}
}
accessToken, err := s.jwtService.GenerateAccessToken(oneTimeAccessToken.User, AuthenticationMethodOneTimePassword)
accessToken, err := s.jwtService.GenerateAccessToken(oneTimeAccessToken.User)
if err != nil {
return model.User{}, "", err
}

View File

@@ -15,24 +15,19 @@ import (
)
type UserGroupService struct {
db *gorm.DB
scimService *ScimService
appConfigService *AppConfigService
customFieldValueService *CustomFieldValueService
db *gorm.DB
scimService *ScimService
appConfigService *AppConfigService
}
func NewUserGroupService(db *gorm.DB, appConfigService *AppConfigService, customFieldValueService *CustomFieldValueService, scimService *ScimService) *UserGroupService {
return &UserGroupService{db: db, appConfigService: appConfigService, customFieldValueService: customFieldValueService, scimService: scimService}
func NewUserGroupService(db *gorm.DB, appConfigService *AppConfigService, scimService *ScimService) *UserGroupService {
return &UserGroupService{db: db, appConfigService: appConfigService, scimService: scimService}
}
func (s *UserGroupService) List(ctx context.Context, name string, listRequestOptions utils.ListRequestOptions) (groups []model.UserGroup, response utils.PaginationResponse, err error) {
tx := s.db.Begin()
defer func() {
tx.Rollback()
}()
query := tx.
query := s.db.
WithContext(ctx).
Preload("CustomClaims").
Model(&model.UserGroup{})
if name != "" {
@@ -48,17 +43,6 @@ func (s *UserGroupService) List(ctx context.Context, name string, listRequestOpt
}
response, err = utils.PaginateFilterAndSort(listRequestOptions, query, &groups)
if err != nil {
return nil, utils.PaginationResponse{}, err
}
for i := range groups {
groups[i].CustomFieldValues, err = s.customFieldValueService.GetCustomFieldValuesForUserGroup(ctx, groups[i].ID, tx)
if err != nil {
return nil, utils.PaginationResponse{}, err
}
}
return groups, response, err
}
@@ -70,19 +54,11 @@ func (s *UserGroupService) getInternal(ctx context.Context, id string, tx *gorm.
err = tx.
WithContext(ctx).
Where("id = ?", id).
Preload("CustomClaims").
Preload("Users").
Preload("AllowedOidcClients").
First(&group).
Error
if err != nil {
return model.UserGroup{}, err
}
group.CustomFieldValues, err = s.customFieldValueService.GetCustomFieldValuesForUserGroup(ctx, group.ID, tx)
if err != nil {
return model.UserGroup{}, err
}
return group, err
}
@@ -128,22 +104,7 @@ func (s *UserGroupService) Delete(ctx context.Context, id string) error {
}
func (s *UserGroupService) Create(ctx context.Context, input dto.UserGroupCreateDto) (group model.UserGroup, err error) {
tx := s.db.Begin()
defer func() {
tx.Rollback()
}()
group, err = s.createInternal(ctx, input, tx)
if err != nil {
return model.UserGroup{}, err
}
err = tx.Commit().Error
if err != nil {
return model.UserGroup{}, err
}
return group, nil
return s.createInternal(ctx, input, s.db)
}
func (s *UserGroupService) createInternal(ctx context.Context, input dto.UserGroupCreateDto, tx *gorm.DB) (group model.UserGroup, err error) {
@@ -168,12 +129,6 @@ func (s *UserGroupService) createInternal(ctx context.Context, input dto.UserGro
return model.UserGroup{}, err
}
if input.LdapID == "" {
if group.CustomFieldValues, err = s.customFieldValueService.updateCustomFieldValuesInternal(ctx, UserGroupID, group.ID, input.CustomFieldValues, tx); err != nil {
return model.UserGroup{}, err
}
}
if s.scimService != nil {
s.scimService.ScheduleSync()
}
@@ -226,12 +181,6 @@ func (s *UserGroupService) updateInternal(ctx context.Context, id string, input
return model.UserGroup{}, err
}
if input.CustomFieldValues != nil {
if _, err := s.customFieldValueService.updateCustomFieldValuesInternal(ctx, UserGroupID, group.ID, input.CustomFieldValues, tx); err != nil {
return model.UserGroup{}, err
}
}
if s.scimService != nil {
s.scimService.ScheduleSync()
}

View File

@@ -27,41 +27,37 @@ import (
)
type UserService struct {
db *gorm.DB
jwtService *JwtService
auditLogService *AuditLogService
emailService *EmailService
appConfigService *AppConfigService
customFieldValueService *CustomFieldValueService
appImagesService *AppImagesService
scimService *ScimService
fileStorage storage.FileStorage
db *gorm.DB
jwtService *JwtService
auditLogService *AuditLogService
emailService *EmailService
appConfigService *AppConfigService
customClaimService *CustomClaimService
appImagesService *AppImagesService
scimService *ScimService
fileStorage storage.FileStorage
}
func NewUserService(db *gorm.DB, jwtService *JwtService, auditLogService *AuditLogService, emailService *EmailService, appConfigService *AppConfigService, customFieldValueService *CustomFieldValueService, appImagesService *AppImagesService, scimService *ScimService, fileStorage storage.FileStorage) *UserService {
func NewUserService(db *gorm.DB, jwtService *JwtService, auditLogService *AuditLogService, emailService *EmailService, appConfigService *AppConfigService, customClaimService *CustomClaimService, appImagesService *AppImagesService, scimService *ScimService, fileStorage storage.FileStorage) *UserService {
return &UserService{
db: db,
jwtService: jwtService,
auditLogService: auditLogService,
emailService: emailService,
appConfigService: appConfigService,
customFieldValueService: customFieldValueService,
appImagesService: appImagesService,
scimService: scimService,
fileStorage: fileStorage,
db: db,
jwtService: jwtService,
auditLogService: auditLogService,
emailService: emailService,
appConfigService: appConfigService,
customClaimService: customClaimService,
appImagesService: appImagesService,
scimService: scimService,
fileStorage: fileStorage,
}
}
func (s *UserService) ListUsers(ctx context.Context, searchTerm string, listRequestOptions utils.ListRequestOptions) ([]model.User, utils.PaginationResponse, error) {
tx := s.db.Begin()
defer func() {
tx.Rollback()
}()
var users []model.User
query := tx.WithContext(ctx).
query := s.db.WithContext(ctx).
Model(&model.User{}).
Preload("UserGroups")
Preload("UserGroups").
Preload("CustomClaims")
if searchTerm != "" {
searchPattern := "%" + searchTerm + "%"
@@ -71,16 +67,6 @@ func (s *UserService) ListUsers(ctx context.Context, searchTerm string, listRequ
}
pagination, err := utils.PaginateFilterAndSort(listRequestOptions, query, &users)
if err != nil {
return nil, utils.PaginationResponse{}, err
}
for i := range users {
users[i].CustomFieldValues, err = s.customFieldValueService.GetCustomFieldValuesForUser(ctx, users[i].ID, tx)
if err != nil {
return nil, utils.PaginationResponse{}, err
}
}
return users, pagination, err
}
@@ -94,17 +80,10 @@ func (s *UserService) getUserInternal(ctx context.Context, userID string, tx *go
err := tx.
WithContext(ctx).
Preload("UserGroups").
Preload("CustomClaims").
Where("id = ?", userID).
First(&user).
Error
if err != nil {
return model.User{}, err
}
user.CustomFieldValues, err = s.customFieldValueService.GetCustomFieldValuesForUser(ctx, user.ID, tx)
if err != nil {
return model.User{}, err
}
return user, err
}
@@ -157,8 +136,7 @@ func (s *UserService) GetProfilePicture(ctx context.Context, userID string) (io.
// Save the default picture for future use (in a goroutine to avoid blocking)
defaultPictureBytes := defaultPicture.Bytes()
//#nosec G118 - We use a background context as this is running in background
// nolint:contextcheck
//nolint:contextcheck
go func() {
// Use bytes.NewReader because we need an io.ReadSeeker
rErr := s.fileStorage.Save(context.Background(), defaultPicturePath, bytes.NewReader(defaultPictureBytes))
@@ -322,17 +300,15 @@ func (s *UserService) createUserInternal(ctx context.Context, input dto.UserCrea
return model.User{}, err
}
// Apply default groups and custom fields for new non-LDAP users.
// Apply default groups and claims for new non-LDAP users
if !isLdapSync {
if len(input.UserGroupIds) == 0 {
if err := s.applyDefaultGroups(ctx, &user, tx); err != nil {
return model.User{}, err
}
}
}
if !isLdapSync {
user.CustomFieldValues, err = s.customFieldValueService.updateCustomFieldValuesInternal(ctx, UserID, user.ID, input.CustomFieldValues, tx)
if err != nil {
if err := s.applyDefaultCustomClaims(ctx, &user, tx); err != nil {
return model.User{}, err
}
}
@@ -376,6 +352,27 @@ func (s *UserService) applyDefaultGroups(ctx context.Context, user *model.User,
return nil
}
func (s *UserService) applyDefaultCustomClaims(ctx context.Context, user *model.User, tx *gorm.DB) error {
config := s.appConfigService.GetDbConfig()
var claims []dto.CustomClaimCreateDto
v := config.SignupDefaultCustomClaims.Value
if v != "" && v != "[]" {
err := json.Unmarshal([]byte(v), &claims)
if err != nil {
return fmt.Errorf("invalid SignupDefaultCustomClaims JSON: %w", err)
}
if len(claims) > 0 {
_, err = s.customClaimService.updateCustomClaimsInternal(ctx, UserID, user.ID, claims, tx)
if err != nil {
return fmt.Errorf("failed to apply default custom claims: %w", err)
}
}
}
return nil
}
func (s *UserService) UpdateUser(ctx context.Context, userID string, updatedUser dto.UserCreateDto, updateOwnUser bool, isLdapSync bool) (model.User, error) {
tx := s.db.Begin()
defer func() {
@@ -465,15 +462,6 @@ func (s *UserService) updateUserInternal(ctx context.Context, userID string, upd
return user, err
}
if updateOwnUser {
user.CustomFieldValues, err = s.customFieldValueService.updateSelfEditableCustomFieldValuesForUser(ctx, user.ID, updatedUser.CustomFieldValues, tx)
} else {
user.CustomFieldValues, err = s.customFieldValueService.updateCustomFieldValuesInternal(ctx, UserID, user.ID, updatedUser.CustomFieldValues, tx)
}
if err != nil {
return user, err
}
if s.scimService != nil {
s.scimService.ScheduleSync()
}

View File

@@ -72,20 +72,14 @@ func (s *UserSignUpService) SignUp(ctx context.Context, signupData dto.SignUpDto
}
}
customFieldValues, err := s.filterSignupCustomFieldValues(signupData.CustomFieldValues)
if err != nil {
return model.User{}, "", err
}
userToCreate := dto.UserCreateDto{
Username: signupData.Username,
Email: signupData.Email,
FirstName: signupData.FirstName,
LastName: signupData.LastName,
DisplayName: strings.TrimSpace(signupData.FirstName + " " + signupData.LastName),
UserGroupIds: userGroupIDs,
EmailVerified: s.appConfigService.GetDbConfig().EmailsVerified.IsTrue(),
CustomFieldValues: customFieldValues,
Username: signupData.Username,
Email: signupData.Email,
FirstName: signupData.FirstName,
LastName: signupData.LastName,
DisplayName: strings.TrimSpace(signupData.FirstName + " " + signupData.LastName),
UserGroupIds: userGroupIDs,
EmailVerified: s.appConfigService.GetDbConfig().EmailsVerified.IsTrue(),
}
user, err := s.userService.createUserInternal(ctx, userToCreate, false, tx)
@@ -93,7 +87,7 @@ func (s *UserSignUpService) SignUp(ctx context.Context, signupData dto.SignUpDto
return model.User{}, "", err
}
accessToken, err := s.jwtService.GenerateAccessToken(user, "")
accessToken, err := s.jwtService.GenerateAccessToken(user)
if err != nil {
return model.User{}, "", err
}
@@ -130,27 +124,23 @@ func (s *UserSignUpService) SignUpInitialAdmin(ctx context.Context, signUpData d
tx.Rollback()
}()
setupCompleted, err := s.isInitialAdminSetupCompleted(ctx, tx)
if err != nil {
var userCount int64
if err := tx.WithContext(ctx).Model(&model.User{}).
Where("id != ?", staticApiKeyUserID).
Count(&userCount).Error; err != nil {
return model.User{}, "", err
}
if setupCompleted {
return model.User{}, "", &common.SetupNotAvailableError{}
}
customFieldValues, err := s.filterSignupCustomFieldValues(signUpData.CustomFieldValues)
if err != nil {
return model.User{}, "", err
if userCount != 0 {
return model.User{}, "", &common.SetupAlreadyCompletedError{}
}
userToCreate := dto.UserCreateDto{
FirstName: signUpData.FirstName,
LastName: signUpData.LastName,
DisplayName: strings.TrimSpace(signUpData.FirstName + " " + signUpData.LastName),
Username: signUpData.Username,
Email: signUpData.Email,
IsAdmin: true,
CustomFieldValues: customFieldValues,
FirstName: signUpData.FirstName,
LastName: signUpData.LastName,
DisplayName: strings.TrimSpace(signUpData.FirstName + " " + signUpData.LastName),
Username: signUpData.Username,
Email: signUpData.Email,
IsAdmin: true,
}
user, err := s.userService.createUserInternal(ctx, userToCreate, false, tx)
@@ -158,7 +148,7 @@ func (s *UserSignUpService) SignUpInitialAdmin(ctx context.Context, signUpData d
return model.User{}, "", err
}
token, err := s.jwtService.GenerateAccessToken(user, AuthenticationMethodOneTimePassword)
token, err := s.jwtService.GenerateAccessToken(user)
if err != nil {
return model.User{}, "", err
}
@@ -171,49 +161,6 @@ func (s *UserSignUpService) SignUpInitialAdmin(ctx context.Context, signUpData d
return user, token, nil
}
func (s *UserSignUpService) filterSignupCustomFieldValues(customFieldValues []dto.CustomFieldValueCreateDto) ([]dto.CustomFieldValueCreateDto, error) {
fields, err := ParseCustomFieldDefinitions(s.appConfigService.GetDbConfig().CustomFields.Value)
if err != nil {
return nil, err
}
allowedFieldIDs := make(map[string]struct{}, len(fields))
allowedFieldKeys := make(map[string]struct{}, len(fields))
for _, field := range fields {
if !customFieldAppliesTo(field, UserID) || (!field.Required && !field.UserEditable) {
continue
}
allowedFieldIDs[field.ID] = struct{}{}
allowedFieldKeys[field.Key] = struct{}{}
}
filteredCustomFieldValues := make([]dto.CustomFieldValueCreateDto, 0, len(customFieldValues))
for _, customFieldValue := range customFieldValues {
_, idAllowed := allowedFieldIDs[customFieldValue.CustomFieldID]
_, keyAllowed := allowedFieldKeys[customFieldValue.Key]
if idAllowed || keyAllowed {
filteredCustomFieldValues = append(filteredCustomFieldValues, customFieldValue)
}
}
return filteredCustomFieldValues, nil
}
func (s *UserSignUpService) IsInitialAdminSetupCompleted(ctx context.Context) (bool, error) {
return s.isInitialAdminSetupCompleted(ctx, s.db)
}
func (s *UserSignUpService) isInitialAdminSetupCompleted(ctx context.Context, db *gorm.DB) (bool, error) {
var userCount int64
if err := db.WithContext(ctx).Model(&model.User{}).
Where("id != ?", staticApiKeyUserID).
Count(&userCount).Error; err != nil {
return false, err
}
return userCount != 0, nil
}
func (s *UserSignUpService) ListSignupTokens(ctx context.Context, listRequestOptions utils.ListRequestOptions) ([]model.SignupToken, utils.PaginationResponse, error) {
var tokens []model.SignupToken
query := s.db.WithContext(ctx).Preload("UserGroups").Model(&model.SignupToken{})

View File

@@ -69,7 +69,8 @@ func (s *VersionService) GetLatestVersion(ctx context.Context) (string, error) {
return strings.TrimPrefix(payload.TagName, "v"), nil
})
if staleErr, ok := errors.AsType[*utils.ErrStale](err); ok {
var staleErr *utils.ErrStale
if errors.As(err, &staleErr) {
slog.Warn("Failed to fetch latest version, returning stale cache", "error", staleErr.Err)
return version, nil
}

View File

@@ -266,7 +266,7 @@ func (s *WebAuthnService) VerifyLogin(ctx context.Context, sessionID string, cre
return model.User{}, "", &common.UserDisabledError{}
}
token, err := s.jwtService.GenerateAccessToken(*user, AuthenticationMethodPhishingResistant)
token, err := s.jwtService.GenerateAccessToken(*user)
if err != nil {
return model.User{}, "", err
}
@@ -293,40 +293,26 @@ func (s *WebAuthnService) ListCredentials(ctx context.Context, userID string) ([
return credentials, nil
}
func (s *WebAuthnService) DeleteCredential(ctx context.Context, userID string, credentialID string, ipAddress string, userAgent string, actorUserID string) error {
func (s *WebAuthnService) DeleteCredential(ctx context.Context, userID string, credentialID string, ipAddress string, userAgent string) error {
tx := s.db.Begin()
defer func() {
tx.Rollback()
}()
credential := &model.WebauthnCredential{}
result := tx.
err := tx.
WithContext(ctx).
Clauses(clause.Returning{}).
Delete(credential, "id = ? AND user_id = ?", credentialID, userID)
if result.Error != nil {
return fmt.Errorf("failed to delete record: %w", result.Error)
}
if result.RowsAffected == 0 {
return gorm.ErrRecordNotFound
Delete(credential, "id = ? AND user_id = ?", credentialID, userID).
Error
if err != nil {
return fmt.Errorf("failed to delete record: %w", err)
}
auditLogData := model.AuditLogData{"credentialID": hex.EncodeToString(credential.CredentialID), "passkeyName": credential.Name}
if actorUserID != "" && actorUserID != userID {
var actor model.User
err := tx.
WithContext(ctx).
First(&actor, "id = ?", actorUserID).
Error
if err != nil {
return fmt.Errorf("failed to load actor user: %w", err)
}
auditLogData["actorUserID"] = actorUserID
auditLogData["actorUsername"] = actor.Username
}
s.auditLogService.Create(ctx, model.AuditLogEventPasskeyRemoved, ipAddress, userAgent, userID, auditLogData, tx)
err := tx.Commit().Error
err = tx.Commit().Error
if err != nil {
return fmt.Errorf("failed to commit transaction: %w", err)
}
@@ -389,14 +375,6 @@ func (s *WebAuthnService) CreateReauthenticationTokenWithAccessToken(ctx context
return "", errors.New("access token does not contain user ID")
}
authenticationMethod, err := GetAuthenticationMethod(token)
if err != nil {
return "", err
}
if authenticationMethod != AuthenticationMethodPhishingResistant {
return "", &common.ReauthenticationRequiredError{}
}
// Check if token is issued less than a minute ago
tokenExpiration, ok := token.IssuedAt()
if !ok || time.Since(tokenExpiration) > time.Minute {

View File

@@ -1,68 +0,0 @@
package service
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/pocket-id/pocket-id/backend/internal/common"
"github.com/pocket-id/pocket-id/backend/internal/model"
)
func TestCreateReauthenticationTokenWithAccessToken(t *testing.T) {
mockConfig := NewTestAppConfigService(&model.AppConfig{
SessionDuration: model.AppConfigVariable{Value: "60"},
})
setupService := func(t *testing.T) (*WebAuthnService, model.User) {
t.Helper()
jwtService, db, _ := setupJwtService(t, mockConfig)
user := model.User{
Base: model.Base{ID: "reauth-user"},
Username: "reauth-user",
}
require.NoError(t, db.Create(&user).Error)
return &WebAuthnService{
db: db,
jwtService: jwtService,
}, user
}
t.Run("accepts a fresh access token from WebAuthn login", func(t *testing.T) {
service, user := setupService(t)
accessToken, err := service.jwtService.GenerateAccessToken(user, AuthenticationMethodPhishingResistant)
require.NoError(t, err)
reauthenticationToken, err := service.CreateReauthenticationTokenWithAccessToken(t.Context(), accessToken)
require.NoError(t, err)
assert.NotEmpty(t, reauthenticationToken)
})
t.Run("rejects a fresh access token from one-time access login", func(t *testing.T) {
service, user := setupService(t)
accessToken, err := service.jwtService.GenerateAccessToken(user, AuthenticationMethodOneTimePassword)
require.NoError(t, err)
reauthenticationToken, err := service.CreateReauthenticationTokenWithAccessToken(t.Context(), accessToken)
assert.Empty(t, reauthenticationToken)
require.Error(t, err)
assert.ErrorAs(t, err, new(*common.ReauthenticationRequiredError))
})
t.Run("rejects a fresh access token without an authentication method", func(t *testing.T) {
service, user := setupService(t)
accessToken, err := service.jwtService.GenerateAccessToken(user, "")
require.NoError(t, err)
reauthenticationToken, err := service.CreateReauthenticationTokenWithAccessToken(t.Context(), accessToken)
assert.Empty(t, reauthenticationToken)
require.Error(t, err)
assert.ErrorAs(t, err, new(*common.ReauthenticationRequiredError))
})
}

View File

@@ -138,7 +138,7 @@ func (s *s3Storage) List(ctx context.Context, path string) ([]ObjectInfo, error)
continue
}
objects = append(objects, ObjectInfo{
Path: s.pathFromKey(aws.ToString(obj.Key)),
Path: aws.ToString(obj.Key),
Size: aws.ToInt64(obj.Size),
ModTime: aws.ToTime(obj.LastModified),
})
@@ -147,13 +147,6 @@ func (s *s3Storage) List(ctx context.Context, path string) ([]ObjectInfo, error)
return objects, nil
}
func (s *s3Storage) pathFromKey(key string) string {
if s.prefix == "" {
return key
}
return strings.TrimPrefix(key, s.prefix+"/")
}
func (s *s3Storage) Walk(ctx context.Context, root string, fn func(ObjectInfo) error) error {
objects, err := s.List(ctx, root)
if err != nil {
@@ -186,7 +179,8 @@ func (s *s3Storage) buildObjectKey(p string) string {
}
func isS3NotFound(err error) bool {
if apiErr, ok := errors.AsType[smithy.APIError](err); ok {
var apiErr smithy.APIError
if errors.As(err, &apiErr) {
if apiErr.ErrorCode() == "NotFound" || apiErr.ErrorCode() == "NoSuchKey" {
return true
}

View File

@@ -35,50 +35,6 @@ func TestS3Helpers(t *testing.T) {
}
})
t.Run("pathFromKey strips prefix to honor relative-path contract", func(t *testing.T) {
tests := []struct {
name string
prefix string
key string
expected string
}{
{name: "no prefix returns key unchanged", prefix: "", key: "images/logo.png", expected: "images/logo.png"},
{name: "no prefix empty key", prefix: "", key: "", expected: ""},
{name: "prefix matches and is stripped", prefix: "data/uploads", key: "data/uploads/application-images/logo.svg", expected: "application-images/logo.svg"},
{name: "single-segment prefix stripped", prefix: "root", key: "root/foo/bar.txt", expected: "foo/bar.txt"},
{name: "prefix equal to key without trailing slash is unchanged", prefix: "root", key: "root", expected: "root"},
{name: "key without expected prefix returned unchanged", prefix: "data/uploads", key: "other/path.txt", expected: "other/path.txt"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
s := &s3Storage{
bucket: "bucket",
prefix: tc.prefix,
}
assert.Equal(t, tc.expected, s.pathFromKey(tc.key))
})
}
})
t.Run("pathFromKey is the inverse of buildObjectKey for clean paths", func(t *testing.T) {
paths := []string{
"images/logo.png",
"application-images/logo.svg",
"oidc-client-images/abc.png",
"deeply/nested/file.bin",
}
prefixes := []string{"", "root", "data/uploads"}
for _, prefix := range prefixes {
for _, p := range paths {
s := &s3Storage{bucket: "bucket", prefix: prefix}
assert.Equal(t, p, s.pathFromKey(s.buildObjectKey(p)),
"round-trip failed for prefix=%q path=%q", prefix, p)
}
}
})
t.Run("isS3NotFound detects expected errors", func(t *testing.T) {
assert.True(t, isS3NotFound(&smithy.GenericAPIError{Code: "NoSuchKey"}))
assert.True(t, isS3NotFound(&smithy.GenericAPIError{Code: "NotFound"}))

View File

@@ -35,7 +35,7 @@ func TestValidateCallbackURLPattern(t *testing.T) {
},
{
name: "wildcard userinfo",
pattern: "https://user:*@example.com/callback", // #nosec G101 - Test credential
pattern: "https://user:*@example.com/callback",
shouldError: false,
},
{

View File

@@ -64,7 +64,6 @@ func TestBearerAuth(t *testing.T) {
}
}
// #nosec G101 - Test credentials
func TestOAuthClientBasicAuth(t *testing.T) {
tests := []struct {
name string

View File

@@ -1,106 +0,0 @@
package profilepicture
import (
"bytes"
"encoding/binary"
"io"
"strings"
"github.com/zitadel/exifremove/pkg/exifremove"
)
const maxRIFFSize = ^uint32(0)
// StripMetadata removes EXIF/XMP metadata from JPG, PNG and WEBP images
// Returns a *bytes.Reader so that storage backends receive a seekable reader with a known content length,
// which is required for correct checksum calculation on S3-compatible services
func StripMetadata(file io.Reader, ext string) (*bytes.Reader, error) {
data, err := io.ReadAll(file)
if err != nil {
return nil, err
}
switch strings.ToLower(ext) {
case "jpg", "jpeg":
stripped, err := exifremove.Remove(data)
if err == nil {
return bytes.NewReader(stripped), nil
}
return bytes.NewReader(data), nil
case "png":
stripped, err := exifremove.Remove(data)
if err == nil {
return bytes.NewReader(stripped), nil
}
return bytes.NewReader(data), nil
case "webp":
return bytes.NewReader(stripWEBPMetadata(data)), nil
default:
return bytes.NewReader(data), nil
}
}
func stripWEBPMetadata(data []byte) []byte {
// Check if the file contains the RIFF...WEBP header
if len(data) < 12 || string(data[:4]) != "RIFF" || string(data[8:12]) != "WEBP" {
return data
}
var out bytes.Buffer
// Build the WEBP header
out.WriteString("RIFF")
out.Write([]byte{0, 0, 0, 0}) // Size will be filled at the end
out.WriteString("WEBP")
for pos := 12; pos < len(data); {
// Each RIFF chunk needs an 8 byte header
if pos+8 > len(data) {
return data
}
// Read the chunk type and payload size from the header
chunkType := string(data[pos : pos+4])
chunkSize := int(binary.LittleEndian.Uint32(data[pos+4 : pos+8]))
chunkEnd := pos + 8 + chunkSize
// Chunks with odd payload sizes include one additional byte at the end
if chunkSize%2 == 1 {
chunkEnd++
}
// End of chunk can't be more than the actual image data length
if chunkEnd > len(data) {
return data
}
// Remove chunks with the EXIF or XMP data type
if chunkType == "EXIF" || chunkType == "XMP " {
pos = chunkEnd
continue
}
// In the VP8X chunk there is a feature flag if the file contains any EXIF or XMP data
if chunkType == "VP8X" && chunkSize >= 10 {
// Copy the chunk because we don't want to modify the original one
chunk := make([]byte, chunkEnd-pos)
copy(chunk, data[pos:chunkEnd])
// Clear the Exif and XMP feature flags
chunk[8] &^= 0x0c
out.Write(chunk)
} else {
out.Write(data[pos:chunkEnd])
}
pos = chunkEnd
}
// WEBP image can max be 4GB in size
riffSize := out.Len() - 8
if riffSize < 0 || riffSize > int(maxRIFFSize) {
return data
}
// Set the size in the header (byte 4-7)
binary.LittleEndian.PutUint32(out.Bytes()[4:8], uint32(riffSize))
return out.Bytes()
}

View File

@@ -1,81 +0,0 @@
package profilepicture
import (
"bytes"
"encoding/binary"
"io"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestStripMetadata(t *testing.T) {
t.Run("removes WEBP EXIF and XMP chunks", func(t *testing.T) {
input := webpFile(
webpChunk("VP8X", []byte{0x0c, 0, 0, 0, 0, 0, 0, 0, 0, 0}),
webpChunk("VP8 ", []byte{1, 2, 3, 4}),
webpChunk("EXIF", []byte("gps")),
webpChunk("XMP ", []byte("xmp")),
webpChunk("ICCP", []byte("profile")),
)
output := stripReader(t, input, "webp")
assert.Contains(t, string(output), "VP8 ")
assert.NotContains(t, string(output), "gps")
assert.NotContains(t, string(output), "xmp")
assert.Contains(t, string(output), "profile")
assert.Equal(t, byte(0), output[20]&0x0c)
})
t.Run("leaves non photo metadata formats unchanged", func(t *testing.T) {
input := []byte(`<svg><metadata>secret</metadata><path d="M0 0"/></svg>`)
output := stripReader(t, input, "svg")
assert.Equal(t, input, output)
})
t.Run("leaves malformed photo data unchanged", func(t *testing.T) {
input := []byte("fake-png-content")
output := stripReader(t, input, "png")
assert.Equal(t, input, output)
})
}
func stripReader(t *testing.T, input []byte, ext string) []byte {
t.Helper()
reader, err := StripMetadata(bytes.NewReader(input), ext)
require.NoError(t, err)
output, err := io.ReadAll(reader)
require.NoError(t, err)
return output
}
func webpFile(chunks ...[]byte) []byte {
var out bytes.Buffer
out.WriteString("RIFF")
out.Write([]byte{0, 0, 0, 0})
out.WriteString("WEBP")
for _, chunk := range chunks {
out.Write(chunk)
}
binary.LittleEndian.PutUint32(out.Bytes()[4:8], uint32(out.Len()-8)) //nolint:gosec
return out.Bytes()
}
func webpChunk(chunkType string, data []byte) []byte {
var out bytes.Buffer
out.WriteString(chunkType)
_ = binary.Write(&out, binary.LittleEndian, uint32(len(data))) //nolint:gosec
out.Write(data)
if len(data)%2 == 1 {
out.WriteByte(0)
}
return out.Bytes()
}

View File

@@ -54,7 +54,7 @@ func CreateDefaultProfilePicture(initials string) (*bytes.Buffer, error) {
img := imaging.New(profilePictureSize, profilePictureSize, color.RGBA{R: 255, G: 255, B: 255, A: 255})
// Load the font
fontBytes, err := resources.FS.ReadFile("fonts/Gloock.ttf")
fontBytes, err := resources.FS.ReadFile("fonts/PlayfairDisplay-Bold.ttf")
if err != nil {
return nil, fmt.Errorf("failed to read font file: %w", err)
}
@@ -84,7 +84,7 @@ func CreateDefaultProfilePicture(initials string) (*bytes.Buffer, error) {
// Center the initials
x := (profilePictureSize - font.MeasureString(face, initials).Ceil()) / 2
y := (profilePictureSize-face.Metrics().Height.Ceil())/2 + face.Metrics().Ascent.Ceil()
y := (profilePictureSize-face.Metrics().Height.Ceil())/2 + face.Metrics().Ascent.Ceil() - 10
drawer.Dot = fixed.P(x, y)
// Draw the initials

View File

@@ -86,10 +86,12 @@ func (f *KeyProviderDatabase) SaveKey(ctx context.Context, key jwk.Key) error {
if err != nil {
return fmt.Errorf("failed to encrypt key: %w", err)
}
encB64 := base64.StdEncoding.EncodeToString(enc)
// Save to database
row := model.KV{
Key: PrivateKeyDBKey,
Value: new(base64.StdEncoding.EncodeToString(enc)),
Value: &encB64,
}
ctx, cancel := context.WithTimeout(ctx, 10*time.Second)

View File

@@ -106,9 +106,10 @@ func TestKeyProviderDatabase_LoadKey(t *testing.T) {
require.NoError(t, err)
// Insert invalid base64 data
invalidBase64 := "not-valid-base64"
err = db.Create(&model.KV{
Key: PrivateKeyDBKey,
Value: new("not-valid-base64"),
Value: &invalidBase64,
}).Error
require.NoError(t, err)
@@ -131,9 +132,10 @@ func TestKeyProviderDatabase_LoadKey(t *testing.T) {
require.NoError(t, err)
// Insert valid base64 but invalid encrypted data
invalidData := base64.StdEncoding.EncodeToString([]byte("not-valid-encrypted-data"))
err = db.Create(&model.KV{
Key: PrivateKeyDBKey,
Value: new(base64.StdEncoding.EncodeToString([]byte("not-valid-encrypted-data"))),
Value: &invalidData,
}).Error
require.NoError(t, err)
@@ -193,10 +195,13 @@ func TestKeyProviderDatabase_LoadKey(t *testing.T) {
encryptedData, err := cryptoutils.Encrypt(kek, invalidKeyData, nil)
require.NoError(t, err)
// Base64 encode the encrypted data
encodedData := base64.StdEncoding.EncodeToString(encryptedData)
// Save to database
err = db.Create(&model.KV{
Key: PrivateKeyDBKey,
Value: new(base64.StdEncoding.EncodeToString(encryptedData)),
Value: &encodedData,
}).Error
require.NoError(t, err)

View File

@@ -3,11 +3,9 @@
package testing
import (
"bytes"
"io"
"net/http"
"strings"
"sync"
_ "github.com/golang-migrate/migrate/v4/source/file"
)
@@ -16,62 +14,25 @@ import (
type MockRoundTripper struct {
Err error
Responses map[string]*http.Response
mu sync.Mutex
responseBodies map[string][]byte
}
// RoundTrip implements the http.RoundTripper interface
func (m *MockRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
if m.Err != nil {
return nil, m.Err
}
// Check if we have a specific response for this URL
for url, resp := range m.Responses {
if req.URL.String() == url {
return m.cloneResponse(url, resp)
return resp, nil
}
}
return NewMockResponse(http.StatusNotFound, ""), nil
}
func (m *MockRoundTripper) cloneResponse(url string, resp *http.Response) (*http.Response, error) {
m.mu.Lock()
defer m.mu.Unlock()
if m.responseBodies == nil {
m.responseBodies = make(map[string][]byte, len(m.Responses))
}
body, ok := m.responseBodies[url]
if !ok {
var err error
body, err = io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
m.responseBodies[url] = body
resp.Body = io.NopCloser(bytes.NewReader(body))
}
cloned := new(http.Response)
*cloned = *resp
cloned.Header = resp.Header.Clone()
cloned.Body = io.NopCloser(bytes.NewReader(body))
cloned.ContentLength = int64(len(body))
return cloned, nil
}
// NewMockResponse creates an http.Response with the given status code and body
func NewMockResponse(statusCode int, body string) *http.Response {
return &http.Response{
StatusCode: statusCode,
Body: io.NopCloser(strings.NewReader(body)),
Header: make(http.Header),
ContentLength: int64(len(body)),
StatusCode: statusCode,
Body: io.NopCloser(strings.NewReader(body)),
Header: make(http.Header),
}
}

File diff suppressed because one or more lines are too long

Binary file not shown.

Binary file not shown.

View File

@@ -1,3 +0,0 @@
ALTER TABLE oidc_authorization_codes DROP COLUMN authentication_method;
ALTER TABLE oidc_refresh_tokens DROP COLUMN authentication_method;
ALTER TABLE oidc_device_codes DROP COLUMN authentication_method;

View File

@@ -1,6 +0,0 @@
ALTER TABLE oidc_authorization_codes
ADD COLUMN authentication_method TEXT NOT NULL DEFAULT '';
ALTER TABLE oidc_refresh_tokens
ADD COLUMN authentication_method TEXT NOT NULL DEFAULT '';
ALTER TABLE oidc_device_codes
ADD COLUMN authentication_method TEXT NOT NULL DEFAULT '';

View File

@@ -1,4 +0,0 @@
DROP INDEX IF EXISTS idx_oidc_refresh_tokens_id_token_jti;
ALTER TABLE oidc_refresh_tokens
DROP COLUMN id_token_jti;

View File

@@ -1,5 +0,0 @@
ALTER TABLE oidc_refresh_tokens
ADD COLUMN id_token_jti UUID;
CREATE INDEX idx_oidc_refresh_tokens_id_token_jti
ON oidc_refresh_tokens(user_id, client_id, id_token_jti);

View File

@@ -1,31 +0,0 @@
CREATE TEMP TABLE custom_field_migration_map AS
SELECT
field.value->>'id' AS custom_field_id,
field.value->>'key' AS key
FROM app_config_variables
CROSS JOIN LATERAL jsonb_array_elements(app_config_variables.value::jsonb) AS field(value)
WHERE app_config_variables.key = 'customFields';
ALTER TABLE custom_field_values RENAME CONSTRAINT custom_field_values_unique TO custom_field_values_custom_field_id_unique;
ALTER TABLE custom_field_values ADD COLUMN key VARCHAR(255);
UPDATE custom_field_values
SET key = custom_field_migration_map.key
FROM custom_field_migration_map
WHERE custom_field_migration_map.custom_field_id = custom_field_values.custom_field_id;
UPDATE custom_field_values
SET key = custom_field_id
WHERE key IS NULL;
ALTER TABLE custom_field_values ALTER COLUMN key SET NOT NULL;
ALTER TABLE custom_field_values DROP CONSTRAINT custom_field_values_custom_field_id_unique;
ALTER TABLE custom_field_values DROP COLUMN custom_field_id;
ALTER TABLE custom_field_values ADD CONSTRAINT custom_claims_unique UNIQUE (key, user_id, user_group_id);
ALTER TABLE custom_field_values RENAME TO custom_claims;
DROP TABLE custom_field_migration_map;
DELETE FROM app_config_variables WHERE key = 'customFields';

View File

@@ -1,61 +0,0 @@
ALTER TABLE custom_claims RENAME TO custom_field_values;
ALTER TABLE custom_field_values RENAME CONSTRAINT custom_claims_unique TO custom_field_values_key_unique;
ALTER TABLE custom_field_values ADD COLUMN custom_field_id VARCHAR(255);
CREATE TEMP TABLE custom_field_migration_map AS
SELECT
key,
SUBSTRING(md5(key) FROM 1 FOR 8) || '-' ||
SUBSTRING(md5(key) FROM 9 FOR 4) || '-' ||
'4' || SUBSTRING(md5(key) FROM 14 FOR 3) || '-' ||
'8' || SUBSTRING(md5(key) FROM 18 FOR 3) || '-' ||
SUBSTRING(md5(key) FROM 21 FOR 12) AS custom_field_id,
BOOL_OR(user_id IS NOT NULL) AS has_user_values,
BOOL_OR(user_group_id IS NOT NULL) AS has_group_values
FROM custom_field_values
GROUP BY key;
INSERT INTO app_config_variables (key, value)
SELECT
'customFields',
COALESCE(
jsonb_agg(
jsonb_build_object(
'id', custom_field_id,
'key', key,
'displayName', key,
'type', 'string',
'target',
CASE
WHEN has_user_values AND has_group_values THEN 'both'
WHEN has_user_values THEN 'user'
ELSE 'group'
END,
'required', false,
'userEditable', false,
'defaultValue', '',
'validationRegex', '',
'validationErrorMessage', ''
)
ORDER BY key
)::TEXT,
'[]'
)
FROM custom_field_migration_map
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value
WHERE app_config_variables.value = '' OR app_config_variables.value = '[]';
UPDATE custom_field_values
SET custom_field_id = custom_field_migration_map.custom_field_id
FROM custom_field_migration_map
WHERE custom_field_migration_map.key = custom_field_values.key;
ALTER TABLE custom_field_values ALTER COLUMN custom_field_id SET NOT NULL;
ALTER TABLE custom_field_values DROP CONSTRAINT custom_field_values_key_unique;
ALTER TABLE custom_field_values DROP COLUMN key;
ALTER TABLE custom_field_values ADD CONSTRAINT custom_field_values_unique UNIQUE (custom_field_id, user_id, user_group_id);
DROP TABLE custom_field_migration_map;
DELETE FROM app_config_variables WHERE key IN ('userCustomFields', 'userGroupCustomFields');

View File

@@ -1,10 +0,0 @@
PRAGMA foreign_keys= OFF;
BEGIN;
ALTER TABLE oidc_authorization_codes DROP COLUMN authentication_method;
ALTER TABLE oidc_refresh_tokens DROP COLUMN authentication_method;
ALTER TABLE oidc_device_codes DROP COLUMN authentication_method;
COMMIT;
PRAGMA foreign_keys= ON;

View File

@@ -1,12 +0,0 @@
PRAGMA foreign_keys= OFF;
BEGIN;
ALTER TABLE oidc_authorization_codes
ADD COLUMN authentication_method TEXT NOT NULL DEFAULT '';
ALTER TABLE oidc_refresh_tokens
ADD COLUMN authentication_method TEXT NOT NULL DEFAULT '';
ALTER TABLE oidc_device_codes
ADD COLUMN authentication_method TEXT NOT NULL DEFAULT '';
COMMIT;
PRAGMA foreign_keys= ON;

Some files were not shown because too many files have changed in this diff Show More