mirror of
https://github.com/pocket-id/pocket-id.git
synced 2026-07-16 05:54:01 +03:00
Compare commits
45 Commits
v2.6.2
...
feat/custo
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
abdfaac1b0 | ||
|
|
0616abaf7f | ||
|
|
9ad2bfc7b3 | ||
|
|
bc4f75c44f | ||
|
|
a71219637a | ||
|
|
b3d40a476b | ||
|
|
272d1479bd | ||
|
|
f13424720b | ||
|
|
6aefe6c8e1 | ||
|
|
dd77bb0f32 | ||
|
|
0c95b7c3cc | ||
|
|
f4a20e3ef9 | ||
|
|
3428fb35d7 | ||
|
|
5db570bf66 | ||
|
|
e8c398ffbd | ||
|
|
9dd3d319cf | ||
|
|
b9fdd530c0 | ||
|
|
cc9163f577 | ||
|
|
b27a52a591 | ||
|
|
bb5a111e3d | ||
|
|
8ad95b8af1 | ||
|
|
e56dc124ce | ||
|
|
91c2ea2a66 | ||
|
|
ce6bdb9d7e | ||
|
|
8b22fcaaa4 | ||
|
|
4abd1a5124 | ||
|
|
714b5b3307 | ||
|
|
7aacbd0245 | ||
|
|
532a7a1d14 | ||
|
|
9399cba775 | ||
|
|
5c7e5f680b | ||
|
|
4e8995e04e | ||
|
|
da3489cfa5 | ||
|
|
12ac7973a2 | ||
|
|
27945f5695 | ||
|
|
2340bb0f1d | ||
|
|
d860ef43ec | ||
|
|
d5cf60689e | ||
|
|
f4706cd6cc | ||
|
|
e33a9b8c88 | ||
|
|
20df033c1f | ||
|
|
f9f93f0ef1 | ||
|
|
64d4ac7919 | ||
|
|
0ed2c48591 | ||
|
|
5559077ab4 |
94
.github/dependabot.yml
vendored
94
.github/dependabot.yml
vendored
@@ -1,12 +1,86 @@
|
||||
# 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: "devcontainers"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: weekly
|
||||
- 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
|
||||
|
||||
5
.github/workflows/backend-linter.yml
vendored
5
.github/workflows/backend-linter.yml
vendored
@@ -17,14 +17,15 @@ 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: ubuntu-latest
|
||||
runs-on: depot-ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v5
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v6
|
||||
|
||||
98
.github/workflows/build-next.yml
vendored
98
.github/workflows/build-next.yml
vendored
@@ -9,43 +9,40 @@ 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: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
id-token: write
|
||||
attestations: write
|
||||
runs-on: depot-ubuntu-latest
|
||||
|
||||
env:
|
||||
CONTAINER_IMAGE_NAME: ghcr.io/${{ github.repository_owner }}/pocket-id
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v5
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
uses: pnpm/action-setup@v5
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v5
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 24
|
||||
cache: "pnpm"
|
||||
|
||||
- name: Setup Go
|
||||
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: Set up Depot CLI
|
||||
uses: depot/setup-action@v1
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
@@ -54,6 +51,40 @@ 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
|
||||
|
||||
@@ -66,31 +97,40 @@ jobs:
|
||||
|
||||
- name: Build and push container image
|
||||
id: build-push-image
|
||||
uses: docker/build-push-action@v6
|
||||
uses: depot/build-push-action@v1
|
||||
with:
|
||||
context: .
|
||||
file: docker/Dockerfile-prebuilt
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: ${{ env.DOCKER_IMAGE_NAME }}:next
|
||||
file: docker/Dockerfile-prebuilt
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
sbom: false
|
||||
provenance: true
|
||||
|
||||
- name: Build and push container image (distroless)
|
||||
uses: docker/build-push-action@v6
|
||||
uses: depot/build-push-action@v1
|
||||
id: container-build-push-distroless
|
||||
with:
|
||||
context: .
|
||||
file: docker/Dockerfile-distroless
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: ${{ env.DOCKER_IMAGE_NAME }}:next-distroless
|
||||
file: docker/Dockerfile-distroless
|
||||
tags: ${{ steps.distroless-meta.outputs.tags }}
|
||||
labels: ${{ steps.distroless-meta.outputs.labels }}
|
||||
sbom: false
|
||||
provenance: true
|
||||
|
||||
- name: Container image attestation
|
||||
uses: actions/attest-build-provenance@v2
|
||||
with:
|
||||
subject-name: "${{ env.DOCKER_IMAGE_NAME }}"
|
||||
subject-name: "${{ env.CONTAINER_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.DOCKER_IMAGE_NAME }}"
|
||||
subject-name: "${{ env.CONTAINER_IMAGE_NAME }}"
|
||||
subject-digest: ${{ steps.container-build-push-distroless.outputs.digest }}
|
||||
push-to-registry: true
|
||||
|
||||
122
.github/workflows/e2e-tests.yml
vendored
122
.github/workflows/e2e-tests.yml
vendored
@@ -1,59 +1,27 @@
|
||||
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'
|
||||
permissions:
|
||||
contents: read
|
||||
actions: write
|
||||
runs-on: ubuntu-latest
|
||||
needs: build
|
||||
runs-on: depot-ubuntu-24.04-32
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -70,25 +38,33 @@ jobs:
|
||||
storage: database
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
uses: pnpm/action-setup@v5
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v5
|
||||
uses: actions/setup-node@v6
|
||||
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@v4
|
||||
uses: actions/cache@v5
|
||||
id: playwright-cache
|
||||
with:
|
||||
path: ~/.cache/ms-playwright
|
||||
key: ${{ runner.os }}-playwright-${{ hashFiles('pnpm-lock.yaml') }}
|
||||
|
||||
- name: Cache PostgreSQL Docker image
|
||||
uses: actions/cache@v4
|
||||
uses: actions/cache@v5
|
||||
id: postgres-cache
|
||||
with:
|
||||
path: /tmp/postgres-image.tar
|
||||
@@ -102,23 +78,8 @@ 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@v4
|
||||
uses: actions/cache@v5
|
||||
id: scim-cache
|
||||
with:
|
||||
path: /tmp/scim-test-server-image.tar
|
||||
@@ -134,45 +95,20 @@ jobs:
|
||||
|
||||
- name: Cache Localstack S3 Docker image
|
||||
if: matrix.storage == 's3'
|
||||
uses: actions/cache@v4
|
||||
uses: actions/cache@v5
|
||||
id: s3-cache
|
||||
with:
|
||||
path: /tmp/localstack-s3-image.tar
|
||||
key: localstack-s3-latest-${{ runner.os }}
|
||||
key: localstack-4.14.0-${{ 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:s3-latest
|
||||
docker save localstack/localstack:s3-latest > /tmp/localstack-s3-image.tar
|
||||
docker pull localstack/localstack:4.14.0
|
||||
docker save localstack/localstack:4.14.0 > /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
|
||||
|
||||
@@ -198,7 +134,7 @@ jobs:
|
||||
DOCKER_COMPOSE_FILE=docker-compose-s3.yml
|
||||
fi
|
||||
|
||||
docker compose -f "$DOCKER_COMPOSE_FILE" up -d
|
||||
docker compose -f "$DOCKER_COMPOSE_FILE" up -d --build
|
||||
|
||||
{
|
||||
LOG_FILE="/tmp/backend.log"
|
||||
@@ -219,7 +155,7 @@ jobs:
|
||||
run: pnpm exec playwright test
|
||||
|
||||
- name: Upload Test Report
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v7
|
||||
if: always() && github.event.pull_request.head.ref != 'i18n_crowdin'
|
||||
with:
|
||||
name: playwright-report-${{ matrix.db }}-${{ matrix.storage }}
|
||||
@@ -228,7 +164,7 @@ jobs:
|
||||
retention-days: 15
|
||||
|
||||
- name: Upload Backend Test Report
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v7
|
||||
if: always() && github.event.pull_request.head.ref != 'i18n_crowdin'
|
||||
with:
|
||||
name: backend-${{ matrix.db }}-${{ matrix.storage }}
|
||||
|
||||
95
.github/workflows/release.yml
vendored
95
.github/workflows/release.yml
vendored
@@ -5,105 +5,140 @@ on:
|
||||
tags:
|
||||
- "v*.*.*"
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
packages: write
|
||||
attestations: write
|
||||
id-token: write
|
||||
artifact-metadata: write
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
packages: write
|
||||
attestations: write
|
||||
id-token: write
|
||||
runs-on: depot-ubuntu-24.04-16
|
||||
|
||||
env:
|
||||
CONTAINER_IMAGE_NAME: ghcr.io/${{ github.repository_owner }}/pocket-id
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v3
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
uses: pnpm/action-setup@v5
|
||||
|
||||
- name: Set up Depot CLI
|
||||
uses: depot/setup-action@v1
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v5
|
||||
uses: actions/setup-node@v6
|
||||
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.DOCKER_IMAGE_NAME }}
|
||||
${{ env.CONTAINER_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.DOCKER_IMAGE_NAME }}
|
||||
${{ env.CONTAINER_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: docker/build-push-action@v6
|
||||
uses: depot/build-push-action@v1
|
||||
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 }}
|
||||
file: docker/Dockerfile-prebuilt
|
||||
sbom: false
|
||||
provenance: true
|
||||
|
||||
- name: Build and push container image (distroless)
|
||||
uses: docker/build-push-action@v6
|
||||
uses: depot/build-push-action@v1
|
||||
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 }}
|
||||
file: docker/Dockerfile-distroless
|
||||
sbom: false
|
||||
provenance: true
|
||||
|
||||
- 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.DOCKER_IMAGE_NAME }}"
|
||||
subject-name: "${{ env.CONTAINER_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.DOCKER_IMAGE_NAME }}"
|
||||
subject-name: "${{ env.CONTAINER_IMAGE_NAME }}"
|
||||
subject-digest: ${{ steps.container-build-push-distroless.outputs.digest }}
|
||||
push-to-registry: true
|
||||
- name: Upload binaries to release
|
||||
@@ -112,14 +147,12 @@ jobs:
|
||||
run: gh release upload ${{ github.ref_name }} backend/.bin/*
|
||||
|
||||
publish-release:
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: depot-ubuntu-latest
|
||||
needs: [build]
|
||||
permissions:
|
||||
contents: write
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v5
|
||||
uses: actions/checkout@v6
|
||||
- name: Mark release as published
|
||||
run: gh release edit ${{ github.ref_name }} --draft=false
|
||||
|
||||
19
.github/workflows/svelte-check.yml
vendored
19
.github/workflows/svelte-check.yml
vendored
@@ -21,28 +21,31 @@ 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: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
checks: write
|
||||
pull-requests: write
|
||||
runs-on: depot-ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v5
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
uses: pnpm/action-setup@v5
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v5
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 24
|
||||
cache: "pnpm"
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm --filter pocket-id-frontend install --frozen-lockfile
|
||||
|
||||
18
.github/workflows/unit-tests.yml
vendored
18
.github/workflows/unit-tests.yml
vendored
@@ -1,22 +1,24 @@
|
||||
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:
|
||||
permissions:
|
||||
contents: read
|
||||
actions: write
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: depot-ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version-file: "backend/go.mod"
|
||||
@@ -30,7 +32,7 @@ jobs:
|
||||
run: |
|
||||
set -e -o pipefail
|
||||
go test -tags=exclude_frontend -v ./... | tee /tmp/TestResults.log
|
||||
- uses: actions/upload-artifact@v4
|
||||
- uses: actions/upload-artifact@v7
|
||||
if: always()
|
||||
with:
|
||||
name: backend-unit-tests
|
||||
|
||||
5
.github/workflows/update-aaguids.yml
vendored
5
.github/workflows/update-aaguids.yml
vendored
@@ -8,14 +8,15 @@ on:
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
id-token: write
|
||||
|
||||
jobs:
|
||||
update-aaguids:
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: depot-ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v5
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Fetch JSON data
|
||||
run: |
|
||||
|
||||
28
CHANGELOG.md
28
CHANGELOG.md
@@ -1,3 +1,31 @@
|
||||
## 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
|
||||
|
||||
@@ -2,8 +2,11 @@
|
||||
|
||||
## Supported Versions
|
||||
|
||||
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).
|
||||
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.
|
||||
|
||||
## 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 write an email to [elias@eliasschneider.com](mailto:elias@eliasschneider.com).
|
||||
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).
|
||||
|
||||
@@ -2,8 +2,11 @@
|
||||
|
||||
package frontend
|
||||
|
||||
import "github.com/gin-gonic/gin"
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/service"
|
||||
)
|
||||
|
||||
func RegisterFrontend(router *gin.Engine, rateLimitMiddleware gin.HandlerFunc) error {
|
||||
func RegisterFrontend(router *gin.Engine, oidcService *service.OidcService) error {
|
||||
return ErrFrontendNotIncluded
|
||||
}
|
||||
|
||||
@@ -10,13 +10,14 @@ import (
|
||||
"io/fs"
|
||||
"mime"
|
||||
"net/http"
|
||||
"os"
|
||||
"path"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"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/*
|
||||
@@ -54,7 +55,7 @@ func init() {
|
||||
}
|
||||
}
|
||||
|
||||
func RegisterFrontend(router *gin.Engine, rateLimitMiddleware gin.HandlerFunc) error {
|
||||
func RegisterFrontend(router *gin.Engine, oidcService *service.OidcService) error {
|
||||
distFS, err := fs.Sub(frontendFS, "dist")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create sub FS: %w", err)
|
||||
@@ -78,21 +79,24 @@ func RegisterFrontend(router *gin.Engine, rateLimitMiddleware gin.HandlerFunc) e
|
||||
return
|
||||
}
|
||||
|
||||
if strings.HasPrefix(path, "api/") {
|
||||
if strings.HasPrefix(path, "api/") || strings.HasPrefix(path, ".well-known/") {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "API endpoint not found"})
|
||||
return
|
||||
}
|
||||
|
||||
// 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" {
|
||||
if isSPARequest(path, distFS) {
|
||||
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")
|
||||
validatedRedirectURI, err := oidcService.ResolveAllowedCallbackURL(c.Request.Context(), clientID, redirectURI)
|
||||
if err == nil {
|
||||
c.Header("Content-Security-Policy", middleware.BuildCSP(nonce, validatedRedirectURI))
|
||||
}
|
||||
}
|
||||
|
||||
// Do not cache the HTML shell, as it embeds a per-request nonce
|
||||
c.Header("Content-Type", "text/html; charset=utf-8")
|
||||
c.Header("Cache-Control", "no-store")
|
||||
@@ -108,11 +112,46 @@ func RegisterFrontend(router *gin.Engine, rateLimitMiddleware gin.HandlerFunc) e
|
||||
fileServer.ServeHTTP(c.Writer, c.Request)
|
||||
}
|
||||
|
||||
router.NoRoute(rateLimitMiddleware, handler)
|
||||
rateLimitMiddleware := middleware.NewRateLimitMiddleware().Add(rate.Every(300*time.Millisecond), 50)
|
||||
router.NoRoute(rateLimitOnlyForOAuth2AuthorizationPostRequest(rateLimitMiddleware, distFS), 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
|
||||
|
||||
111
backend/frontend/frontend_included_test.go
Normal file
111
backend/frontend/frontend_included_test.go
Normal file
@@ -0,0 +1,111 @@
|
||||
//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)
|
||||
})
|
||||
}
|
||||
@@ -3,41 +3,43 @@ module github.com/pocket-id/pocket-id/backend
|
||||
go 1.26.0
|
||||
|
||||
require (
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.5
|
||||
github.com/aws/aws-sdk-go-v2/config v1.32.14
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.14
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.99.0
|
||||
github.com/aws/smithy-go v1.24.3
|
||||
github.com/caarlos0/env/v11 v11.4.0
|
||||
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/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.8.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/glebarez/go-sqlite v1.22.0
|
||||
github.com/glebarez/sqlite v1.11.0
|
||||
github.com/go-co-op/gocron/v2 v2.20.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.16.4
|
||||
github.com/go-webauthn/webauthn v0.17.3
|
||||
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.0.13
|
||||
github.com/lestrrat-go/jwx/v3 v3.1.1
|
||||
github.com/lmittmann/tint v1.1.3
|
||||
github.com/mattn/go-isatty v0.0.21
|
||||
github.com/mattn/go-isatty v0.0.22
|
||||
github.com/mileusna/useragent v1.3.5
|
||||
github.com/orandin/slog-gorm v1.4.0
|
||||
github.com/oschwald/maxminddb-golang/v2 v2.1.1
|
||||
github.com/oschwald/maxminddb-golang/v2 v2.2.0
|
||||
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
|
||||
@@ -49,11 +51,10 @@ require (
|
||||
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.50.0
|
||||
golang.org/x/image v0.39.0
|
||||
golang.org/x/net v0.53.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.36.0
|
||||
golang.org/x/text v0.37.0
|
||||
golang.org/x/time v0.15.0
|
||||
gorm.io/driver/postgres v1.6.0
|
||||
gorm.io/gorm v1.31.1
|
||||
@@ -61,20 +62,19 @@ require (
|
||||
|
||||
require (
|
||||
github.com/Azure/go-ntlmssp v0.1.0 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.21 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.6 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.22 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.13 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.21 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.21 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/signin v1.0.9 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.30.15 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.19 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.41.10 // 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/beorn7/perks v1.0.1 // indirect
|
||||
github.com/bits-and-blooms/bitset v1.24.4 // indirect
|
||||
github.com/bytedance/gopkg v0.1.4 // indirect
|
||||
@@ -85,25 +85,37 @@ require (
|
||||
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/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.1 // indirect
|
||||
github.com/fxamacker/cbor/v2 v2.9.2 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.13 // indirect
|
||||
github.com/gin-contrib/sse v1.1.1 // 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.3 // 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/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
|
||||
@@ -141,7 +153,7 @@ require (
|
||||
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.3 // 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
|
||||
@@ -164,12 +176,14 @@ require (
|
||||
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.43.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
|
||||
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/mathutil v1.7.1 // indirect
|
||||
|
||||
189
backend/go.sum
189
backend/go.sum
@@ -6,44 +6,42 @@ 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.5 h1:dj5kopbwUsVUVFgO4Fi5BIT3t4WyqIDjGKCangnV/yY=
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.5/go.mod h1:mwsPRE8ceUUpiTgF7QmQIJ7lgsKUPQOUl3o72QBrE1o=
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8 h1:eBMB84YGghSocM7PsjmmPffTa+1FBUeNvGvFou6V/4o=
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8/go.mod h1:lyw7GFp3qENLh7kwzf7iMzAxDn+NzjXEAGjKS2UOKqI=
|
||||
github.com/aws/aws-sdk-go-v2/config v1.32.14 h1:opVIRo/ZbbI8OIqSOKmpFaY7IwfFUOCCXBsUpJOwDdI=
|
||||
github.com/aws/aws-sdk-go-v2/config v1.32.14/go.mod h1:U4/V0uKxh0Tl5sxmCBZ3AecYny4UNlVmObYjKuuaiOo=
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.14 h1:n+UcGWAIZHkXzYt87uMFBv/l8THYELoX6gVcUvgl6fI=
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.14/go.mod h1:cJKuyWB59Mqi0jM3nFYQRmnHVQIcgoxjEMAbLkpr62w=
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.21 h1:NUS3K4BTDArQqNu2ih7yeDLaS3bmHD0YndtA6UP884g=
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.21/go.mod h1:YWNWJQNjKigKY1RHVJCuupeWDrrHjRqHm0N9rdrWzYI=
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21 h1:Rgg6wvjjtX8bNHcvi9OnXWwcE0a2vGpbwmtICOsvcf4=
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21/go.mod h1:A/kJFst/nm//cyqonihbdpQZwiUhhzpqTsdbhDdRF9c=
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21 h1:PEgGVtPoB6NTpPrBgqSE5hE/o47Ij9qk/SEZFbUOe9A=
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21/go.mod h1:p+hz+PRAYlY3zcpJhPwXlLC4C+kqn70WIHwnzAfs6ps=
|
||||
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.6 h1:qYQ4pzQ2Oz6WpQ8T3HvGHnZydA72MnLuFK9tJwmrbHw=
|
||||
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.6/go.mod h1:O3h0IK87yXci+kg6flUKzJnWeziQUKciKrLjcatSNcY=
|
||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.22 h1:rWyie/PxDRIdhNf4DzRk0lvjVOqFJuNnO8WwaIRVxzQ=
|
||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.22/go.mod h1:zd/JsJ4P7oGfUhXn1VyLqaRZwPmZwg44Jf2dS84Dm3Y=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7 h1:5EniKhLZe4xzL7a+fU3C2tfUN4nWIqlLesfrjkuPFTY=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7/go.mod h1:x0nZssQ3qZSnIcePWLvcoFisRXJzcTVvYpAAdYX8+GI=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.13 h1:JRaIgADQS/U6uXDqlPiefP32yXTda7Kqfx+LgspooZM=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.13/go.mod h1:CEuVn5WqOMilYl+tbccq8+N2ieCy0gVn3OtRb0vBNNM=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.21 h1:c31//R3xgIJMSC8S6hEVq+38DcvUlgFY0FM6mSI5oto=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.21/go.mod h1:r6+pf23ouCB718FUxaqzZdbpYFyDtehyZcmP5KL9FkA=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.21 h1:ZlvrNcHSFFWURB8avufQq9gFsheUgjVD9536obIknfM=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.21/go.mod h1:cv3TNhVrssKR0O/xxLJVRfd2oazSnZnkUeTf6ctUwfQ=
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.99.0 h1:hlSuz394kV0vhv9drL5lhuEFbEOEP1VyQpy15qWh1Pk=
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.99.0/go.mod h1:uoA43SdFwacedBfSgfFSjjCvYe8aYBS7EnU5GZ/YKMM=
|
||||
github.com/aws/aws-sdk-go-v2/service/signin v1.0.9 h1:QKZH0S178gCmFEgst8hN0mCX1KxLgHBKKY/CLqwP8lg=
|
||||
github.com/aws/aws-sdk-go-v2/service/signin v1.0.9/go.mod h1:7yuQJoT+OoH8aqIxw9vwF+8KpvLZ8AWmvmUWHsGQZvI=
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.30.15 h1:lFd1+ZSEYJZYvv9d6kXzhkZu07si3f+GQ1AaYwa2LUM=
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.30.15/go.mod h1:WSvS1NLr7JaPunCXqpJnWk1Bjo7IxzZXrZi1QQCkuqM=
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.19 h1:dzztQ1YmfPrxdrOiuZRMF6fuOwWlWpD2StNLTceKpys=
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.19/go.mod h1:YO8TrYtFdl5w/4vmjL8zaBSsiNp3w0L1FfKVKenZT7w=
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.41.10 h1:p8ogvvLugcR/zLBXTXrTkj0RYBUdErbMnAFFp12Lm/U=
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.41.10/go.mod h1:60dv0eZJfeVXfbT1tFJinbHrDfSJ2GZl4Q//OSSNAVw=
|
||||
github.com/aws/smithy-go v1.24.3 h1:XgOAaUgx+HhVBoP4v8n6HCQoTRDhoMghKqw4LNHsDNg=
|
||||
github.com/aws/smithy-go v1.24.3/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
|
||||
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/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=
|
||||
@@ -55,8 +53,8 @@ github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uS
|
||||
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.0 h1:Kcb6t5kIIr4XkoQC9AF2j+8E1Jsrl3Wz/hhm1LtoGAc=
|
||||
github.com/caarlos0/env/v11 v11.4.0/go.mod h1:qupehSf/Y0TUTsxKywqRt/vJjN5nz6vauiYEUUr8P4U=
|
||||
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/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=
|
||||
@@ -67,6 +65,8 @@ 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=
|
||||
@@ -91,6 +91,32 @@ 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=
|
||||
@@ -101,10 +127,10 @@ 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.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/8M=
|
||||
github.com/fsnotify/fsnotify v1.8.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
||||
github.com/fxamacker/cbor/v2 v2.9.1 h1:2rWm8B193Ll4VdjsJY28jxs70IdDsHRWgQYAI80+rMQ=
|
||||
github.com/fxamacker/cbor/v2 v2.9.1/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
|
||||
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/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=
|
||||
@@ -119,8 +145,13 @@ github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GM
|
||||
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.20.0 h1:9IMrnnVSWjfSh3E54gWmWCHbloQJLh6f9+nwyKfLNpc=
|
||||
github.com/go-co-op/gocron/v2 v2.20.0/go.mod h1:5lEiCKk1oVJV39Zg7/YG10OnaVrDAV5GGR6O0663k6U=
|
||||
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-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
||||
@@ -138,10 +169,13 @@ github.com/go-playground/validator/v10 v10.30.2 h1:JiFIMtSSHb2/XBUbWM4i/MpeQm9ZK
|
||||
github.com/go-playground/validator/v10 v10.30.2/go.mod h1:mAf2pIOVXjTEBrwUMGKkCWKKPs9NheYGabeB04txQSc=
|
||||
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.16.4 h1:R9jqR/cYZa7hRquFF7Za/8qoH/K/TIs1/Q/4CyGN+1Q=
|
||||
github.com/go-webauthn/webauthn v0.16.4/go.mod h1:SU2ljAgToTV/YLPI0C05QS4qn+e04WpB5g1RMfcZfS4=
|
||||
github.com/go-webauthn/x v0.2.3 h1:8oArS+Rc1SWFLXhE17KZNx258Z4kUSyaDgsSncCO5RA=
|
||||
github.com/go-webauthn/x v0.2.3/go.mod h1:tM04GF3V6VYq79AZMl7vbj4q6pz9r7L2criWRzbWhPk=
|
||||
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/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
|
||||
@@ -152,6 +186,10 @@ 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=
|
||||
@@ -177,6 +215,8 @@ 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=
|
||||
@@ -203,6 +243,7 @@ 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=
|
||||
@@ -237,16 +278,16 @@ github.com/lestrrat-go/httpcc v1.0.1 h1:ydWCStUeJLkpYyjLDHihupbn2tYmZ7m22BGkcvZZ
|
||||
github.com/lestrrat-go/httpcc v1.0.1/go.mod h1:qiltp3Mt56+55GPVCbTdM9MlqhvzyuL6W/NMDA8vA5E=
|
||||
github.com/lestrrat-go/httprc/v3 v3.0.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.0.13 h1:AdHKiPIYeCSnOJtvdpipPg/0SuFh9rdkN+HF3O0VdSk=
|
||||
github.com/lestrrat-go/jwx/v3 v3.0.13/go.mod h1:2m0PV1A9tM4b/jVLMx8rh6rBl7F6WGb3EG2hufN9OQU=
|
||||
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/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/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.21 h1:xYae+lCNBP7QuW4PUnNG61ffM4hVIfm+zUzDuSzYLGs=
|
||||
github.com/mattn/go-isatty v0.0.21/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
|
||||
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/mileusna/useragent v1.3.5 h1:SJM5NzBmh/hO+4LGeATKpaEX9+b4vcGg2qXGLiNGDws=
|
||||
@@ -274,8 +315,8 @@ github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQ
|
||||
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.1.1 h1:lA8FH0oOrM4u7mLvowq8IT6a3Q/qEnqRzLQn9eH5ojc=
|
||||
github.com/oschwald/maxminddb-golang/v2 v2.1.1/go.mod h1:PLdx6PR+siSIoXqqy7C7r3SB3KZnhxWr1Dp6g0Hacl8=
|
||||
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=
|
||||
@@ -325,8 +366,8 @@ 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.3 h1:bCSxiTz386UTgyT1i0MSCvdbWjVW+8sG3PjkGsZQt4s=
|
||||
github.com/tinylib/msgp v1.6.3/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA=
|
||||
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=
|
||||
@@ -336,6 +377,8 @@ 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=
|
||||
@@ -408,13 +451,13 @@ golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliY
|
||||
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
|
||||
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
|
||||
golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc=
|
||||
golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
|
||||
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
|
||||
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/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
|
||||
golang.org/x/image v0.39.0 h1:skVYidAEVKgn8lZ602XO75asgXBgLj9G/FE3RbuPFww=
|
||||
golang.org/x/image v0.39.0/go.mod h1:sIbmppfU+xFLPIG0FoVUTvyBMmgng1/XAMhQ2ft0hpA=
|
||||
golang.org/x/image v0.40.0 h1:Tw4GyDXMo+daZN1znreBRC3VayR1aLFUyUEOLUdW1a8=
|
||||
golang.org/x/image v0.40.0/go.mod h1:uIc348UZMSvS5Z65CVZ7iDPaNobNFEPeJ4kbqTOszmA=
|
||||
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=
|
||||
@@ -424,6 +467,11 @@ 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/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=
|
||||
@@ -432,8 +480,8 @@ 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.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
|
||||
golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs=
|
||||
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/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=
|
||||
@@ -447,6 +495,7 @@ 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/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=
|
||||
@@ -457,8 +506,8 @@ 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.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
|
||||
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
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/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=
|
||||
@@ -478,8 +527,8 @@ golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
||||
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
|
||||
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
|
||||
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/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
@@ -506,6 +555,10 @@ google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j
|
||||
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=
|
||||
|
||||
@@ -15,12 +15,11 @@ import (
|
||||
"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"
|
||||
|
||||
@@ -40,7 +39,10 @@ func initRouter(db *gorm.DB, svc *services) (utils.Service, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
registerRoutes(r, db, svc)
|
||||
err = registerRoutes(r, db, svc)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
serverConfig, err := initServer(r)
|
||||
if err != nil {
|
||||
@@ -70,15 +72,6 @@ func initEngine() (*gin.Engine, error) {
|
||||
configureEngine(r)
|
||||
registerGlobalMiddleware(r)
|
||||
|
||||
frontendRateLimitMiddleware := middleware.NewRateLimitMiddleware().Add(rate.Every(100*time.Millisecond), 300)
|
||||
if err := frontend.RegisterFrontend(r, frontendRateLimitMiddleware); err != nil {
|
||||
if errors.Is(err, frontend.ErrFrontendNotIncluded) {
|
||||
slog.Warn("Frontend is not included in the build. Skipping frontend registration.")
|
||||
return r, nil
|
||||
}
|
||||
return nil, fmt.Errorf("failed to register frontend: %w", err)
|
||||
}
|
||||
|
||||
return r, nil
|
||||
}
|
||||
|
||||
@@ -116,7 +109,16 @@ func registerGlobalMiddleware(r *gin.Engine) {
|
||||
r.Use(middleware.NewErrorHandlerMiddleware().Add())
|
||||
}
|
||||
|
||||
func registerRoutes(r *gin.Engine, db *gorm.DB, svc *services) {
|
||||
func registerRoutes(r *gin.Engine, db *gorm.DB, svc *services) error {
|
||||
|
||||
err := frontend.RegisterFrontend(r, 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)
|
||||
}
|
||||
|
||||
// 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)
|
||||
@@ -130,7 +132,6 @@ func registerRoutes(r *gin.Engine, db *gorm.DB, svc *services) {
|
||||
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)
|
||||
@@ -142,6 +143,8 @@ func registerRoutes(r *gin.Engine, db *gorm.DB, svc *services) {
|
||||
|
||||
// These are not rate-limited.
|
||||
controller.NewHealthzController(r)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func registerTestRoutes(apiGroup *gin.RouterGroup, db *gorm.DB, svc *services) {
|
||||
@@ -160,24 +163,26 @@ func initServer(r *gin.Engine) (*serverConfig, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
network, addr := listenerNetworkAndAddr()
|
||||
listener, err := net.Listen(network, addr) //nolint:noctx
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create %s listener: %w", network, err)
|
||||
var socketFn func() (*socket, error)
|
||||
switch {
|
||||
case common.EnvConfig.SystemdSocket:
|
||||
socketFn = systemdSocket
|
||||
case common.EnvConfig.UnixSocket != "":
|
||||
socketFn = unixSocket
|
||||
default:
|
||||
socketFn = tcpSocket
|
||||
}
|
||||
|
||||
if err := setUnixSocketMode(network, addr); err != nil {
|
||||
listener.Close()
|
||||
socket, err := socketFn()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &serverConfig{
|
||||
addr: addr,
|
||||
certProvider: certProvider,
|
||||
listener: listener,
|
||||
server: newHTTPServer(r, protocols),
|
||||
tlsConfig: tlsConfig,
|
||||
}, nil
|
||||
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) {
|
||||
@@ -205,12 +210,70 @@ func initServerProtocols() (*http.Protocols, *tls.Config, *tlsCertProvider, erro
|
||||
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{
|
||||
MaxHeaderBytes: 1 << 20,
|
||||
ReadHeaderTimeout: 10 * time.Second,
|
||||
Protocols: protocols,
|
||||
Handler: h2c.NewHandler(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||
Handler: 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 {
|
||||
@@ -220,37 +283,10 @@ func newHTTPServer(r *gin.Engine, protocols *http.Protocols) *http.Server {
|
||||
}
|
||||
|
||||
r.ServeHTTP(w, req)
|
||||
}), &http2.Server{}),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
func listenerNetworkAndAddr() (string, string) {
|
||||
if common.EnvConfig.UnixSocket == "" {
|
||||
return "tcp", net.JoinHostPort(common.EnvConfig.Host, common.EnvConfig.Port)
|
||||
}
|
||||
|
||||
addr := common.EnvConfig.UnixSocket
|
||||
os.Remove(addr) // remove dangling the socket file to avoid file-exist error
|
||||
return "unix", addr
|
||||
}
|
||||
|
||||
func setUnixSocketMode(network, addr string) error {
|
||||
if network != "unix" || common.EnvConfig.UnixSocketMode == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
mode, err := strconv.ParseUint(common.EnvConfig.UnixSocketMode, 8, 32)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to parse UNIX socket mode '%s': %w", common.EnvConfig.UnixSocketMode, err)
|
||||
}
|
||||
|
||||
if err := os.Chmod(addr, os.FileMode(mode)); err != nil {
|
||||
return fmt.Errorf("failed to set UNIX socket mode '%s': %w", common.EnvConfig.UnixSocketMode, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func runServer(ctx context.Context, config *serverConfig) error {
|
||||
slog.Info("Server listening", slog.String("addr", config.addr), slog.Bool("tls", config.tlsConfig != nil))
|
||||
|
||||
|
||||
@@ -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
|
||||
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
|
||||
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
|
||||
}
|
||||
|
||||
// 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.customClaimService = service.NewCustomClaimService(db)
|
||||
svc.customFieldValueService = service.NewCustomFieldValueService(db, svc.appConfigService)
|
||||
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.customClaimService, svc.webauthnService, svc.scimService, httpClient, fileStorage)
|
||||
svc.oidcService, err = service.NewOidcService(ctx, db, svc.jwtService, svc.appConfigService, svc.auditLogService, svc.customFieldValueService, 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.scimService)
|
||||
svc.userService = service.NewUserService(db, svc.jwtService, svc.auditLogService, svc.emailService, svc.appConfigService, svc.customClaimService, svc.appImagesService, svc.scimService, fileStorage)
|
||||
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.ldapService = service.NewLdapService(db, httpClient, svc.appConfigService, svc.userService, svc.userGroupService, fileStorage)
|
||||
|
||||
svc.apiKeyService, err = service.NewApiKeyService(ctx, db, svc.emailService)
|
||||
|
||||
@@ -2,9 +2,12 @@ package cmds
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
@@ -13,8 +16,14 @@ import (
|
||||
)
|
||||
|
||||
type healthcheckFlags struct {
|
||||
Endpoint string
|
||||
Verbose bool
|
||||
Endpoint string
|
||||
UnixSocket string
|
||||
Verbose bool
|
||||
}
|
||||
|
||||
type healthcheckResult struct {
|
||||
StatusCode int
|
||||
URL string
|
||||
}
|
||||
|
||||
func init() {
|
||||
@@ -29,47 +38,26 @@ func init() {
|
||||
ctx, cancel := context.WithTimeout(cmd.Context(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
url := flags.Endpoint + "/healthz"
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if flags.UnixSocket == "" && !cmd.Flags().Changed("endpoint") {
|
||||
flags.UnixSocket = common.EnvConfig.UnixSocket
|
||||
}
|
||||
|
||||
result, err := healthcheck(ctx, flags)
|
||||
if err != nil {
|
||||
slog.ErrorContext(ctx,
|
||||
"Failed to create request object",
|
||||
"Healthcheck failed",
|
||||
"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", res.StatusCode,
|
||||
"url", url,
|
||||
"status", result.StatusCode,
|
||||
"url", result.URL,
|
||||
"unixSocket", flags.UnixSocket,
|
||||
"ms", time.Since(start).Milliseconds(),
|
||||
)
|
||||
}
|
||||
@@ -77,7 +65,42 @@ 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
|
||||
}
|
||||
|
||||
79
backend/internal/cmds/healthcheck_test.go
Normal file
79
backend/internal/cmds/healthcheck_test.go
Normal file
@@ -0,0 +1,79 @@
|
||||
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)
|
||||
}
|
||||
@@ -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"`
|
||||
S3SecretAccessKey string `env:"S3_SECRET_ACCESS_KEY" options:"file"`
|
||||
S3ForcePathStyle bool `env:"S3_FORCE_PATH_STYLE"`
|
||||
S3DisableDefaultIntegrityChecks bool `env:"S3_DISABLE_DEFAULT_INTEGRITY_CHECKS"`
|
||||
|
||||
@@ -68,6 +68,7 @@ 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"`
|
||||
@@ -153,6 +154,9 @@ func ValidateEnvConfig(config *EnvConfigSchema) error {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -8,21 +8,28 @@ 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)
|
||||
@@ -30,445 +37,364 @@ func (e *AlreadyInUseError) Is(target error) bool {
|
||||
|
||||
type SetupNotAvailableError struct{}
|
||||
|
||||
func (e *SetupNotAvailableError) Error() string { return "not found" }
|
||||
func (e *SetupNotAvailableError) HttpStatusCode() int { return http.StatusNotFound }
|
||||
func (e SetupNotAvailableError) Error() string { return "not found" }
|
||||
func (e SetupNotAvailableError) HttpStatusCode() int { return http.StatusNotFound }
|
||||
|
||||
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 ReservedClaimError struct {
|
||||
type ReservedCustomFieldError struct {
|
||||
Key string
|
||||
}
|
||||
|
||||
func (e *ReservedClaimError) Error() string {
|
||||
return fmt.Sprintf("Claim %s is reserved and can't be used", e.Key)
|
||||
func (e ReservedCustomFieldError) Error() string {
|
||||
return fmt.Sprintf("Custom field %s is reserved and can't be used", e.Key)
|
||||
}
|
||||
func (e *ReservedClaimError) HttpStatusCode() int { return http.StatusBadRequest }
|
||||
func (e ReservedCustomFieldError) HttpStatusCode() int { return http.StatusBadRequest }
|
||||
|
||||
type DuplicateClaimError struct {
|
||||
type DuplicateCustomFieldError struct {
|
||||
Key string
|
||||
}
|
||||
|
||||
func (e *DuplicateClaimError) Error() string {
|
||||
return fmt.Sprintf("Claim %s is already defined", e.Key)
|
||||
func (e DuplicateCustomFieldError) Error() string {
|
||||
return fmt.Sprintf("Custom field %s is already defined", e.Key)
|
||||
}
|
||||
func (e *DuplicateClaimError) HttpStatusCode() int { return http.StatusBadRequest }
|
||||
func (e DuplicateCustomFieldError) 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 }
|
||||
|
||||
type OidcMissingCodeChallengeError struct{}
|
||||
|
||||
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 }
|
||||
|
||||
type LdapUserGroupUpdateError struct{}
|
||||
|
||||
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 }
|
||||
|
||||
type OidcClientIdNotMatchingError struct{}
|
||||
|
||||
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 }
|
||||
|
||||
type OidcNoCallbackURLError struct{}
|
||||
|
||||
func (e *OidcNoCallbackURLError) Error() string {
|
||||
return "No callback URL provided"
|
||||
}
|
||||
func (e *OidcNoCallbackURLError) HttpStatusCode() int { return http.StatusBadRequest }
|
||||
|
||||
type UiConfigDisabledError struct{}
|
||||
|
||||
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 }
|
||||
|
||||
type InvalidUUIDError struct{}
|
||||
|
||||
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 }
|
||||
|
||||
type InvalidAPIKeyError struct{}
|
||||
|
||||
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 }
|
||||
|
||||
type APIKeyNotFoundError struct{}
|
||||
|
||||
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 }
|
||||
|
||||
type APIKeyExpirationDateError struct{}
|
||||
|
||||
func (e *APIKeyExpirationDateError) Error() string {
|
||||
return "API Key expiration time must be in the future"
|
||||
}
|
||||
func (e *APIKeyExpirationDateError) HttpStatusCode() int { return http.StatusBadRequest }
|
||||
|
||||
type APIKeyAuthNotAllowedError struct{}
|
||||
|
||||
func (e *APIKeyAuthNotAllowedError) Error() string {
|
||||
return "API key authentication is not allowed for this endpoint"
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
type OidcMissingRefreshTokenError struct{}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
type UserDisabledError struct{}
|
||||
|
||||
func (e *UserDisabledError) Error() string {
|
||||
return "User account is disabled"
|
||||
}
|
||||
func (e *UserDisabledError) HttpStatusCode() int {
|
||||
return http.StatusForbidden
|
||||
}
|
||||
|
||||
type ValidationError struct {
|
||||
type CustomFieldValidationError struct {
|
||||
Message string
|
||||
}
|
||||
|
||||
func (e *ValidationError) Error() string {
|
||||
return e.Message
|
||||
}
|
||||
func (e CustomFieldValidationError) Error() string { return e.Message }
|
||||
func (e CustomFieldValidationError) HttpStatusCode() int { return http.StatusBadRequest }
|
||||
|
||||
func (e *ValidationError) 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 }
|
||||
|
||||
type OidcMissingCodeChallengeError struct{}
|
||||
|
||||
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 }
|
||||
|
||||
type LdapUserGroupUpdateError struct{}
|
||||
|
||||
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 }
|
||||
|
||||
type OidcClientIdNotMatchingError struct{}
|
||||
|
||||
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 }
|
||||
|
||||
type OidcNoCallbackURLError struct{}
|
||||
|
||||
func (e OidcNoCallbackURLError) Error() string {
|
||||
return "No callback URL provided"
|
||||
}
|
||||
func (e OidcNoCallbackURLError) HttpStatusCode() int { return http.StatusBadRequest }
|
||||
|
||||
type UiConfigDisabledError struct{}
|
||||
|
||||
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 }
|
||||
|
||||
type InvalidUUIDError struct{}
|
||||
|
||||
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 }
|
||||
|
||||
type InvalidAPIKeyError struct{}
|
||||
|
||||
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 }
|
||||
|
||||
type APIKeyNotFoundError struct{}
|
||||
|
||||
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 }
|
||||
|
||||
type APIKeyExpirationDateError struct{}
|
||||
|
||||
func (e APIKeyExpirationDateError) Error() string {
|
||||
return "API Key expiration time must be in the future"
|
||||
}
|
||||
func (e APIKeyExpirationDateError) HttpStatusCode() int { return http.StatusBadRequest }
|
||||
|
||||
type APIKeyAuthNotAllowedError struct{}
|
||||
|
||||
func (e APIKeyAuthNotAllowedError) Error() string {
|
||||
return "API key authentication is not allowed for this endpoint"
|
||||
}
|
||||
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 }
|
||||
|
||||
type OidcMissingRefreshTokenError struct{}
|
||||
|
||||
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 }
|
||||
|
||||
type UserDisabledError struct{}
|
||||
|
||||
func (e UserDisabledError) Error() string { return "User account is disabled" }
|
||||
func (e UserDisabledError) HttpStatusCode() int { return http.StatusForbidden }
|
||||
|
||||
type ValidationError struct{ Message string }
|
||||
|
||||
func (e ValidationError) Error() string { return e.Message }
|
||||
|
||||
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) Error() string { return "Invalid email verification token" }
|
||||
|
||||
func (e *InvalidEmailVerificationTokenError) HttpStatusCode() int {
|
||||
return http.StatusBadRequest
|
||||
}
|
||||
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 }
|
||||
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 }
|
||||
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 }
|
||||
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 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 OidcAccountSelectionRequiredError) Error() string { return "account_selection_required" }
|
||||
func (e OidcAccountSelectionRequiredError) HttpStatusCode() int { return http.StatusBadRequest }
|
||||
|
||||
@@ -49,7 +49,7 @@ type AppConfigController struct {
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Success 200 {array} dto.PublicAppConfigVariableDto
|
||||
// @Router /application-configuration [get]
|
||||
// @Router /api/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 /application-configuration/all [get]
|
||||
// @Router /api/application-configuration/all [get]
|
||||
func (acc *AppConfigController) listAllAppConfigHandler(c *gin.Context) {
|
||||
configuration := acc.appConfigService.ListAppConfig(true)
|
||||
|
||||
|
||||
@@ -1,115 +0,0 @@
|
||||
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)
|
||||
}
|
||||
@@ -202,10 +202,8 @@ func (oc *OidcController) createTokensHandler(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
// Check if the client ID / secret are passed in the Authorization header (RFC 6749)
|
||||
parseBasicAuth(c.Request, &input.ClientSecret, &input.ClientID)
|
||||
|
||||
tokens, err := oc.createTokens(c.Request.Context(), input)
|
||||
|
||||
@@ -313,6 +311,12 @@ 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()
|
||||
@@ -692,6 +696,22 @@ 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
|
||||
@@ -704,10 +724,8 @@ func (oc *OidcController) deviceAuthorizationHandler(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
// Check if the client ID / secret are passed in the Authorization header (RFC 6749)
|
||||
parseBasicAuth(c.Request, &input.ClientSecret, &input.ClientID)
|
||||
|
||||
response, err := oc.oidcService.CreateDeviceAuthorization(c.Request.Context(), input)
|
||||
if err != nil {
|
||||
|
||||
@@ -146,6 +146,43 @@ 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) {
|
||||
|
||||
@@ -91,7 +91,7 @@ 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"},
|
||||
"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)
|
||||
|
||||
@@ -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"`
|
||||
SignupDefaultCustomClaims string `json:"signupDefaultCustomClaims" binding:"omitempty,json"`
|
||||
CustomFields string `json:"customFields" binding:"omitempty,json"`
|
||||
AccentColor string `json:"accentColor"`
|
||||
RequireUserEmail string `json:"requireUserEmail" binding:"required"`
|
||||
SmtpHost string `json:"smtpHost"`
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
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"`
|
||||
}
|
||||
42
backend/internal/dto/custom_field_dto.go
Normal file
42
backend/internal/dto/custom_field_dto.go
Normal file
@@ -0,0 +1,42 @@
|
||||
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"`
|
||||
}
|
||||
@@ -72,6 +72,7 @@ type AuthorizeOidcClientRequestDto struct {
|
||||
CodeChallengeMethod string `json:"codeChallengeMethod"`
|
||||
ReauthenticationToken string `json:"reauthenticationToken"`
|
||||
Prompt string `json:"prompt"`
|
||||
ResponseMode string `json:"responseMode" binding:"omitempty,response_mode"`
|
||||
}
|
||||
|
||||
type AuthorizeOidcClientResponseDto struct {
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
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"`
|
||||
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"`
|
||||
}
|
||||
|
||||
@@ -7,33 +7,34 @@ 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"`
|
||||
CustomClaims []CustomClaimDto `json:"customClaims"`
|
||||
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"`
|
||||
CustomFieldValues []CustomFieldValueDto `json:"customFieldValues"`
|
||||
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"`
|
||||
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"`
|
||||
CustomFieldValues []CustomFieldValueCreateDto `json:"customFieldValues"`
|
||||
LdapID string `json:"-"`
|
||||
}
|
||||
|
||||
func (u UserCreateDto) Validate() error {
|
||||
|
||||
@@ -11,7 +11,7 @@ type UserGroupDto struct {
|
||||
ID string `json:"id"`
|
||||
FriendlyName string `json:"friendlyName"`
|
||||
Name string `json:"name"`
|
||||
CustomClaims []CustomClaimDto `json:"customClaims"`
|
||||
CustomFieldValues []CustomFieldValueDto `json:"customFieldValues"`
|
||||
LdapID *string `json:"ldapId"`
|
||||
CreatedAt datatype.DateTime `json:"createdAt"`
|
||||
Users []UserDto `json:"users"`
|
||||
@@ -22,7 +22,6 @@ 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"`
|
||||
@@ -33,9 +32,10 @@ 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"`
|
||||
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"`
|
||||
CustomFieldValues []CustomFieldValueCreateDto `json:"customFieldValues"`
|
||||
LdapID string `json:"-"`
|
||||
}
|
||||
|
||||
func (g UserGroupCreateDto) Validate() error {
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
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"
|
||||
@@ -33,6 +35,12 @@ func init() {
|
||||
"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 {
|
||||
@@ -47,6 +55,9 @@ func init() {
|
||||
"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())
|
||||
},
|
||||
}
|
||||
for k, v := range validators {
|
||||
err := engine.RegisterValidation(k, v)
|
||||
@@ -56,6 +67,16 @@ func init() {
|
||||
}
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
return e.Struct(input)
|
||||
}
|
||||
|
||||
// ValidateUsername validates username inputs
|
||||
func ValidateUsername(username string) bool {
|
||||
return validateUsernameRegex.MatchString(username)
|
||||
@@ -66,6 +87,20 @@ 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:"
|
||||
@@ -87,3 +122,17 @@ func ValidateCallbackURLPattern(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"
|
||||
// Empty responseMode is allowed (field not provided, use default)
|
||||
func ValidateResponseMode(responseMode string) bool {
|
||||
switch responseMode {
|
||||
case "form_post", "query", "fragment":
|
||||
return true
|
||||
case "":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,6 +58,62 @@ 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
|
||||
input string
|
||||
expected bool
|
||||
}{
|
||||
{"valid form_post", "form_post", true},
|
||||
{"valid query", "query", true},
|
||||
{"valid fragment", "fragment", true},
|
||||
{"valid empty", "", true},
|
||||
{"invalid unknown", "unknown", false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
assert.Equal(t, tt.expected, ValidateResponseMode(tt.input))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateCallbackURL(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
|
||||
@@ -37,7 +37,9 @@ func TestWithApiKeyAuthDisabled(t *testing.T) {
|
||||
jwtService, err := service.NewJwtService(t.Context(), db, appConfigService)
|
||||
require.NoError(t, err)
|
||||
|
||||
userService := service.NewUserService(db, jwtService, nil, nil, appConfigService, nil, nil, nil, nil)
|
||||
customFieldsValueService := service.NewCustomFieldValueService(db, appConfigService)
|
||||
|
||||
userService := service.NewUserService(db, jwtService, nil, nil, appConfigService, customFieldsValueService, nil, nil, nil)
|
||||
apiKeyService, err := service.NewApiKeyService(t.Context(), db, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -88,10 +90,9 @@ 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: &email,
|
||||
Email: new("auth@example.com"),
|
||||
FirstName: "Auth",
|
||||
LastName: "User",
|
||||
DisplayName: "Auth User",
|
||||
|
||||
@@ -3,6 +3,7 @@ package middleware
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
@@ -28,22 +29,39 @@ func (m *CspMiddleware) Add() gin.HandlerFunc {
|
||||
// Generate a random base64 nonce for this request
|
||||
nonce := generateNonce()
|
||||
c.Set("csp_nonce", nonce)
|
||||
c.Writer.Header().Set("Content-Security-Policy", BuildCSP(nonce))
|
||||
|
||||
csp := "default-src 'self'; " +
|
||||
"base-uri 'self'; " +
|
||||
"object-src 'none'; " +
|
||||
"frame-ancestors 'none'; " +
|
||||
"form-action 'self'; " +
|
||||
"img-src * blob:;" +
|
||||
"font-src 'self'; " +
|
||||
"style-src 'self' 'unsafe-inline'; " +
|
||||
"script-src 'self' 'nonce-" + nonce + "'"
|
||||
|
||||
c.Writer.Header().Set("Content-Security-Policy", csp)
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
formAction += b.String()
|
||||
}
|
||||
|
||||
return "default-src 'self'; " +
|
||||
"base-uri 'self'; " +
|
||||
"object-src 'none'; " +
|
||||
"frame-ancestors 'none'; " +
|
||||
"form-action " + formAction + "; " +
|
||||
"img-src * blob:;" +
|
||||
"font-src 'self'; " +
|
||||
"style-src 'self' 'unsafe-inline'; " +
|
||||
"script-src 'self' 'nonce-" + nonce + "'"
|
||||
}
|
||||
|
||||
func generateNonce() string {
|
||||
b := make([]byte, 16)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
|
||||
24
backend/internal/middleware/csp_middleware_test.go
Normal file
24
backend/internal/middleware/csp_middleware_test.go
Normal file
@@ -0,0 +1,24 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestBuildCSP(t *testing.T) {
|
||||
t.Run("uses self form action by default", func(t *testing.T) {
|
||||
csp := BuildCSP("test-nonce")
|
||||
|
||||
assert.Contains(t, csp, "form-action 'self';")
|
||||
assert.Contains(t, csp, "script-src 'self' 'nonce-test-nonce'")
|
||||
})
|
||||
|
||||
t.Run("adds validated form action targets", func(t *testing.T) {
|
||||
csp := BuildCSP("test-nonce", "https://example.com/callback")
|
||||
|
||||
assert.Contains(t, csp, "form-action 'self' https://example.com/callback;")
|
||||
assert.Equal(t, 1, strings.Count(csp, "form-action"))
|
||||
})
|
||||
}
|
||||
@@ -23,7 +23,6 @@ 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")
|
||||
@@ -39,30 +38,56 @@ func (m *ErrorHandlerMiddleware) Add() gin.HandlerFunc {
|
||||
}
|
||||
|
||||
// Check for slice validation errors
|
||||
var sliceValidationErrors binding.SliceValidationError
|
||||
if errors.As(err, &sliceValidationErrors) {
|
||||
if errors.As(sliceValidationErrors[0], &validationErrors) {
|
||||
svErr, ok := errors.AsType[binding.SliceValidationError](err)
|
||||
if ok {
|
||||
if errors.As(svErr[0], &validationErrors) {
|
||||
message := handleValidationError(validationErrors)
|
||||
errorResponse(c, http.StatusBadRequest, message)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
var appErr common.AppError
|
||||
if errors.As(err, &appErr) {
|
||||
// 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 {
|
||||
errorResponse(c, appErr.HttpStatusCode(), appErr.Error())
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Something went wrong"})
|
||||
c.JSON(http.StatusInternalServerError, errorResponseBody{
|
||||
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, gin.H{"error": message})
|
||||
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,
|
||||
})
|
||||
}
|
||||
|
||||
func handleValidationError(validationErrors validator.ValidationErrors) string {
|
||||
|
||||
@@ -43,7 +43,7 @@ type AppConfig struct {
|
||||
AllowOwnAccountEdit AppConfigVariable `key:"allowOwnAccountEdit,public"` // Public
|
||||
AllowUserSignups AppConfigVariable `key:"allowUserSignups,public"` // Public
|
||||
SignupDefaultUserGroupIDs AppConfigVariable `key:"signupDefaultUserGroupIDs"`
|
||||
SignupDefaultCustomClaims AppConfigVariable `key:"signupDefaultCustomClaims"`
|
||||
CustomFields AppConfigVariable `key:"customFields,public"` // Public
|
||||
// Internal
|
||||
InstanceID AppConfigVariable `key:"instanceId,internal"` // Internal
|
||||
// Email
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
package model
|
||||
|
||||
type CustomClaim struct {
|
||||
Base
|
||||
|
||||
Key string
|
||||
Value string
|
||||
|
||||
UserID *string
|
||||
UserGroupID *string
|
||||
}
|
||||
11
backend/internal/model/custom_field_value.go
Normal file
11
backend/internal/model/custom_field_value.go
Normal file
@@ -0,0 +1,11 @@
|
||||
package model
|
||||
|
||||
type CustomFieldValue struct {
|
||||
Base
|
||||
|
||||
CustomFieldID string
|
||||
Value string
|
||||
|
||||
UserID *string
|
||||
UserGroupID *string
|
||||
}
|
||||
@@ -79,6 +79,7 @@ type OidcRefreshToken struct {
|
||||
Base
|
||||
|
||||
Token string
|
||||
IdTokenJti *string
|
||||
ExpiresAt datatype.DateTime
|
||||
Scope string
|
||||
AuthenticationMethod string
|
||||
|
||||
@@ -26,9 +26,9 @@ type User struct {
|
||||
Disabled bool `sortable:"true" filterable:"true"`
|
||||
UpdatedAt *datatype.DateTime
|
||||
|
||||
CustomClaims []CustomClaim
|
||||
UserGroups []UserGroup `gorm:"many2many:user_groups_users;"`
|
||||
Credentials []WebauthnCredential
|
||||
CustomFieldValues []CustomFieldValue
|
||||
UserGroups []UserGroup `gorm:"many2many:user_groups_users;"`
|
||||
Credentials []WebauthnCredential
|
||||
}
|
||||
|
||||
func (u User) WebAuthnID() []byte { return []byte(u.ID) }
|
||||
|
||||
@@ -13,7 +13,7 @@ type UserGroup struct {
|
||||
LdapID *string
|
||||
UpdatedAt *datatype.DateTime
|
||||
Users []User `gorm:"many2many:user_groups_users;"`
|
||||
CustomClaims []CustomClaim
|
||||
CustomFieldValues []CustomFieldValue
|
||||
AllowedOidcClients []OidcClient `gorm:"many2many:oidc_clients_allowed_user_groups;"`
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
@@ -67,7 +68,7 @@ func (s *AppConfigService) getDefaultDbConfig() *model.AppConfig {
|
||||
AllowOwnAccountEdit: model.AppConfigVariable{Value: "true"},
|
||||
AllowUserSignups: model.AppConfigVariable{Value: "disabled"},
|
||||
SignupDefaultUserGroupIDs: model.AppConfigVariable{Value: "[]"},
|
||||
SignupDefaultCustomClaims: model.AppConfigVariable{Value: "[]"},
|
||||
CustomFields: model.AppConfigVariable{Value: "[]"},
|
||||
AccentColor: model.AppConfigVariable{Value: "default"},
|
||||
// Internal
|
||||
InstanceID: model.AppConfigVariable{Value: ""},
|
||||
@@ -158,6 +159,87 @@ 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{}
|
||||
@@ -179,6 +261,11 @@ 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
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
@@ -243,7 +244,7 @@ func TestUpdateAppConfigValues(t *testing.T) {
|
||||
// Verify database was updated
|
||||
var count int64
|
||||
db.Model(&model.AppConfigVariable{}).Count(&count)
|
||||
require.Equal(t, int64(3), count)
|
||||
require.GreaterOrEqual(t, count, int64(3))
|
||||
|
||||
var appName, sessionDuration, smtpHost model.AppConfigVariable
|
||||
err = db.Where("key = ?", "appName").First(&appName).Error
|
||||
@@ -470,4 +471,98 @@ 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)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ 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 {
|
||||
@@ -68,7 +69,12 @@ func (s *AppImagesService) UpdateImage(ctx context.Context, file *multipart.File
|
||||
}
|
||||
defer fileReader.Close()
|
||||
|
||||
if err := s.storage.Save(ctx, imagePath, fileReader); err != nil {
|
||||
strippedReader, err := imageutil.StripMetadata(fileReader, fileType)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := s.storage.Save(ctx, imagePath, strippedReader); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ package service
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"io"
|
||||
"io/fs"
|
||||
"mime/multipart"
|
||||
@@ -56,6 +57,29 @@ 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)
|
||||
@@ -112,3 +136,26 @@ 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()
|
||||
}
|
||||
|
||||
@@ -154,8 +154,7 @@ func TestAppLockServiceAcquire(t *testing.T) {
|
||||
db := testutils.NewDatabaseForTest(t)
|
||||
service := newTestAppLockService(t, db)
|
||||
|
||||
raw := "this-is-not-json"
|
||||
err := db.Create(&model.KV{Key: lockKey, Value: &raw}).Error
|
||||
err := db.Create(&model.KV{Key: lockKey, Value: new("this-is-not-json")}).Error
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = service.Acquire(context.Background(), false)
|
||||
|
||||
@@ -1,260 +0,0 @@
|
||||
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
|
||||
}
|
||||
566
backend/internal/service/custom_field_service.go
Normal file
566
backend/internal/service/custom_field_service.go
Normal file
@@ -0,0 +1,566 @@
|
||||
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
|
||||
}
|
||||
183
backend/internal/service/custom_field_service_test.go
Normal file
183
backend/internal/service/custom_field_service_test.go
Normal file
@@ -0,0 +1,183 @@
|
||||
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)
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"path"
|
||||
@@ -17,6 +18,7 @@ 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"
|
||||
@@ -271,6 +273,7 @@ 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",
|
||||
@@ -562,6 +565,56 @@ 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 {
|
||||
|
||||
@@ -258,64 +258,65 @@ 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, error) {
|
||||
func (s *JwtService) BuildIDToken(userClaims map[string]any, clientID string, nonce string, authenticationMethod string) (jwt.Token, string, 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(uuid.New().String()).
|
||||
JwtID(jti).
|
||||
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)
|
||||
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 '%s' claim in token: %w", AuthenticationMethodsClaim, 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, nil
|
||||
return token, jti, nil
|
||||
}
|
||||
|
||||
// GenerateIDToken creates and signs an ID token
|
||||
func (s *JwtService) GenerateIDToken(userClaims map[string]any, clientID string, nonce string, authenticationMethod string) (string, error) {
|
||||
token, err := s.BuildIDToken(userClaims, clientID, nonce, authenticationMethod)
|
||||
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)
|
||||
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), nil
|
||||
return string(signed), jti, nil
|
||||
}
|
||||
|
||||
func (s *JwtService) VerifyIdToken(tokenString string, acceptExpiredTokens bool) (jwt.Token, error) {
|
||||
|
||||
@@ -530,9 +530,10 @@ func TestGenerateVerifyIdToken(t *testing.T) {
|
||||
}
|
||||
const clientID = "test-client-123"
|
||||
|
||||
tokenString, err := service.GenerateIDToken(userClaims, clientID, "", "")
|
||||
tokenString, jti, 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")
|
||||
@@ -549,6 +550,7 @@ 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()
|
||||
@@ -615,7 +617,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()
|
||||
@@ -636,7 +638,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"
|
||||
@@ -662,7 +664,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")
|
||||
|
||||
@@ -699,7 +701,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")
|
||||
|
||||
@@ -737,7 +739,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")
|
||||
|
||||
|
||||
@@ -216,8 +216,69 @@ 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{
|
||||
@@ -225,6 +286,7 @@ 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,
|
||||
@@ -267,9 +329,10 @@ 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,
|
||||
Name: value.GetAttributeValue(dbConfig.LdapAttributeGroupName.Value),
|
||||
FriendlyName: value.GetAttributeValue(dbConfig.LdapAttributeGroupName.Value),
|
||||
LdapID: ldapID,
|
||||
CustomFieldValues: customFieldValuesFromLDAPEntry(value, customFields),
|
||||
}
|
||||
dto.Normalize(&syncGroup)
|
||||
|
||||
@@ -291,6 +354,10 @@ 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{
|
||||
@@ -304,6 +371,7 @@ 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(
|
||||
@@ -342,12 +410,13 @@ 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),
|
||||
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),
|
||||
// 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,
|
||||
@@ -423,6 +492,11 @@ 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 {
|
||||
@@ -448,28 +522,38 @@ 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)
|
||||
}
|
||||
continue
|
||||
} 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)
|
||||
}
|
||||
}
|
||||
|
||||
_, 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)
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -500,6 +584,10 @@ 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)
|
||||
@@ -551,6 +639,11 @@ 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,
|
||||
@@ -583,8 +676,7 @@ func (s *LdapService) reconcileUsers(ctx context.Context, tx *gorm.DB, desiredUs
|
||||
|
||||
err = s.userService.deleteUserInternal(ctx, tx, user.ID, true)
|
||||
if err != nil {
|
||||
target := &common.LdapUserUpdateError{}
|
||||
if errors.As(err, &target) {
|
||||
if _, ok := errors.AsType[*common.LdapUserUpdateError](err); ok {
|
||||
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)
|
||||
|
||||
@@ -141,6 +141,51 @@ 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()
|
||||
@@ -318,14 +363,15 @@ func newTestLdapServiceWithAppConfig(t *testing.T, appConfigModel *model.AppConf
|
||||
|
||||
appConfig := NewTestAppConfigService(appConfigModel)
|
||||
|
||||
groupService := NewUserGroupService(db, appConfig, nil)
|
||||
customFieldValueService := NewCustomFieldValueService(db, appConfig)
|
||||
groupService := NewUserGroupService(db, appConfig, customFieldValueService, nil)
|
||||
userService := NewUserService(
|
||||
db,
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
appConfig,
|
||||
NewCustomClaimService(db),
|
||||
customFieldValueService,
|
||||
NewAppImagesService(map[string]string{}, fileStorage),
|
||||
nil,
|
||||
fileStorage,
|
||||
@@ -405,6 +451,15 @@ 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
|
||||
|
||||
@@ -6,7 +6,6 @@ import (
|
||||
"crypto/subtle"
|
||||
"crypto/tls"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
@@ -34,6 +33,7 @@ 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
|
||||
customClaimService *CustomClaimService
|
||||
webAuthnService *WebAuthnService
|
||||
scimService *ScimService
|
||||
db *gorm.DB
|
||||
jwtService *JwtService
|
||||
appConfigService *AppConfigService
|
||||
auditLogService *AuditLogService
|
||||
customFieldValueService *CustomFieldValueService
|
||||
webAuthnService *WebAuthnService
|
||||
scimService *ScimService
|
||||
|
||||
httpClient *http.Client
|
||||
jwkCache *jwk.Cache
|
||||
@@ -69,22 +69,22 @@ func NewOidcService(
|
||||
jwtService *JwtService,
|
||||
appConfigService *AppConfigService,
|
||||
auditLogService *AuditLogService,
|
||||
customClaimService *CustomClaimService,
|
||||
customFieldValueService *CustomFieldValueService,
|
||||
webAuthnService *WebAuthnService,
|
||||
scimService *ScimService,
|
||||
httpClient *http.Client,
|
||||
fileStorage storage.FileStorage,
|
||||
) (s *OidcService, err error) {
|
||||
s = &OidcService{
|
||||
db: db,
|
||||
jwtService: jwtService,
|
||||
appConfigService: appConfigService,
|
||||
auditLogService: auditLogService,
|
||||
customClaimService: customClaimService,
|
||||
webAuthnService: webAuthnService,
|
||||
scimService: scimService,
|
||||
httpClient: httpClient,
|
||||
fileStorage: fileStorage,
|
||||
db: db,
|
||||
jwtService: jwtService,
|
||||
appConfigService: appConfigService,
|
||||
auditLogService: auditLogService,
|
||||
customFieldValueService: customFieldValueService,
|
||||
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
|
||||
@@ -151,22 +151,20 @@ func (s *OidcService) Authorize(ctx context.Context, input dto.AuthorizeOidcClie
|
||||
|
||||
// Parse prompt parameter (space-delimited list per OIDC spec)
|
||||
promptValues := parsePromptParameter(input.Prompt)
|
||||
hasPromptNone := contains(promptValues, "none")
|
||||
hasPromptLogin := contains(promptValues, "login")
|
||||
hasPromptConsent := contains(promptValues, "consent")
|
||||
hasPromptSelectAccount := contains(promptValues, "select_account")
|
||||
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.OidcInteractionRequiredError{}
|
||||
return "", "", common.NewOidcInvalidRequestError("prompt type 'none' cannot be combined with others")
|
||||
}
|
||||
|
||||
// Handle prompt=select_account early (not supported)
|
||||
if hasPromptSelectAccount {
|
||||
return "", "", &common.OidcInteractionRequiredError{}
|
||||
}
|
||||
// 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 {
|
||||
@@ -219,14 +217,24 @@ func (s *OidcService) Authorize(ctx context.Context, input dto.AuthorizeOidcClie
|
||||
|
||||
// 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 "", "", err
|
||||
return "", "", fmt.Errorf("failed to commit transaction: %w", err)
|
||||
}
|
||||
|
||||
return code, callbackURL, nil
|
||||
@@ -345,12 +353,12 @@ func (s *OidcService) createTokenFromDeviceCode(ctx context.Context, input dto.O
|
||||
}
|
||||
|
||||
// Explicitly use the input clientID for the audience claim to ensure consistency
|
||||
idToken, err := s.jwtService.GenerateIDToken(userClaims, input.ClientID, deviceAuth.Nonce, deviceAuth.AuthenticationMethod)
|
||||
idToken, idTokenJti, err := s.jwtService.GenerateIDToken(userClaims, input.ClientID, deviceAuth.Nonce, deviceAuth.AuthenticationMethod)
|
||||
if err != nil {
|
||||
return CreatedTokens{}, err
|
||||
}
|
||||
|
||||
refreshToken, err := s.createRefreshToken(ctx, input.ClientID, *deviceAuth.UserID, deviceAuth.Scope, deviceAuth.AuthenticationMethod, tx)
|
||||
refreshToken, err := s.createRefreshToken(ctx, input.ClientID, *deviceAuth.UserID, deviceAuth.Scope, deviceAuth.AuthenticationMethod, idTokenJti, tx)
|
||||
if err != nil {
|
||||
return CreatedTokens{}, err
|
||||
}
|
||||
@@ -446,13 +454,13 @@ func (s *OidcService) createTokenFromAuthorizationCode(ctx context.Context, inpu
|
||||
|
||||
authenticationMethod := authorizationCodeMetaData.AuthenticationMethod
|
||||
|
||||
idToken, err := s.jwtService.GenerateIDToken(userClaims, input.ClientID, authorizationCodeMetaData.Nonce, authenticationMethod)
|
||||
idToken, idTokenJti, err := s.jwtService.GenerateIDToken(userClaims, input.ClientID, authorizationCodeMetaData.Nonce, authenticationMethod)
|
||||
if err != nil {
|
||||
return CreatedTokens{}, err
|
||||
}
|
||||
|
||||
// Generate a refresh token
|
||||
refreshToken, err := s.createRefreshToken(ctx, input.ClientID, authorizationCodeMetaData.UserID, authorizationCodeMetaData.Scope, authenticationMethod, tx)
|
||||
refreshToken, err := s.createRefreshToken(ctx, input.ClientID, authorizationCodeMetaData.UserID, authorizationCodeMetaData.Scope, authenticationMethod, idTokenJti, tx)
|
||||
if err != nil {
|
||||
return CreatedTokens{}, err
|
||||
}
|
||||
@@ -586,13 +594,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, err := s.jwtService.GenerateIDToken(userClaims, input.ClientID, "", authenticationMethods)
|
||||
idToken, idTokenJti, err := s.jwtService.GenerateIDToken(userClaims, input.ClientID, "", authenticationMethods)
|
||||
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, tx)
|
||||
newRefreshToken, err := s.createRefreshToken(ctx, input.ClientID, storedRefreshToken.UserID, storedRefreshToken.Scope, authenticationMethods, idTokenJti, tx)
|
||||
if err != nil {
|
||||
return CreatedTokens{}, err
|
||||
}
|
||||
@@ -737,11 +745,10 @@ func (s *OidcService) introspectRefreshToken(ctx context.Context, clientID strin
|
||||
).
|
||||
First(&storedRefreshToken).
|
||||
Error
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
introspectDto.Active = false
|
||||
return introspectDto, nil
|
||||
}
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
introspectDto.Active = false
|
||||
return introspectDto, nil
|
||||
} else if err != nil {
|
||||
return introspectDto, err
|
||||
}
|
||||
|
||||
@@ -754,6 +761,27 @@ func (s *OidcService) GetClient(ctx context.Context, clientID string) (model.Oid
|
||||
return s.getClientInternal(ctx, clientID, s.db, false)
|
||||
}
|
||||
|
||||
func (s *OidcService) ResolveAllowedCallbackURL(ctx context.Context, clientID, inputCallbackURL string) (string, error) {
|
||||
client, err := s.GetClient(ctx, clientID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if inputCallbackURL == "" || len(client.CallbackURLs) == 0 {
|
||||
return "", &common.OidcMissingCallbackURLError{}
|
||||
}
|
||||
|
||||
matched, err := utils.GetCallbackURLFromList(client.CallbackURLs, inputCallbackURL)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if matched == "" {
|
||||
return "", &common.OidcInvalidCallbackURLError{}
|
||||
}
|
||||
|
||||
return matched, nil
|
||||
}
|
||||
|
||||
func (s *OidcService) getClientInternal(ctx context.Context, clientID string, tx *gorm.DB, forUpdate bool) (model.OidcClient, error) {
|
||||
var client model.OidcClient
|
||||
q := tx.
|
||||
@@ -1034,7 +1062,12 @@ func (s *OidcService) UpdateClientLogo(ctx context.Context, clientID string, fil
|
||||
return err
|
||||
}
|
||||
defer reader.Close()
|
||||
err = s.fileStorage.Save(ctx, imagePath, reader)
|
||||
strippedReader, err := imageutil.StripMetadata(reader, fileType)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = s.fileStorage.Save(ctx, imagePath, strippedReader)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -1167,7 +1200,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) (string, error) {
|
||||
func (s *OidcService) ValidateEndSession(ctx context.Context, input dto.OidcLogoutDto, userID string) (callbackURL string, err error) {
|
||||
// If no ID token hint is provided, return an error
|
||||
if input.IdTokenHint == "" {
|
||||
return "", &common.TokenInvalidError{}
|
||||
@@ -1189,9 +1222,22 @@ 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 = s.db.
|
||||
err = tx.
|
||||
WithContext(ctx).
|
||||
Preload("Client").
|
||||
First(&userAuthorizedOIDCClient, "client_id = ? AND user_id = ?", clientID[0], userID).
|
||||
@@ -1200,16 +1246,27 @@ func (s *OidcService) ValidateEndSession(ctx context.Context, input dto.OidcLogo
|
||||
return "", &common.OidcMissingAuthorizationError{}
|
||||
}
|
||||
|
||||
// If the client has no logout callback URLs, return an error
|
||||
if len(userAuthorizedOIDCClient.Client.LogoutCallbackURLs) == 0 {
|
||||
return "", &common.OidcNoCallbackURLError{}
|
||||
// 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
|
||||
}
|
||||
}
|
||||
|
||||
callbackURL, err := s.getLogoutCallbackURL(&userAuthorizedOIDCClient.Client, input.PostLogoutRedirectUri)
|
||||
err = tx.
|
||||
WithContext(ctx).
|
||||
Where("user_id = ? AND client_id = ? AND id_token_jti = ?", userID, clientID[0], idTokenJti).
|
||||
Delete(&model.OidcRefreshToken{}).
|
||||
Error
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if err := tx.Commit().Error; err != nil {
|
||||
return "", fmt.Errorf("failed to commit transaction: %w", err)
|
||||
}
|
||||
|
||||
return callbackURL, nil
|
||||
}
|
||||
|
||||
@@ -1219,7 +1276,10 @@ func (s *OidcService) createAuthorizationCode(ctx context.Context, clientID stri
|
||||
return "", err
|
||||
}
|
||||
|
||||
codeChallengeMethodSha256 := strings.ToUpper(codeChallengeMethod) == "S256"
|
||||
codeChallengeMethodSha256, err := codeChallengeMethodIsSha256(codeChallengeMethod)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
oidcAuthorizationCode := model.OidcAuthorizationCode{
|
||||
ExpiresAt: datatype.DateTime(time.Now().Add(15 * time.Minute)),
|
||||
@@ -1244,6 +1304,19 @@ 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
|
||||
@@ -1633,7 +1706,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, tx *gorm.DB) (string, error) {
|
||||
func (s *OidcService) createRefreshToken(ctx context.Context, clientID string, userID string, scope string, authenticationMethod string, idTokenJti string, tx *gorm.DB) (string, error) {
|
||||
refreshToken, err := utils.GenerateRandomAlphanumericString(40)
|
||||
if err != nil {
|
||||
return "", err
|
||||
@@ -1646,6 +1719,7 @@ func (s *OidcService) createRefreshToken(ctx context.Context, clientID string, u
|
||||
m := model.OidcRefreshToken{
|
||||
ExpiresAt: datatype.DateTime(time.Now().Add(RefreshTokenDuration)),
|
||||
Token: refreshTokenHash,
|
||||
IdTokenJti: &idTokenJti,
|
||||
ClientID: clientID,
|
||||
UserID: userID,
|
||||
Scope: scope,
|
||||
@@ -1779,14 +1853,18 @@ 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()
|
||||
// We need to register the URL
|
||||
err = s.jwkCache.Register(
|
||||
registerCtx,
|
||||
url,
|
||||
jwk.WithMaxInterval(24*time.Hour),
|
||||
jwk.WithMinInterval(15*time.Minute),
|
||||
|
||||
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...)
|
||||
// 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)
|
||||
@@ -1901,7 +1979,7 @@ 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, "", authenticationMethod)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -1964,23 +2042,51 @@ func (s *OidcService) getUserClaims(ctx context.Context, user *model.User, scope
|
||||
}
|
||||
|
||||
if slices.Contains(scopes, "profile") {
|
||||
// Add custom claims
|
||||
customClaims, err := s.customClaimService.GetCustomClaimsForUserWithUserGroups(ctx, user.ID, tx)
|
||||
// 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)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, customClaim := range customClaims {
|
||||
// The value of the custom claim can be a JSON object or a string
|
||||
var jsonValue any
|
||||
err := json.Unmarshal([]byte(customClaim.Value), &jsonValue)
|
||||
if err == nil {
|
||||
// It's JSON, so we store it as an object
|
||||
claims[customClaim.Key] = jsonValue
|
||||
} else {
|
||||
// Marshaling failed, so we store it as a string
|
||||
claims[customClaim.Key] = customClaim.Value
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
value, err := customFieldValueTokenValue(customFieldValue, customField)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
claims[claimKey] = value
|
||||
}
|
||||
|
||||
// Add profile claims
|
||||
@@ -2105,8 +2211,16 @@ 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, utils.NewLimitReader(resp.Body, maxLogoSize+1))
|
||||
err = s.fileStorage.Save(ctx, imagePath, strippedReader)
|
||||
if errors.Is(err, utils.ErrSizeExceeded) {
|
||||
return errLogoTooLarge
|
||||
} else if err != nil {
|
||||
@@ -2194,13 +2308,3 @@ func parsePromptParameter(prompt string) []string {
|
||||
}
|
||||
return strings.Fields(prompt)
|
||||
}
|
||||
|
||||
// contains checks if a string slice contains a specific value
|
||||
func contains(slice []string, value string) bool {
|
||||
for _, item := range slice {
|
||||
if item == value {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -25,6 +26,7 @@ import (
|
||||
"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"
|
||||
)
|
||||
@@ -582,10 +584,9 @@ func TestOidcServiceRefreshTokenAuthorizationState(t *testing.T) {
|
||||
appConfigService: mockConfig,
|
||||
}
|
||||
|
||||
email := "refresh-token-user@example.com"
|
||||
user := model.User{
|
||||
Username: "refresh-token-user",
|
||||
Email: &email,
|
||||
Email: new("refresh-token-user@example.com"),
|
||||
EmailVerified: true,
|
||||
FirstName: "Refresh",
|
||||
LastName: "User",
|
||||
@@ -623,7 +624,7 @@ func TestOidcServiceRefreshTokenAuthorizationState(t *testing.T) {
|
||||
Scope: scope,
|
||||
}).Error)
|
||||
|
||||
refreshToken, err := service.createRefreshToken(t.Context(), client.ID, user.ID, scope, AuthenticationMethodPhishingResistant, db)
|
||||
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
|
||||
@@ -728,7 +729,7 @@ func TestOidcServiceAuthenticationMethodsPersistence(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("stores authentication methods on refresh tokens", func(t *testing.T) {
|
||||
_, err := service.createRefreshToken(t.Context(), "amr-client", "amr-user", "openid profile", authenticationMethod, db)
|
||||
_, 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
|
||||
@@ -762,6 +763,61 @@ 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)
|
||||
@@ -895,6 +951,8 @@ 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)
|
||||
|
||||
@@ -940,7 +998,7 @@ func TestOidcService_downloadAndSaveLogoFromURL(t *testing.T) {
|
||||
// Create a mock HTTP client with responses
|
||||
mockResponses := map[string]*http.Response{
|
||||
//nolint:bodyclose
|
||||
"https://example.com/logo.png": pngResponse,
|
||||
publicLogoHost + "/logo.png": pngResponse,
|
||||
}
|
||||
httpClient := &http.Client{
|
||||
Transport: &testutils.MockRoundTripper{
|
||||
@@ -956,7 +1014,7 @@ func TestOidcService_downloadAndSaveLogoFromURL(t *testing.T) {
|
||||
}
|
||||
|
||||
// Download and save the logo
|
||||
err := s.downloadAndSaveLogoFromURL(t.Context(), client.ID, "https://example.com/logo.png", true)
|
||||
err := s.downloadAndSaveLogoFromURL(t.Context(), client.ID, publicLogoHost+"/logo.png", true)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify the file was saved
|
||||
@@ -985,7 +1043,7 @@ func TestOidcService_downloadAndSaveLogoFromURL(t *testing.T) {
|
||||
|
||||
mockResponses := map[string]*http.Response{
|
||||
//nolint:bodyclose
|
||||
"https://example.com/dark-logo.webp": webpResponse,
|
||||
publicLogoHost + "/dark-logo.webp": webpResponse,
|
||||
}
|
||||
httpClient := &http.Client{
|
||||
Transport: &testutils.MockRoundTripper{
|
||||
@@ -1000,7 +1058,7 @@ func TestOidcService_downloadAndSaveLogoFromURL(t *testing.T) {
|
||||
}
|
||||
|
||||
// Download and save the dark logo
|
||||
err := s.downloadAndSaveLogoFromURL(t.Context(), client.ID, "https://example.com/dark-logo.webp", false)
|
||||
err := s.downloadAndSaveLogoFromURL(t.Context(), client.ID, publicLogoHost+"/dark-logo.webp", false)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify the dark logo file was saved
|
||||
@@ -1024,7 +1082,7 @@ func TestOidcService_downloadAndSaveLogoFromURL(t *testing.T) {
|
||||
|
||||
mockResponses := map[string]*http.Response{
|
||||
//nolint:bodyclose
|
||||
"https://example.com/icon.svg": testutils.NewMockResponse(http.StatusOK, string(svgContent)),
|
||||
publicLogoHost + "/icon.svg": testutils.NewMockResponse(http.StatusOK, string(svgContent)),
|
||||
}
|
||||
httpClient := &http.Client{
|
||||
Transport: &testutils.MockRoundTripper{
|
||||
@@ -1038,7 +1096,7 @@ func TestOidcService_downloadAndSaveLogoFromURL(t *testing.T) {
|
||||
httpClient: httpClient,
|
||||
}
|
||||
|
||||
err := s.downloadAndSaveLogoFromURL(t.Context(), client.ID, "https://example.com/icon.svg", true)
|
||||
err := s.downloadAndSaveLogoFromURL(t.Context(), client.ID, publicLogoHost+"/icon.svg", true)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify SVG file was saved
|
||||
@@ -1055,7 +1113,7 @@ func TestOidcService_downloadAndSaveLogoFromURL(t *testing.T) {
|
||||
|
||||
mockResponses := map[string]*http.Response{
|
||||
//nolint:bodyclose
|
||||
"https://example.com/logo": jpgResponse,
|
||||
publicLogoHost + "/logo": jpgResponse,
|
||||
}
|
||||
httpClient := &http.Client{
|
||||
Transport: &testutils.MockRoundTripper{
|
||||
@@ -1069,7 +1127,7 @@ func TestOidcService_downloadAndSaveLogoFromURL(t *testing.T) {
|
||||
httpClient: httpClient,
|
||||
}
|
||||
|
||||
err := s.downloadAndSaveLogoFromURL(t.Context(), client.ID, "https://example.com/logo", true)
|
||||
err := s.downloadAndSaveLogoFromURL(t.Context(), client.ID, publicLogoHost+"/logo", true)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify JPG file was saved (jpeg extension is normalized to jpg)
|
||||
@@ -1091,7 +1149,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
|
||||
"https://example.com/not-found.png": testutils.NewMockResponse(http.StatusNotFound, "Not Found"),
|
||||
publicLogoHost + "/not-found.png": testutils.NewMockResponse(http.StatusNotFound, "Not Found"),
|
||||
}
|
||||
httpClient := &http.Client{
|
||||
Transport: &testutils.MockRoundTripper{
|
||||
@@ -1105,7 +1163,7 @@ func TestOidcService_downloadAndSaveLogoFromURL(t *testing.T) {
|
||||
httpClient: httpClient,
|
||||
}
|
||||
|
||||
err := s.downloadAndSaveLogoFromURL(t.Context(), client.ID, "https://example.com/not-found.png", true)
|
||||
err := s.downloadAndSaveLogoFromURL(t.Context(), client.ID, publicLogoHost+"/not-found.png", true)
|
||||
require.Error(t, err)
|
||||
require.ErrorContains(t, err, "failed to fetch logo")
|
||||
})
|
||||
@@ -1121,7 +1179,7 @@ func TestOidcService_downloadAndSaveLogoFromURL(t *testing.T) {
|
||||
|
||||
mockResponses := map[string]*http.Response{
|
||||
//nolint:bodyclose
|
||||
"https://example.com/large.png": largeResponse,
|
||||
publicLogoHost + "/large.png": largeResponse,
|
||||
}
|
||||
httpClient := &http.Client{
|
||||
Transport: &testutils.MockRoundTripper{
|
||||
@@ -1135,7 +1193,7 @@ func TestOidcService_downloadAndSaveLogoFromURL(t *testing.T) {
|
||||
httpClient: httpClient,
|
||||
}
|
||||
|
||||
err := s.downloadAndSaveLogoFromURL(t.Context(), client.ID, "https://example.com/large.png", true)
|
||||
err := s.downloadAndSaveLogoFromURL(t.Context(), client.ID, publicLogoHost+"/large.png", true)
|
||||
require.Error(t, err)
|
||||
require.ErrorIs(t, err, errLogoTooLarge)
|
||||
})
|
||||
@@ -1147,7 +1205,7 @@ func TestOidcService_downloadAndSaveLogoFromURL(t *testing.T) {
|
||||
|
||||
mockResponses := map[string]*http.Response{
|
||||
//nolint:bodyclose
|
||||
"https://example.com/file.txt": textResponse,
|
||||
publicLogoHost + "/file.txt": textResponse,
|
||||
}
|
||||
httpClient := &http.Client{
|
||||
Transport: &testutils.MockRoundTripper{
|
||||
@@ -1161,7 +1219,7 @@ func TestOidcService_downloadAndSaveLogoFromURL(t *testing.T) {
|
||||
httpClient: httpClient,
|
||||
}
|
||||
|
||||
err := s.downloadAndSaveLogoFromURL(t.Context(), client.ID, "https://example.com/file.txt", true)
|
||||
err := s.downloadAndSaveLogoFromURL(t.Context(), client.ID, publicLogoHost+"/file.txt", true)
|
||||
require.Error(t, err)
|
||||
var fileTypeErr *common.FileTypeNotSupportedError
|
||||
require.ErrorAs(t, err, &fileTypeErr)
|
||||
@@ -1174,7 +1232,7 @@ func TestOidcService_downloadAndSaveLogoFromURL(t *testing.T) {
|
||||
|
||||
mockResponses := map[string]*http.Response{
|
||||
//nolint:bodyclose
|
||||
"https://example.com/logo.png": pngResponse,
|
||||
publicLogoHost + "/logo.png": pngResponse,
|
||||
}
|
||||
httpClient := &http.Client{
|
||||
Transport: &testutils.MockRoundTripper{
|
||||
@@ -1188,12 +1246,121 @@ func TestOidcService_downloadAndSaveLogoFromURL(t *testing.T) {
|
||||
httpClient: httpClient,
|
||||
}
|
||||
|
||||
err := s.downloadAndSaveLogoFromURL(t.Context(), "non-existent-client-id", "https://example.com/logo.png", true)
|
||||
err := s.downloadAndSaveLogoFromURL(t.Context(), "non-existent-client-id", publicLogoHost+"/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) {
|
||||
@@ -1236,30 +1403,13 @@ func TestPromptParameterConflicts(t *testing.T) {
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
values := parsePromptParameter(tt.prompt)
|
||||
hasNone := contains(values, "none")
|
||||
hasConsent := contains(values, "consent")
|
||||
hasLogin := contains(values, "login")
|
||||
hasSelectAccount := contains(values, "select_account")
|
||||
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)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestContains(t *testing.T) {
|
||||
t.Run("finds value in slice", func(t *testing.T) {
|
||||
slice := []string{"none", "login", "consent"}
|
||||
assert.True(t, contains(slice, "login"))
|
||||
})
|
||||
|
||||
t.Run("returns false for missing value", func(t *testing.T) {
|
||||
slice := []string{"none", "login"}
|
||||
assert.False(t, contains(slice, "consent"))
|
||||
})
|
||||
|
||||
t.Run("returns false for empty slice", func(t *testing.T) {
|
||||
slice := []string{}
|
||||
assert.False(t, contains(slice, "none"))
|
||||
})
|
||||
}
|
||||
|
||||
@@ -15,19 +15,24 @@ import (
|
||||
)
|
||||
|
||||
type UserGroupService struct {
|
||||
db *gorm.DB
|
||||
scimService *ScimService
|
||||
appConfigService *AppConfigService
|
||||
db *gorm.DB
|
||||
scimService *ScimService
|
||||
appConfigService *AppConfigService
|
||||
customFieldValueService *CustomFieldValueService
|
||||
}
|
||||
|
||||
func NewUserGroupService(db *gorm.DB, appConfigService *AppConfigService, scimService *ScimService) *UserGroupService {
|
||||
return &UserGroupService{db: db, appConfigService: appConfigService, scimService: scimService}
|
||||
func NewUserGroupService(db *gorm.DB, appConfigService *AppConfigService, customFieldValueService *CustomFieldValueService, scimService *ScimService) *UserGroupService {
|
||||
return &UserGroupService{db: db, appConfigService: appConfigService, customFieldValueService: customFieldValueService, scimService: scimService}
|
||||
}
|
||||
|
||||
func (s *UserGroupService) List(ctx context.Context, name string, listRequestOptions utils.ListRequestOptions) (groups []model.UserGroup, response utils.PaginationResponse, err error) {
|
||||
query := s.db.
|
||||
tx := s.db.Begin()
|
||||
defer func() {
|
||||
tx.Rollback()
|
||||
}()
|
||||
|
||||
query := tx.
|
||||
WithContext(ctx).
|
||||
Preload("CustomClaims").
|
||||
Model(&model.UserGroup{})
|
||||
|
||||
if name != "" {
|
||||
@@ -43,6 +48,17 @@ 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
|
||||
}
|
||||
|
||||
@@ -54,11 +70,19 @@ 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
|
||||
}
|
||||
|
||||
@@ -104,7 +128,22 @@ 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) {
|
||||
return s.createInternal(ctx, input, s.db)
|
||||
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
|
||||
}
|
||||
|
||||
func (s *UserGroupService) createInternal(ctx context.Context, input dto.UserGroupCreateDto, tx *gorm.DB) (group model.UserGroup, err error) {
|
||||
@@ -129,6 +168,12 @@ 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()
|
||||
}
|
||||
@@ -181,6 +226,12 @@ 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()
|
||||
}
|
||||
|
||||
@@ -27,37 +27,41 @@ import (
|
||||
)
|
||||
|
||||
type UserService struct {
|
||||
db *gorm.DB
|
||||
jwtService *JwtService
|
||||
auditLogService *AuditLogService
|
||||
emailService *EmailService
|
||||
appConfigService *AppConfigService
|
||||
customClaimService *CustomClaimService
|
||||
appImagesService *AppImagesService
|
||||
scimService *ScimService
|
||||
fileStorage storage.FileStorage
|
||||
db *gorm.DB
|
||||
jwtService *JwtService
|
||||
auditLogService *AuditLogService
|
||||
emailService *EmailService
|
||||
appConfigService *AppConfigService
|
||||
customFieldValueService *CustomFieldValueService
|
||||
appImagesService *AppImagesService
|
||||
scimService *ScimService
|
||||
fileStorage storage.FileStorage
|
||||
}
|
||||
|
||||
func NewUserService(db *gorm.DB, jwtService *JwtService, auditLogService *AuditLogService, emailService *EmailService, appConfigService *AppConfigService, customClaimService *CustomClaimService, appImagesService *AppImagesService, scimService *ScimService, fileStorage storage.FileStorage) *UserService {
|
||||
func NewUserService(db *gorm.DB, jwtService *JwtService, auditLogService *AuditLogService, emailService *EmailService, appConfigService *AppConfigService, customFieldValueService *CustomFieldValueService, appImagesService *AppImagesService, scimService *ScimService, fileStorage storage.FileStorage) *UserService {
|
||||
return &UserService{
|
||||
db: db,
|
||||
jwtService: jwtService,
|
||||
auditLogService: auditLogService,
|
||||
emailService: emailService,
|
||||
appConfigService: appConfigService,
|
||||
customClaimService: customClaimService,
|
||||
appImagesService: appImagesService,
|
||||
scimService: scimService,
|
||||
fileStorage: fileStorage,
|
||||
db: db,
|
||||
jwtService: jwtService,
|
||||
auditLogService: auditLogService,
|
||||
emailService: emailService,
|
||||
appConfigService: appConfigService,
|
||||
customFieldValueService: customFieldValueService,
|
||||
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 := s.db.WithContext(ctx).
|
||||
query := tx.WithContext(ctx).
|
||||
Model(&model.User{}).
|
||||
Preload("UserGroups").
|
||||
Preload("CustomClaims")
|
||||
Preload("UserGroups")
|
||||
|
||||
if searchTerm != "" {
|
||||
searchPattern := "%" + searchTerm + "%"
|
||||
@@ -67,6 +71,16 @@ 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
|
||||
}
|
||||
@@ -80,10 +94,17 @@ 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
|
||||
}
|
||||
|
||||
@@ -301,15 +322,17 @@ func (s *UserService) createUserInternal(ctx context.Context, input dto.UserCrea
|
||||
return model.User{}, err
|
||||
}
|
||||
|
||||
// Apply default groups and claims for new non-LDAP users
|
||||
// Apply default groups and custom fields 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 err := s.applyDefaultCustomClaims(ctx, &user, tx); err != nil {
|
||||
}
|
||||
if !isLdapSync {
|
||||
user.CustomFieldValues, err = s.customFieldValueService.updateCustomFieldValuesInternal(ctx, UserID, user.ID, input.CustomFieldValues, tx)
|
||||
if err != nil {
|
||||
return model.User{}, err
|
||||
}
|
||||
}
|
||||
@@ -353,27 +376,6 @@ 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() {
|
||||
@@ -463,6 +465,15 @@ 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()
|
||||
}
|
||||
|
||||
@@ -72,14 +72,20 @@ 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(),
|
||||
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,
|
||||
}
|
||||
|
||||
user, err := s.userService.createUserInternal(ctx, userToCreate, false, tx)
|
||||
@@ -132,13 +138,19 @@ func (s *UserSignUpService) SignUpInitialAdmin(ctx context.Context, signUpData d
|
||||
return model.User{}, "", &common.SetupNotAvailableError{}
|
||||
}
|
||||
|
||||
customFieldValues, err := s.filterSignupCustomFieldValues(signUpData.CustomFieldValues)
|
||||
if err != nil {
|
||||
return model.User{}, "", err
|
||||
}
|
||||
|
||||
userToCreate := dto.UserCreateDto{
|
||||
FirstName: signUpData.FirstName,
|
||||
LastName: signUpData.LastName,
|
||||
DisplayName: strings.TrimSpace(signUpData.FirstName + " " + signUpData.LastName),
|
||||
Username: signUpData.Username,
|
||||
Email: signUpData.Email,
|
||||
IsAdmin: true,
|
||||
FirstName: signUpData.FirstName,
|
||||
LastName: signUpData.LastName,
|
||||
DisplayName: strings.TrimSpace(signUpData.FirstName + " " + signUpData.LastName),
|
||||
Username: signUpData.Username,
|
||||
Email: signUpData.Email,
|
||||
IsAdmin: true,
|
||||
CustomFieldValues: customFieldValues,
|
||||
}
|
||||
|
||||
user, err := s.userService.createUserInternal(ctx, userToCreate, false, tx)
|
||||
@@ -159,6 +171,34 @@ 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)
|
||||
}
|
||||
|
||||
@@ -69,8 +69,7 @@ func (s *VersionService) GetLatestVersion(ctx context.Context) (string, error) {
|
||||
return strings.TrimPrefix(payload.TagName, "v"), nil
|
||||
})
|
||||
|
||||
var staleErr *utils.ErrStale
|
||||
if errors.As(err, &staleErr) {
|
||||
if staleErr, ok := errors.AsType[*utils.ErrStale](err); ok {
|
||||
slog.Warn("Failed to fetch latest version, returning stale cache", "error", staleErr.Err)
|
||||
return version, nil
|
||||
}
|
||||
|
||||
@@ -186,8 +186,7 @@ func (s *s3Storage) buildObjectKey(p string) string {
|
||||
}
|
||||
|
||||
func isS3NotFound(err error) bool {
|
||||
var apiErr smithy.APIError
|
||||
if errors.As(err, &apiErr) {
|
||||
if apiErr, ok := errors.AsType[smithy.APIError](err); ok {
|
||||
if apiErr.ErrorCode() == "NotFound" || apiErr.ErrorCode() == "NoSuchKey" {
|
||||
return true
|
||||
}
|
||||
|
||||
106
backend/internal/utils/image/metadata.go
Normal file
106
backend/internal/utils/image/metadata.go
Normal file
@@ -0,0 +1,106 @@
|
||||
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()
|
||||
}
|
||||
81
backend/internal/utils/image/metadata_test.go
Normal file
81
backend/internal/utils/image/metadata_test.go
Normal file
@@ -0,0 +1,81 @@
|
||||
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()
|
||||
}
|
||||
@@ -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/PlayfairDisplay-Bold.ttf")
|
||||
fontBytes, err := resources.FS.ReadFile("fonts/Gloock.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() - 10
|
||||
y := (profilePictureSize-face.Metrics().Height.Ceil())/2 + face.Metrics().Ascent.Ceil()
|
||||
drawer.Dot = fixed.P(x, y)
|
||||
|
||||
// Draw the initials
|
||||
|
||||
@@ -86,12 +86,10 @@ 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: &encB64,
|
||||
Value: new(base64.StdEncoding.EncodeToString(enc)),
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||
|
||||
@@ -106,10 +106,9 @@ 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: &invalidBase64,
|
||||
Value: new("not-valid-base64"),
|
||||
}).Error
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -132,10 +131,9 @@ 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: &invalidData,
|
||||
Value: new(base64.StdEncoding.EncodeToString([]byte("not-valid-encrypted-data"))),
|
||||
}).Error
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -195,13 +193,10 @@ 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: &encodedData,
|
||||
Value: new(base64.StdEncoding.EncodeToString(encryptedData)),
|
||||
}).Error
|
||||
require.NoError(t, err)
|
||||
|
||||
|
||||
@@ -3,9 +3,11 @@
|
||||
package testing
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
_ "github.com/golang-migrate/migrate/v4/source/file"
|
||||
)
|
||||
@@ -14,25 +16,62 @@ 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 resp, nil
|
||||
return m.cloneResponse(url, resp)
|
||||
}
|
||||
}
|
||||
|
||||
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),
|
||||
StatusCode: statusCode,
|
||||
Body: io.NopCloser(strings.NewReader(body)),
|
||||
Header: make(http.Header),
|
||||
ContentLength: int64(len(body)),
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
BIN
backend/resources/fonts/Gloock.ttf
Normal file
BIN
backend/resources/fonts/Gloock.ttf
Normal file
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,4 @@
|
||||
DROP INDEX IF EXISTS idx_oidc_refresh_tokens_id_token_jti;
|
||||
|
||||
ALTER TABLE oidc_refresh_tokens
|
||||
DROP COLUMN id_token_jti;
|
||||
@@ -0,0 +1,5 @@
|
||||
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);
|
||||
@@ -0,0 +1,31 @@
|
||||
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';
|
||||
@@ -0,0 +1,61 @@
|
||||
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');
|
||||
@@ -1,3 +1,10 @@
|
||||
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;
|
||||
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
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;
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
PRAGMA foreign_keys= OFF;
|
||||
BEGIN;
|
||||
|
||||
DROP INDEX IF EXISTS idx_oidc_refresh_tokens_id_token_jti;
|
||||
|
||||
ALTER TABLE oidc_refresh_tokens
|
||||
DROP COLUMN id_token_jti;
|
||||
|
||||
COMMIT;
|
||||
PRAGMA foreign_keys= ON;
|
||||
@@ -0,0 +1,11 @@
|
||||
PRAGMA foreign_keys= OFF;
|
||||
BEGIN;
|
||||
|
||||
ALTER TABLE oidc_refresh_tokens
|
||||
ADD COLUMN id_token_jti TEXT;
|
||||
|
||||
CREATE INDEX idx_oidc_refresh_tokens_id_token_jti
|
||||
ON oidc_refresh_tokens(user_id, client_id, id_token_jti);
|
||||
|
||||
COMMIT;
|
||||
PRAGMA foreign_keys= ON;
|
||||
@@ -0,0 +1,40 @@
|
||||
CREATE TEMP TABLE custom_field_migration_map AS
|
||||
SELECT
|
||||
json_extract(json_each.value, '$.id') AS custom_field_id,
|
||||
json_extract(json_each.value, '$.key') AS key
|
||||
FROM app_config_variables, json_each(app_config_variables.value)
|
||||
WHERE app_config_variables.key = 'customFields';
|
||||
|
||||
ALTER TABLE custom_field_values RENAME TO custom_field_values_old;
|
||||
|
||||
CREATE TABLE custom_claims
|
||||
(
|
||||
id TEXT NOT NULL PRIMARY KEY,
|
||||
created_at DATETIME,
|
||||
key TEXT NOT NULL,
|
||||
value TEXT NOT NULL,
|
||||
|
||||
user_id TEXT,
|
||||
user_group_id TEXT,
|
||||
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (user_group_id) REFERENCES user_groups (id) ON DELETE CASCADE,
|
||||
|
||||
CONSTRAINT custom_claims_unique UNIQUE (key, user_id, user_group_id),
|
||||
CHECK (user_id IS NOT NULL OR user_group_id IS NOT NULL)
|
||||
);
|
||||
|
||||
INSERT INTO custom_claims (id, created_at, key, value, user_id, user_group_id)
|
||||
SELECT
|
||||
old.id,
|
||||
old.created_at,
|
||||
COALESCE(map.key, old.custom_field_id),
|
||||
old.value,
|
||||
old.user_id,
|
||||
old.user_group_id
|
||||
FROM custom_field_values_old old
|
||||
LEFT JOIN custom_field_migration_map map ON map.custom_field_id = old.custom_field_id;
|
||||
|
||||
DROP TABLE custom_field_values_old;
|
||||
DROP TABLE custom_field_migration_map;
|
||||
|
||||
DELETE FROM app_config_variables WHERE key = 'customFields';
|
||||
@@ -0,0 +1,73 @@
|
||||
ALTER TABLE custom_claims RENAME TO custom_field_values_old;
|
||||
|
||||
CREATE TEMP TABLE custom_field_migration_map AS
|
||||
SELECT
|
||||
key,
|
||||
lower(hex(randomblob(4))) || '-' ||
|
||||
lower(hex(randomblob(2))) || '-' ||
|
||||
'4' || substr(lower(hex(randomblob(2))), 2) || '-' ||
|
||||
substr('89ab', abs(random()) % 4 + 1, 1) || substr(lower(hex(randomblob(2))), 2) || '-' ||
|
||||
lower(hex(randomblob(6))) AS custom_field_id,
|
||||
MAX(user_id IS NOT NULL) AS has_user_values,
|
||||
MAX(user_group_id IS NOT NULL) AS has_group_values
|
||||
FROM custom_field_values_old
|
||||
GROUP BY key;
|
||||
|
||||
INSERT INTO app_config_variables (key, value)
|
||||
VALUES (
|
||||
'customFields',
|
||||
COALESCE(
|
||||
(
|
||||
SELECT json_group_array(
|
||||
json_object(
|
||||
'id', custom_field_id,
|
||||
'key', key,
|
||||
'displayName', key,
|
||||
'type', 'string',
|
||||
'target',
|
||||
CASE
|
||||
WHEN has_user_values = 1 AND has_group_values = 1 THEN 'both'
|
||||
WHEN has_user_values = 1 THEN 'user'
|
||||
ELSE 'group'
|
||||
END,
|
||||
'required', json('false'),
|
||||
'userEditable', json('false'),
|
||||
'defaultValue', '',
|
||||
'validationRegex', '',
|
||||
'validationErrorMessage', ''
|
||||
)
|
||||
)
|
||||
FROM custom_field_migration_map
|
||||
ORDER BY key
|
||||
),
|
||||
'[]'
|
||||
)
|
||||
)
|
||||
ON CONFLICT(key) DO UPDATE SET value = excluded.value
|
||||
WHERE app_config_variables.value = '' OR app_config_variables.value = '[]';
|
||||
|
||||
CREATE TABLE custom_field_values
|
||||
(
|
||||
id TEXT NOT NULL PRIMARY KEY,
|
||||
created_at DATETIME,
|
||||
custom_field_id TEXT NOT NULL,
|
||||
value TEXT NOT NULL,
|
||||
|
||||
user_id TEXT,
|
||||
user_group_id TEXT,
|
||||
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (user_group_id) REFERENCES user_groups (id) ON DELETE CASCADE,
|
||||
|
||||
CONSTRAINT custom_field_values_unique UNIQUE (custom_field_id, user_id, user_group_id),
|
||||
CHECK (user_id IS NOT NULL OR user_group_id IS NOT NULL)
|
||||
);
|
||||
|
||||
INSERT INTO custom_field_values (id, created_at, custom_field_id, value, user_id, user_group_id)
|
||||
SELECT old.id, old.created_at, map.custom_field_id, old.value, old.user_id, old.user_group_id
|
||||
FROM custom_field_values_old old
|
||||
JOIN custom_field_migration_map map ON map.key = old.key;
|
||||
|
||||
DROP TABLE custom_field_values_old;
|
||||
DROP TABLE custom_field_migration_map;
|
||||
|
||||
DELETE FROM app_config_variables WHERE key IN ('userCustomFields', 'userGroupCustomFields');
|
||||
1
depot.json
Normal file
1
depot.json
Normal file
@@ -0,0 +1 @@
|
||||
{ "id": "c36t29j6bz" }
|
||||
@@ -1,3 +1,5 @@
|
||||
# syntax=docker/dockerfile:1.7
|
||||
|
||||
# This file uses multi-stage builds to build the application from source, including the front-end
|
||||
|
||||
# Tags passed to "go build"
|
||||
@@ -9,27 +11,33 @@ RUN corepack enable
|
||||
|
||||
WORKDIR /build
|
||||
|
||||
COPY pnpm-workspace.yaml pnpm-lock.yaml ./
|
||||
COPY package.json pnpm-workspace.yaml pnpm-lock.yaml ./
|
||||
COPY frontend/package.json ./frontend/
|
||||
RUN pnpm --filter pocket-id-frontend install --frozen-lockfile
|
||||
RUN --mount=type=cache,target=/root/.local/share/pnpm/store \
|
||||
pnpm --filter pocket-id-frontend install --frozen-lockfile
|
||||
|
||||
COPY ./frontend ./frontend/
|
||||
|
||||
RUN BUILD_OUTPUT_PATH=dist pnpm --filter pocket-id-frontend run build
|
||||
RUN --mount=type=cache,target=/build/frontend/node_modules/.vite \
|
||||
--mount=type=cache,target=/build/frontend/.svelte-kit \
|
||||
BUILD_OUTPUT_PATH=dist pnpm --filter pocket-id-frontend run build
|
||||
|
||||
# Stage 2: Build Backend
|
||||
FROM golang:1.26-alpine AS backend-builder
|
||||
ARG BUILD_TAGS
|
||||
WORKDIR /build
|
||||
COPY ./backend/go.mod ./backend/go.sum ./
|
||||
RUN go mod download
|
||||
RUN --mount=type=cache,target=/go/pkg/mod \
|
||||
go mod download
|
||||
|
||||
COPY ./backend ./
|
||||
COPY --from=frontend-builder /build/frontend/dist ./frontend/dist
|
||||
COPY .version .version
|
||||
|
||||
WORKDIR /build/cmd
|
||||
RUN VERSION=$(cat /build/.version) \
|
||||
RUN --mount=type=cache,target=/go/pkg/mod \
|
||||
--mount=type=cache,target=/root/.cache/go-build \
|
||||
VERSION=$(cat /build/.version) && \
|
||||
CGO_ENABLED=0 \
|
||||
GOOS=linux \
|
||||
go build \
|
||||
|
||||
@@ -9,17 +9,17 @@
|
||||
"export": "email export"
|
||||
},
|
||||
"dependencies": {
|
||||
"@react-email/components": "1.0.1",
|
||||
"react": "^19.2.5",
|
||||
"react-dom": "^19.2.5",
|
||||
"tailwind-merge": "^3.5.0"
|
||||
"@react-email/components": "1.0.12",
|
||||
"react": "^19.2.6",
|
||||
"react-dom": "^19.2.6",
|
||||
"tailwind-merge": "^3.6.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@react-email/preview-server": "5.0.7",
|
||||
"@types/node": "^24.12.2",
|
||||
"@react-email/preview-server": "5.2.10",
|
||||
"@types/node": "^25.9.0",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"react-email": "5.0.7",
|
||||
"tsx": "^4.21.0"
|
||||
"react-email": "6.1.4",
|
||||
"tsx": "^4.22.2"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,6 +53,8 @@
|
||||
"webauthn_not_supported_by_browser": "Les claus d'accés no són compatibles amb aquest navegador. Utilitza un mètode alternatiu per iniciar la sessió.",
|
||||
"critical_error_occurred_contact_administrator": "S'ha produït un error crític. Contacta amb l'administrador.",
|
||||
"sign_in_to": "Inicia la sessió a {name}",
|
||||
"account_selection_signin_confirmation": "Vols utilitzar el compte següent per continuar a <b>{name}</b>?",
|
||||
"use_a_different_account": "Utilitza un compte diferent",
|
||||
"client_not_found": "Client no trobat",
|
||||
"client_wants_to_access_the_following_information": "<b>{client}</b> vol accedir a la informació següent:",
|
||||
"do_you_want_to_sign_in_to_client_with_your_app_name_account": "Vols iniciar la sessió a <b>{client}</b> amb el teu compte de {appName}?",
|
||||
@@ -433,7 +435,7 @@
|
||||
"active": "Actiu",
|
||||
"usage": "Ús",
|
||||
"created": "Creat",
|
||||
"token": "Token",
|
||||
"token": "Token\n",
|
||||
"loading": "Carregant",
|
||||
"delete_signup_token": "Elimina token d'alta",
|
||||
"are_you_sure_you_want_to_delete_this_signup_token": "Segur que vols eliminar aquest token d'alta? Aquesta acció no es pot desfer.",
|
||||
|
||||
@@ -53,6 +53,8 @@
|
||||
"webauthn_not_supported_by_browser": "Tento prohlížeč nepodporuje přístupové klíče. Použijte prosím alternativní způsob přihlášení.",
|
||||
"critical_error_occurred_contact_administrator": "Došlo k kritické chybě. Obraťte se na správce.",
|
||||
"sign_in_to": "Přihlásit se k {name}",
|
||||
"account_selection_signin_confirmation": "Chcete pokračovat pomocí následujícího účtu <b>{name}</b>?",
|
||||
"use_a_different_account": "Použijte jiný účet",
|
||||
"client_not_found": "Klient nebyl nalezen",
|
||||
"client_wants_to_access_the_following_information": "<b>{client}</b> chce získat přístup k následujícím informacím:",
|
||||
"do_you_want_to_sign_in_to_client_with_your_app_name_account": "Chcete se přihlásit do <b>{client}</b> s vaším {appName} účtem?",
|
||||
|
||||
@@ -53,6 +53,8 @@
|
||||
"webauthn_not_supported_by_browser": "Passkeys understøttes ikke af denne browser. Brug en alternativ login-metode.",
|
||||
"critical_error_occurred_contact_administrator": "En kritisk fejl opstod. Kontakt venligst din administrator.",
|
||||
"sign_in_to": "Log ind på {name}",
|
||||
"account_selection_signin_confirmation": "Vil du bruge følgende konto til at fortsætte <b>{name}</b>?",
|
||||
"use_a_different_account": "Brug en anden konto",
|
||||
"client_not_found": "Klient ikke fundet",
|
||||
"client_wants_to_access_the_following_information": "{client} ønsker at få adgang til følgende oplysninger:",
|
||||
"do_you_want_to_sign_in_to_client_with_your_app_name_account": "Vil du logge ind på {client} med din {appName}-konto?",
|
||||
|
||||
@@ -53,6 +53,8 @@
|
||||
"webauthn_not_supported_by_browser": "Passkeys werden von diesem Browser nicht unterstützt. Bitte probier eine andere Anmeldemethode aus.",
|
||||
"critical_error_occurred_contact_administrator": "Ein kritischer Fehler ist aufgetreten. Bitte kontaktiere deinen Administrator.",
|
||||
"sign_in_to": "Bei {name} anmelden",
|
||||
"account_selection_signin_confirmation": "Möchtest du mit dem folgenden Konto fortfahren <b>{name}</b>?",
|
||||
"use_a_different_account": "Verwende ein anderes Konto",
|
||||
"client_not_found": "Client nicht gefunden",
|
||||
"client_wants_to_access_the_following_information": "<b>{client}</b> möchte auf die folgenden Informationen zugreifen:",
|
||||
"do_you_want_to_sign_in_to_client_with_your_app_name_account": "Möchtest du dich bei <b>{client}</b> mit deinem {appName} Konto anmelden?",
|
||||
@@ -87,7 +89,7 @@
|
||||
"enter_your_email_address_to_receive_an_email_with_a_login_code": "Gib deine E-Mail-Adresse ein, um eine E-Mail mit einem Login-Code zu erhalten.",
|
||||
"your_email": "Deine E-Mail",
|
||||
"submit": "Bestätigen",
|
||||
"enter_the_code_you_received_to_sign_in": "Gebe den Code ein, den du erhalten hast, um dich anzumelden.",
|
||||
"enter_the_code_you_received_to_sign_in": "Gib den Code ein, den du erhalten hast, um dich anzumelden.",
|
||||
"code": "Code",
|
||||
"invalid_redirect_url": "Ungültige Weiterleitungs-URL",
|
||||
"audit_log": "Aktivitäts-Log",
|
||||
@@ -106,7 +108,7 @@
|
||||
"ip_address": "IP-Adresse",
|
||||
"device": "Gerät",
|
||||
"client": "Client",
|
||||
"actor": "Schauspieler",
|
||||
"actor": "Akteur",
|
||||
"unknown": "unbekannt",
|
||||
"account_details_updated_successfully": "Kontodetails erfolgreich aktualisiert",
|
||||
"profile_picture_updated_successfully": "Profilbild erfolgreich aktualisiert. Die Aktualisierung kann einige Minuten dauern.",
|
||||
@@ -343,7 +345,7 @@
|
||||
"device_code_authorization": "Gerätecode-Autorisierung",
|
||||
"new_device_code_authorization": "Neue Gerätecode-Autorisierung",
|
||||
"passkey_added": "Passwort hinzugefügt",
|
||||
"passkey_removed": "Passwort entfernt",
|
||||
"passkey_removed": "Passkey entfernt",
|
||||
"disable_animations": "Animationen deaktivieren",
|
||||
"turn_off_ui_animations": "Deaktiviert alle Animationen in der Benutzeroberfläche.",
|
||||
"user_disabled": "Account deaktiviert",
|
||||
@@ -395,7 +397,7 @@
|
||||
"select_an_accent_color_to_customize_the_appearance_of_pocket_id": "Wähl eine Akzentfarbe aus, um das Aussehen von Pocket ID anzupassen.",
|
||||
"accent_color": "Akzentfarbe",
|
||||
"custom_accent_color": "Benutzerdefinierte Akzentfarbe",
|
||||
"custom_accent_color_description": "Geben Sie eine benutzerdefinierte Farbe mit gültigen CSS-Farbformaten ein (z.B. hex, rgb, hsl).",
|
||||
"custom_accent_color_description": "Gib eine benutzerdefinierte Farbe mit gültigen CSS-Farbformaten ein (z.B. hex, rgb, hsl).",
|
||||
"color_value": "Farbwert",
|
||||
"apply": "Übernehmen",
|
||||
"signup_token": "Anmeldungstoken",
|
||||
|
||||
@@ -5,9 +5,6 @@
|
||||
"confirm": "Confirm",
|
||||
"docs": "Docs",
|
||||
"key": "Key",
|
||||
"value": "Value",
|
||||
"remove_custom_claim": "Remove custom claim",
|
||||
"add_custom_claim": "Add custom claim",
|
||||
"add_another": "Add another",
|
||||
"select_a_date": "Select a date",
|
||||
"select_file": "Select File",
|
||||
@@ -35,7 +32,6 @@
|
||||
"one_week": "1 week",
|
||||
"one_month": "1 month",
|
||||
"expiration": "Expiration",
|
||||
"generate_code": "Generate Code",
|
||||
"name": "Name",
|
||||
"browser_unsupported": "Browser unsupported",
|
||||
"this_browser_does_not_support_passkeys": "This browser doesn't support passkeys. Please use an alternative sign in method.",
|
||||
@@ -53,6 +49,8 @@
|
||||
"webauthn_not_supported_by_browser": "Passkeys are not supported by this browser. Please use an alternative sign in method.",
|
||||
"critical_error_occurred_contact_administrator": "A critical error occurred. Please contact your administrator.",
|
||||
"sign_in_to": "Sign in to {name}",
|
||||
"account_selection_signin_confirmation": "Do you want to use the following account to continue to <b>{name}</b>?",
|
||||
"use_a_different_account": "Use a different account",
|
||||
"client_not_found": "Client not found",
|
||||
"client_wants_to_access_the_following_information": "<b>{client}</b> wants to access the following information:",
|
||||
"do_you_want_to_sign_in_to_client_with_your_app_name_account": "Do you want to sign in to <b>{client}</b> with your {appName} account?",
|
||||
@@ -73,7 +71,6 @@
|
||||
"authenticate_with_passkey_to_access_account": "Authenticate yourself with your passkey to access your account.",
|
||||
"authenticate": "Authenticate",
|
||||
"please_try_again": "Please try again.",
|
||||
"continue": "Continue",
|
||||
"alternative_sign_in": "Alternative Sign In",
|
||||
"if_you_do_not_have_access_to_your_passkey_you_can_sign_in_using_one_of_the_following_methods": "If you don't have access to your passkey, you can sign in using one of the following methods.",
|
||||
"use_your_passkey_instead": "Use your passkey instead?",
|
||||
@@ -167,7 +164,6 @@
|
||||
"ldap": "LDAP",
|
||||
"configure_ldap_settings_to_sync_users_and_groups_from_an_ldap_server": "Configure LDAP settings to sync users and groups from an LDAP server.",
|
||||
"images": "Images",
|
||||
"update": "Update",
|
||||
"email_configuration_updated_successfully": "Email configuration updated successfully",
|
||||
"save_changes_question": "Save changes?",
|
||||
"you_have_to_save_the_changes_before_sending_a_test_email_do_you_want_to_save_now": "You have to save the changes before sending a test email. Do you want to save now?",
|
||||
@@ -247,12 +243,9 @@
|
||||
"edit": "Edit",
|
||||
"user_groups_updated_successfully": "User groups updated successfully",
|
||||
"user_updated_successfully": "User updated successfully",
|
||||
"custom_claims_updated_successfully": "Custom claims updated successfully",
|
||||
"back": "Back",
|
||||
"user_details_firstname_lastname": "User Details {firstName} {lastName}",
|
||||
"manage_which_groups_this_user_belongs_to": "Manage which groups this user belongs to.",
|
||||
"custom_claims": "Custom Claims",
|
||||
"custom_claims_are_key_value_pairs_that_can_be_used_to_store_additional_information_about_a_user": "Custom claims are key-value pairs that can be used to store additional information about a user. These claims will be included in the ID token if the scope 'profile' is requested.",
|
||||
"user_group_created_successfully": "User group created successfully",
|
||||
"create_user_group": "Create User Group",
|
||||
"create_a_new_group_that_can_be_assigned_to_users": "Create a new group that can be assigned to users.",
|
||||
@@ -269,7 +262,6 @@
|
||||
"users_updated_successfully": "Users updated successfully",
|
||||
"user_group_details_name": "User Group Details {name}",
|
||||
"assign_users_to_this_group": "Assign users to this group.",
|
||||
"custom_claims_are_key_value_pairs_that_can_be_used_to_store_additional_information_about_a_user_prioritized": "Custom claims are key-value pairs that can be used to store additional information about a user. These claims will be included in the ID token if the scope 'profile' is requested. Custom claims defined on the user will be prioritized if there are conflicts.",
|
||||
"oidc_client_created_successfully": "OIDC client created successfully",
|
||||
"create_oidc_client": "Create OIDC Client",
|
||||
"add_a_new_oidc_client_to_appname": "Add a new OIDC client to {appName}.",
|
||||
@@ -287,9 +279,7 @@
|
||||
"requires_reauthentication": "Requires Re-Authentication",
|
||||
"requires_users_to_authenticate_again_on_each_authorization": "Requires users to authenticate again on each authorization, even if already signed in",
|
||||
"name_logo": "{name} logo",
|
||||
"change_logo": "Change Logo",
|
||||
"upload_logo": "Upload Logo",
|
||||
"remove_logo": "Remove Logo",
|
||||
"are_you_sure_you_want_to_delete_this_oidc_client": "Are you sure you want to delete this OIDC client?",
|
||||
"oidc_client_deleted_successfully": "OIDC client deleted successfully",
|
||||
"authorization_url": "Authorization URL",
|
||||
@@ -371,7 +361,6 @@
|
||||
"add_federated_client_credential": "Add Federated Client Credential",
|
||||
"add_another_federated_client_credential": "Add another federated client credential",
|
||||
"oidc_allowed_group_count": "Allowed Group Count",
|
||||
"unrestricted": "Unrestricted",
|
||||
"show_advanced_options": "Show Advanced Options",
|
||||
"hide_advanced_options": "Hide Advanced Options",
|
||||
"oidc_data_preview": "OIDC Data Preview",
|
||||
@@ -383,9 +372,7 @@
|
||||
"access_token_payload": "Access Token Payload",
|
||||
"userinfo_endpoint_response": "Userinfo Endpoint Response",
|
||||
"copy": "Copy",
|
||||
"no_preview_data_available": "No preview data available",
|
||||
"copy_all": "Copy All",
|
||||
"preview": "Preview",
|
||||
"preview_for_user": "Preview for {name}",
|
||||
"preview_the_oidc_data_that_would_be_sent_for_this_user": "Preview the OIDC data that would be sent for this user",
|
||||
"show": "Show",
|
||||
@@ -407,11 +394,9 @@
|
||||
"user_creation": "User Creation",
|
||||
"configure_user_creation": "Manage user creation settings, including signup methods and default permissions for new users.",
|
||||
"user_creation_groups_description": "Assign these groups automatically to new users upon signup.",
|
||||
"user_creation_claims_description": "Assign these custom claims automatically to new users upon signup.",
|
||||
"user_creation_updated_successfully": "User creation settings updated successfully.",
|
||||
"signup_disabled_description": "User signups are completely disabled. Only administrators can create new user accounts.",
|
||||
"signup_requires_valid_token": "A valid signup token is required to create an account",
|
||||
"validating_signup_token": "Validating signup token",
|
||||
"go_to_login": "Go to login",
|
||||
"signup_to_appname": "Sign Up to {appName}",
|
||||
"create_your_account_to_get_started": "Create your account to get started.",
|
||||
@@ -434,7 +419,6 @@
|
||||
"usage": "Usage",
|
||||
"created": "Created",
|
||||
"token": "Token",
|
||||
"loading": "Loading",
|
||||
"delete_signup_token": "Delete Signup Token",
|
||||
"are_you_sure_you_want_to_delete_this_signup_token": "Are you sure you want to delete this signup token? This action cannot be undone.",
|
||||
"signup_with_token": "Signup with token",
|
||||
@@ -524,5 +508,34 @@
|
||||
"email_verification_sent": "Verification email sent successfully.",
|
||||
"emails_verified_by_default": "Emails verified by default",
|
||||
"emails_verified_by_default_description": "When enabled, users' email addresses will be marked as verified by default upon signup or when their email address is changed.",
|
||||
"user_has_no_passkeys_yet": "This user has no passkeys yet."
|
||||
"user_has_no_passkeys_yet": "This user has no passkeys yet.",
|
||||
"custom_fields": "Custom Fields",
|
||||
"configure_custom_fields": "Configure custom fields that can be assigned to users and user groups.",
|
||||
"custom_fields_updated_successfully": "Custom fields updated successfully",
|
||||
"add_custom_field": "Add custom field",
|
||||
"delete_custom_field_name": "Delete {name}?",
|
||||
"delete_custom_field_description": "Are you sure you want to delete the custom field {name}? This will remove the field from all users and groups.",
|
||||
"custom_field_already_exists": "Custom field already exists",
|
||||
"available_for": "Available for",
|
||||
"users_and_groups": "Users and groups",
|
||||
"field_is_required": "Field is required",
|
||||
"must_be_a_number": "Must be a number",
|
||||
"value_must_be_true_or_false": "Value must be true or false",
|
||||
"default_value": "Default value",
|
||||
"no_default": "No default",
|
||||
"invalid_regex": "Invalid regex",
|
||||
"validation_regex": "Validation regex",
|
||||
"validation_regex_description": "A regular expression that the value of this field must match.",
|
||||
"must_be_a_valid_email_address": "Must be a valid email address",
|
||||
"validation_error_message": "Validation error message",
|
||||
"validation_error_message_description": "The error message that will be shown when the value does not match the validation regex.",
|
||||
"value_does_not_match_required_format": "Value does not match the required format",
|
||||
"user_editable": "User editable",
|
||||
"user_editable_description": "Allow users to update this field on their own account.",
|
||||
"text": "Text",
|
||||
"number": "Number",
|
||||
"boolean": "Boolean",
|
||||
"type": "Type",
|
||||
"required": "Required",
|
||||
"required_custom_field_description": "Whether this field should be marked as required in the form"
|
||||
}
|
||||
|
||||
@@ -53,6 +53,8 @@
|
||||
"webauthn_not_supported_by_browser": "Este navegador no admite claves de acceso. Utiliza otro método para iniciar sesión.",
|
||||
"critical_error_occurred_contact_administrator": "Ha ocurrido un error crítico. Por favor, contacte a su administrador.",
|
||||
"sign_in_to": "Iniciar sesión en {name}",
|
||||
"account_selection_signin_confirmation": "¿Quieres usar la siguiente cuenta para seguir <b>{name}</b>?",
|
||||
"use_a_different_account": "Usa otra cuenta",
|
||||
"client_not_found": "Cliente no encontrado",
|
||||
"client_wants_to_access_the_following_information": "<b>{client}</b> quiere acceder a la siguiente información:",
|
||||
"do_you_want_to_sign_in_to_client_with_your_app_name_account": "¿Quieres iniciar sesión en <b>{client}</b> con tu cuenta {appName}?",
|
||||
|
||||
@@ -53,6 +53,8 @@
|
||||
"webauthn_not_supported_by_browser": "Passkeys are not supported by this browser. Please use an alternative sign in method.",
|
||||
"critical_error_occurred_contact_administrator": "A critical error occurred. Please contact your administrator.",
|
||||
"sign_in_to": "Sign in to {name}",
|
||||
"account_selection_signin_confirmation": "Kas soovite jätkata järgmise kontoga <b>{name}</b>?",
|
||||
"use_a_different_account": "Kasuta teist kontot",
|
||||
"client_not_found": "Client not found",
|
||||
"client_wants_to_access_the_following_information": "<b>{client}</b> wants to access the following information:",
|
||||
"do_you_want_to_sign_in_to_client_with_your_app_name_account": "Do you want to sign in to <b>{client}</b> with your {appName} account?",
|
||||
|
||||
@@ -53,6 +53,8 @@
|
||||
"webauthn_not_supported_by_browser": "Tämä selain ei tue salasanan sijaan käytettäviä tunnuksia. Käytä vaihtoehtoista kirjautumistapaa.",
|
||||
"critical_error_occurred_contact_administrator": "Kriittinen virhe tapahtui. Ota yhteyttä järjestelmänvalvojaan.",
|
||||
"sign_in_to": "Kirjaudu palveluun {name}",
|
||||
"account_selection_signin_confirmation": "Haluatko jatkaa seuraavalla tilillä <b>{name}</b>?",
|
||||
"use_a_different_account": "Käytä toista tiliä",
|
||||
"client_not_found": "Asiakasta ei löydy",
|
||||
"client_wants_to_access_the_following_information": "<b>{client}</b> haluaa käyttää seuraavia tietoja:",
|
||||
"do_you_want_to_sign_in_to_client_with_your_app_name_account": "Haluatko kirjautua sisään palveluun <b>{client}</b> {appName} -tililläsi?",
|
||||
|
||||
@@ -53,6 +53,8 @@
|
||||
"webauthn_not_supported_by_browser": "Les clés d'accès ne sont pas prises en charge par ce navigateur. Essaie une autre méthode de connexion.",
|
||||
"critical_error_occurred_contact_administrator": "Une erreur critique s'est produite. Veuillez contacter votre administrateur.",
|
||||
"sign_in_to": "Connexion à {name}",
|
||||
"account_selection_signin_confirmation": "Veux-tu utiliser le compte suivant pour continuer <b>{name}</b>?",
|
||||
"use_a_different_account": "Utilise un autre compte",
|
||||
"client_not_found": "Client introuvable",
|
||||
"client_wants_to_access_the_following_information": "<b>{client}</b> souhaite accéder aux informations suivantes :",
|
||||
"do_you_want_to_sign_in_to_client_with_your_app_name_account": "Voulez-vous vous connecter à <b>{client}</b> avec votre compte {appName}?",
|
||||
|
||||
@@ -53,6 +53,8 @@
|
||||
"webauthn_not_supported_by_browser": "Questo browser non supporta le passkey. Prova a usare un altro modo per accedere.",
|
||||
"critical_error_occurred_contact_administrator": "Si è verificato un errore critico. Contatta il tuo amministratore.",
|
||||
"sign_in_to": "Accedi a {name}",
|
||||
"account_selection_signin_confirmation": "Vuoi usare il seguente account per continuare a <b>{name}</b>?",
|
||||
"use_a_different_account": "Usa un altro account",
|
||||
"client_not_found": "Client non trovato",
|
||||
"client_wants_to_access_the_following_information": "<b>{client}</b> vuole accedere alle seguenti informazioni:",
|
||||
"do_you_want_to_sign_in_to_client_with_your_app_name_account": "Vuoi accedere a <b>{client}</b> con il tuo account {appName}?",
|
||||
|
||||
@@ -53,6 +53,8 @@
|
||||
"webauthn_not_supported_by_browser": "このブラウザではパスキーはサポートされていません。別のサインイン方法をご利用ください。",
|
||||
"critical_error_occurred_contact_administrator": "重大なエラーが発生しました。管理者にお問い合わせください。",
|
||||
"sign_in_to": "{name} にサインイン",
|
||||
"account_selection_signin_confirmation": "以下のアカウントを使用して続行しますか <b>{name}</b>?",
|
||||
"use_a_different_account": "別のアカウントを使用する",
|
||||
"client_not_found": "クライアントが見つかりません",
|
||||
"client_wants_to_access_the_following_information": "<b>{client}</b> は以下の情報にアクセスしようとしています:",
|
||||
"do_you_want_to_sign_in_to_client_with_your_app_name_account": "{appName} アカウントで <b>{client}</b> にサインインしますか?",
|
||||
|
||||
@@ -53,6 +53,8 @@
|
||||
"webauthn_not_supported_by_browser": "이 브라우저에서는 패스키를 지원하지 않습니다. 다른 로그인 방법을 사용해 주세요.",
|
||||
"critical_error_occurred_contact_administrator": "치명적인 오류가 발생했습니다. 관리자에게 연락해주세요.",
|
||||
"sign_in_to": "{name}에 로그인",
|
||||
"account_selection_signin_confirmation": "다음 계정으로 계속 진행하시겠습니까? <b>{name}</b>계속하시겠습니까?",
|
||||
"use_a_different_account": "다른 계정을 사용하세요",
|
||||
"client_not_found": "클라이언트를 찾을 수 없습니다",
|
||||
"client_wants_to_access_the_following_information": "<b>{client}</b>이(가) 다음 정보에 접근하려고 합니다:",
|
||||
"do_you_want_to_sign_in_to_client_with_your_app_name_account": "{appName} 계정으로 <b>{client}</b>에 로그인하시겠습니까?",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user