Compare commits

..

11 Commits

Author SHA1 Message Date
ItalyPaleAle
b11860b864 Add initial REST endpoints 2025-12-07 15:49:45 -08:00
Alessandro (Ale) Segala
c4bd20a90d Merge branch 'main' into breaking/v2 2025-12-07 12:38:12 -08:00
Elias Schneider
900f8fe240 post merge fix 2025-11-30 18:48:10 +01:00
Elias Schneider
1da694f8ad Merge branch 'main' into v2-main 2025-11-30 18:28:00 +01:00
Kyle Mendell
e06538a101 feat: remove DbProvider env variable and calculate it dynamically (#1114) 2025-11-27 12:38:06 -08:00
Elias Schneider
39c1f93756 feat: add CLI command for importing and exporting Pocket ID data (#998)
Co-authored-by: Alessandro (Ale) Segala <43508+ItalyPaleAle@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-11-26 10:38:15 +01:00
ItalyPaleAle
e306d6eb58 Merge remote-tracking branch 'origin' into breaking/v2 2025-11-25 20:48:03 -08:00
Elias Schneider
46793fe68a Merge branch 'main' into v2 2025-11-17 08:39:36 +01:00
Elias Schneider
e22822890f feat!: drop support for storing JWK on the filesystem (#1088) 2025-11-14 12:02:51 +01:00
Elias Schneider
2694d79add Merge branch 'main' into v2 2025-11-14 11:08:20 +01:00
Elias Schneider
98cf1f66c3 fix!: rename LDAP_ATTRIBUTE_ADMIN_GROUP env variable to LDAP_ADMIN_GROUP_NAME (#1089) 2025-11-12 11:51:01 +01:00
364 changed files with 4705 additions and 16433 deletions

View File

@@ -2,9 +2,7 @@
"name": "pocket-id",
"image": "mcr.microsoft.com/devcontainers/typescript-node:1-22-bookworm",
"features": {
"ghcr.io/devcontainers/features/go:1": {
"version": "1.26"
}
"ghcr.io/devcontainers/features/go:1": {}
},
"customizations": {
"vscode": {

View File

@@ -2,7 +2,7 @@ name: Run Backend Linter
on:
push:
branches: [main]
branches: [main, breaking/**]
paths:
- "backend/**"
pull_request:
@@ -17,15 +17,14 @@ permissions:
pull-requests: read
# Optional: allow write access to checks to allow the action to annotate code in the PR.
checks: write
id-token: write
jobs:
golangci-lint:
name: Run Golangci-lint
runs-on: depot-ubuntu-latest
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v6
uses: actions/checkout@v5
- name: Set up Go
uses: actions/setup-go@v6
@@ -33,9 +32,9 @@ jobs:
go-version-file: backend/go.mod
- name: Run Golangci-lint
uses: golangci/golangci-lint-action@v9.0.0
uses: golangci/golangci-lint-action@v8.0.0
with:
version: v2.9.0
version: v2.4.0
args: --build-tags=exclude_frontend
working-directory: backend
only-new-issues: ${{ github.event_name == 'pull_request' }}

View File

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

View File

@@ -1,7 +1,7 @@
name: E2E Tests
on:
push:
branches: [main]
branches: [main, breaking/**]
paths-ignore:
- "docs/**"
- "**.md"
@@ -13,15 +13,47 @@ on:
- "**.md"
- ".github/**"
permissions:
contents: read
actions: write
id-token: write
jobs:
build:
if: github.event.pull_request.head.ref != 'i18n_crowdin'
timeout-minutes: 20
permissions:
contents: read
actions: write
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build and export
uses: docker/build-push-action@v6
with:
context: .
file: docker/Dockerfile
push: false
load: false
tags: pocket-id:test
outputs: type=docker,dest=/tmp/docker-image.tar
build-args: BUILD_TAGS=e2etest
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Upload Docker image artifact
uses: actions/upload-artifact@v4
with:
name: docker-image
path: /tmp/docker-image.tar
retention-days: 1
test:
if: github.event.pull_request.head.ref != 'i18n_crowdin'
runs-on: depot-ubuntu-24.04-32
permissions:
contents: read
actions: write
runs-on: ubuntu-latest
needs: build
strategy:
fail-fast: false
matrix:
@@ -38,22 +70,15 @@ jobs:
storage: database
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v5
- name: Setup pnpm
uses: pnpm/action-setup@v4
- name: Setup Node.js
uses: actions/setup-node@v6
uses: actions/setup-node@v5
with:
node-version: 24
cache: "pnpm"
- name: Set up Depot CLI
uses: depot/setup-action@v1
- name: Set up Depot Docker builder
run: depot configure-docker
node-version: 22
- name: Cache Playwright Browsers
uses: actions/cache@v4
@@ -77,20 +102,20 @@ jobs:
if: matrix.db == 'postgres' && steps.postgres-cache.outputs.cache-hit == 'true'
run: docker load < /tmp/postgres-image.tar
- name: Cache SCIM Test Server Docker image
- name: Cache LLDAP Docker image
uses: actions/cache@v4
id: scim-cache
id: lldap-cache
with:
path: /tmp/scim-test-server-image.tar
key: scim-test-server-${{ runner.os }}
- name: Pull and save SCIM Test Server image
if: steps.scim-cache.outputs.cache-hit != 'true'
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 ghcr.io/pocket-id/scim-test-server
docker save ghcr.io/pocket-id/scim-test-server > /tmp/scim-test-server-image.tar
- name: Load SCIM Test Server image
if: steps.scim-cache.outputs.cache-hit == 'true'
run: docker load < /tmp/scim-test-server-image.tar
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 Localstack S3 Docker image
if: matrix.storage == 's3'
@@ -108,6 +133,31 @@ jobs:
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
@@ -121,19 +171,14 @@ jobs:
run: |
DOCKER_COMPOSE_FILE=docker-compose.yml
cat > .env <<EOF
FILE_BACKEND=${{ matrix.storage }}
SCIM_SERVICE_PROVIDER_URL=http://localhost:18123/v2
SCIM_SERVICE_PROVIDER_URL_INTERNAL=http://scim-test-server:8080/v2
EOF
echo "FILE_BACKEND=${{ matrix.storage }}" > .env
if [ "${{ matrix.db }}" = "postgres" ]; then
DOCKER_COMPOSE_FILE=docker-compose-postgres.yml
elif [ "${{ matrix.storage }}" = "s3" ]; then
DOCKER_COMPOSE_FILE=docker-compose-s3.yml
fi
docker compose -f "$DOCKER_COMPOSE_FILE" up -d --build
docker compose -f "$DOCKER_COMPOSE_FILE" up -d
{
LOG_FILE="/tmp/backend.log"

View File

@@ -1,106 +0,0 @@
name: PR Quality
permissions:
contents: read
issues: read
pull-requests: write
on:
pull_request_target:
types: [opened, reopened]
jobs:
pr-quality:
runs-on: ubuntu-latest
steps:
- uses: peakoss/anti-slop@v0
with:
# General Settings
max-failures: 4
# PR Branch Checks
allowed-target-branches: "main"
blocked-target-branches: ""
allowed-source-branches: ""
blocked-source-branches: ""
# PR Quality Checks
max-negative-reactions: 0
require-maintainer-can-modify: true
# PR Title Checks
require-conventional-title: true
# PR Description Checks
require-description: true
max-description-length: 2500
max-emoji-count: 0
max-code-references: 0
require-linked-issue: false
blocked-terms: ""
blocked-issue-numbers: ""
# PR Template Checks
require-pr-template: true
strict-pr-template-sections: ""
optional-pr-template-sections: "Issues"
max-additional-pr-template-sections: 3
# Commit Message Checks
max-commit-message-length: 500
require-conventional-commits: false
require-commit-author-match: true
blocked-commit-authors: ""
# File Checks
allowed-file-extensions: ""
allowed-paths: ""
blocked-paths: |
SECURITY.md
LICENSE
require-final-newline: false
max-added-comments: 0
# User Checks
detect-spam-usernames: true
min-account-age: 30
max-daily-forks: 7
min-profile-completeness: 4
# Merge Checks
min-repo-merged-prs: 0
min-repo-merge-ratio: 0
min-global-merge-ratio: 30
global-merge-ratio-exclude-own: false
# Exemptions
exempt-draft-prs: false
exempt-bots: |
actions-user
dependabot[bot]
renovate[bot]
github-actions[bot]
exempt-users: ""
exempt-author-association: "OWNER,MEMBER,COLLABORATOR"
exempt-label: "quality/exempt"
exempt-pr-label: ""
exempt-all-milestones: false
exempt-all-pr-milestones: false
exempt-milestones: ""
exempt-pr-milestones: ""
# PR Success Actions
success-add-pr-labels: "quality/verified"
# PR Failure Actions
failure-remove-pr-labels: ""
failure-remove-all-pr-labels: true
failure-add-pr-labels: "quality/rejected"
failure-pr-message: |
This PR did not pass quality checks so it will be closed.
See the [workflow run](https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}/attempts/${{ github.run_attempt }}) for details on which checks failed.
If you believe this is a mistake please let us know.
close-pr: true
lock-pr: false

View File

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

View File

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

View File

@@ -1,7 +1,7 @@
name: Unit Tests
on:
push:
branches: [main]
branches: [main, breaking/**]
paths:
- "backend/**"
pull_request:
@@ -9,16 +9,14 @@ on:
paths:
- "backend/**"
permissions:
contents: read
id-token: write
actions: write
jobs:
test-backend:
runs-on: depot-ubuntu-latest
permissions:
contents: read
actions: write
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v5
- uses: actions/setup-go@v6
with:
go-version-file: "backend/go.mod"

View File

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

View File

@@ -1 +1 @@
2.4.0
1.16.0

View File

@@ -1,188 +1,3 @@
## v2.4.0
### Bug Fixes
- improve wildcard matching by using `go-urlpattern` ([#1332](https://github.com/pocket-id/pocket-id/pull/1332) by @stonith404)
- federated client credentials not working if sub ≠ client_id ([#1342](https://github.com/pocket-id/pocket-id/pull/1342) by @ItalyPaleAle)
- handle IPv6 addresses in callback URLs ([#1355](https://github.com/pocket-id/pocket-id/pull/1355) by @ItalyPaleAle)
- wildcard callback URLs blocked by browser-native URL validation ([#1359](https://github.com/pocket-id/pocket-id/pull/1359) by @Copilot)
- one-time-access-token route should get user ID from URL only ([#1358](https://github.com/pocket-id/pocket-id/pull/1358) by @ItalyPaleAle)
- various fixes in background jobs ([#1362](https://github.com/pocket-id/pocket-id/pull/1362) by @ItalyPaleAle)
- use URL keyboard type for callback URL inputs ([a675d07](https://github.com/pocket-id/pocket-id/commit/a675d075d1ab9b7ff8160f1cfc35bc0ea1f1980a) by @stonith404)
### Features
- allow first name and display name to be optional ([#1288](https://github.com/pocket-id/pocket-id/pull/1288) by @taoso)
### Other
- bump svelte from 5.53.2 to 5.53.5 in the npm_and_yarn group across 1 directory ([#1348](https://github.com/pocket-id/pocket-id/pull/1348) by @dependabot[bot])
- bump @sveltejs/kit from 2.53.0 to 2.53.3 in the npm_and_yarn group across 1 directory ([#1349](https://github.com/pocket-id/pocket-id/pull/1349) by @dependabot[bot])
- update AAGUIDs ([#1354](https://github.com/pocket-id/pocket-id/pull/1354) by @github-actions[bot])
- add Português files ([01141b8](https://github.com/pocket-id/pocket-id/commit/01141b8c0f2e96a40fd876d3206e49a694fd12c4) by @kmendell)
- add Latvian files ([e0fc4cc](https://github.com/pocket-id/pocket-id/commit/e0fc4cc01bd51e5a97e46aad78a493a668049220) by @kmendell)
- fix wrong seed data ([e7bd66d](https://github.com/pocket-id/pocket-id/commit/e7bd66d1a77c89dde542b4385ba01dc0d432e434) by @stonith404)
- fix wrong seed data in `database.json` ([f4eb8db](https://github.com/pocket-id/pocket-id/commit/f4eb8db50993edacd90e919b39a5c6d9dd4924c7) by @stonith404)
### Performance Improvements
- frontend performance optimizations ([#1344](https://github.com/pocket-id/pocket-id/pull/1344) by @ItalyPaleAle)
**Full Changelog**: https://github.com/pocket-id/pocket-id/compare/v2.3.0...v2.4.0
## v2.3.0
### Bug Fixes
- ENCRYPTION_KEY needed for version and help commands ([#1256](https://github.com/pocket-id/pocket-id/pull/1256) by @kmendell)
- prevent deletion of OIDC provider logo for non admin/anonymous users ([#1267](https://github.com/pocket-id/pocket-id/pull/1267) by @HiMoritz)
- add `type="url"` to url inputs ([bb7b0d5](https://github.com/pocket-id/pocket-id/commit/bb7b0d56084df49b6a003cc3eaf076884e2cbf60) by @stonith404)
- increase rate limit for frontend and api requests ([aab7e36](https://github.com/pocket-id/pocket-id/commit/aab7e364e85f1ce13950da93cc50324328cdd96d) by @stonith404)
- decode URL-encoded client ID and secret in Basic auth ([#1263](https://github.com/pocket-id/pocket-id/pull/1263) by @ypomortsev)
- token endpoint must not accept params as query string args ([#1321](https://github.com/pocket-id/pocket-id/pull/1321) by @ItalyPaleAle)
- left align input error messages ([b3fe143](https://github.com/pocket-id/pocket-id/commit/b3fe14313684f9d8c389ed93ea8e479e3681b5c6) by @stonith404)
- disallow API key renewal and creation with API key authentication ([#1334](https://github.com/pocket-id/pocket-id/pull/1334) by @stonith404)
### Features
- add VERSION_CHECK_DISABLED environment variable ([#1254](https://github.com/pocket-id/pocket-id/pull/1254) by @dihmandrake)
- add support for HTTP/2 ([56afebc](https://github.com/pocket-id/pocket-id/commit/56afebc242be7ed14b58185425d6445bf18f640a) by @stonith404)
- manageability of uncompressed geolite db file ([#1234](https://github.com/pocket-id/pocket-id/pull/1234) by @gucheen)
- add JWT ID for generated tokens ([#1322](https://github.com/pocket-id/pocket-id/pull/1322) by @imnotjames)
- current version api endpoint ([#1310](https://github.com/pocket-id/pocket-id/pull/1310) by @kmendell)
### Other
- bump @sveltejs/kit from 2.49.2 to 2.49.5 in the npm_and_yarn group across 1 directory ([#1240](https://github.com/pocket-id/pocket-id/pull/1240) by @dependabot[bot])
- bump svelte from 5.46.1 to 5.46.4 in the npm_and_yarn group across 1 directory ([#1242](https://github.com/pocket-id/pocket-id/pull/1242) by @dependabot[bot])
- bump devalue to 5.6.2 ([9dbc02e](https://github.com/pocket-id/pocket-id/commit/9dbc02e56871b2de6a39c443e1455efc26a949f7) by @kmendell)
- upgrade deps ([4811625](https://github.com/pocket-id/pocket-id/commit/4811625cdd64b47ea67b7a9b03396e455896ccd6) by @kmendell)
- add Estonian files ([53ef61a](https://github.com/pocket-id/pocket-id/commit/53ef61a3e5c4b77edec49d41ab94302bfec84269) by @kmendell)
- update AAGUIDs ([#1257](https://github.com/pocket-id/pocket-id/pull/1257) by @github-actions[bot])
- add Norwegian language files ([80558c5](https://github.com/pocket-id/pocket-id/commit/80558c562533e7b4d658d5baa4221d8cd209b47d) by @stonith404)
- run formatter ([60825c5](https://github.com/pocket-id/pocket-id/commit/60825c5743b0e233ab622fd4d0ea04eb7ab59529) by @kmendell)
- bump axios from 1.13.2 to 1.13.5 in the npm_and_yarn group across 1 directory ([#1309](https://github.com/pocket-id/pocket-id/pull/1309) by @dependabot[bot])
- update dependenicies ([94a4897](https://github.com/pocket-id/pocket-id/commit/94a48977ba24e099b6221838d620c365eb1d4bf4) by @kmendell)
- update AAGUIDs ([#1316](https://github.com/pocket-id/pocket-id/pull/1316) by @github-actions[bot])
- bump svelte from 5.46.4 to 5.51.5 in the npm_and_yarn group across 1 directory ([#1324](https://github.com/pocket-id/pocket-id/pull/1324) by @dependabot[bot])
- bump @sveltejs/kit from 2.49.5 to 2.52.2 in the npm_and_yarn group across 1 directory ([#1327](https://github.com/pocket-id/pocket-id/pull/1327) by @dependabot[bot])
- upgrade dependencies ([0678699](https://github.com/pocket-id/pocket-id/commit/0678699d0cce5448c425b2c16bedab5fc242cbf0) by @stonith404)
- upgrade to node 24 and go 1.26.0 ([#1328](https://github.com/pocket-id/pocket-id/pull/1328) by @kmendell)
**Full Changelog**: https://github.com/pocket-id/pocket-id/compare/v2.2.0...v2.3.0
## v2.2.0
### Bug Fixes
- allow changing "require email address" if no SMTP credentials present ([8c68b08](https://github.com/pocket-id/pocket-id/commit/8c68b08c12ba371deda61662e3d048d63d07c56f) by @stonith404)
- data import from sqlite to postgres fails because of wrong datatype ([1a032a8](https://github.com/pocket-id/pocket-id/commit/1a032a812ef78b250a898d14bec73a8ef7a7859a) by @stonith404)
- user can't update account if email is empty ([5828fa5](https://github.com/pocket-id/pocket-id/commit/5828fa57791314594625d52475733dce23cc2fcc) by @stonith404)
- login codes sent by an admin incorrectly requires a device token ([03f9be0](https://github.com/pocket-id/pocket-id/commit/03f9be0d125732e02a8e2c5390d9e6d0c74ce957) by @stonith404)
- allow exchanging logic code if already authenticated ([0e2cdc3](https://github.com/pocket-id/pocket-id/commit/0e2cdc393e34276bb3b8ea318cdc7261de3f2dec) by @stonith404)
- db version downgrades don't downgrade db schema ([4df4bcb](https://github.com/pocket-id/pocket-id/commit/4df4bcb6451b4bf88093e04f3222c8737f2c7be3) by @stonith404)
- use user specific email verified claim instead of global one ([2a11c3e](https://github.com/pocket-id/pocket-id/commit/2a11c3e60942d45c2e5b422d99945bce65a622a2) by @stonith404)
### Features
- add CLI command for encryption key rotation ([#1209](https://github.com/pocket-id/pocket-id/pull/1209) by @stonith404)
- improve passkey error messages ([2f25861](https://github.com/pocket-id/pocket-id/commit/2f25861d15aefa868042e70d3e21b7b38a6ae679) by @stonith404)
- make home page URL configurable ([#1215](https://github.com/pocket-id/pocket-id/pull/1215) by @stonith404)
- add option to renew API key ([#1214](https://github.com/pocket-id/pocket-id/pull/1214) by @stonith404)
- add support for email verification ([#1223](https://github.com/pocket-id/pocket-id/pull/1223) by @stonith404)
- add environment variable to disable built-in rate limiting ([9ca3d33](https://github.com/pocket-id/pocket-id/commit/9ca3d33c8897cf49a871783058205bb180529cd2) by @stonith404)
- add static api key env variable ([#1229](https://github.com/pocket-id/pocket-id/pull/1229) by @stonith404)
**Full Changelog**: https://github.com/pocket-id/pocket-id/compare/v2.1.0...v2.2.0
## v2.1.0
### Bug Fixes
- invalid cookie name for email login code device token ([d6a7b50](https://github.com/pocket-id/pocket-id/commit/d6a7b503ff4571b1291a55a569add3374f5e2d5b) by @stonith404)
### Features
- add issuer url to oidc client details list ([#1197](https://github.com/pocket-id/pocket-id/pull/1197) by @kmendell)
- process nonce within device authorization flow ([#1185](https://github.com/pocket-id/pocket-id/pull/1185) by @justincmoy)
### Other
- run SCIM jobs in context of gocron instead of custom implementation ([4881130](https://github.com/pocket-id/pocket-id/commit/4881130eadcef0642f8a87650b7c36fda453b51b) by @stonith404)
**Full Changelog**: https://github.com/pocket-id/pocket-id/compare/v2.0.2...v2.1.0
## v2.0.2
### Bug Fixes
- migration fails if users exist with no email address ([2f651ad](https://github.com/pocket-id/pocket-id/commit/2f651adf3b4e8d689461da2083c3afcb1eb1d477) by @stonith404)
- allow version downgrade database is dirty ([ba00f40](https://github.com/pocket-id/pocket-id/commit/ba00f40bd4b06f31d251599fcb1db63e902a6987) by @stonith404)
- localhost callback URLs with port don't match correctly ([7c34501](https://github.com/pocket-id/pocket-id/commit/7c345010556f11a593948b2a1ae558b7a8003696) by @stonith404)
### Other
- add no-op migration to postgres ([a24b2af](https://github.com/pocket-id/pocket-id/commit/a24b2afb7b8165bed05976058a8ae797adc245df) by @stonith404)
**Full Changelog**: https://github.com/pocket-id/pocket-id/compare/v2.0.1...v2.0.2
## v2.0.1
### Bug Fixes
- admins imported from LDAP lose admin privileges ([2cce200](https://github.com/pocket-id/pocket-id/commit/2cce2008928081b5e0f0e6bcbc3f43816f082de9) by @stonith404)
- restore old input input field size ([2341da9](https://github.com/pocket-id/pocket-id/commit/2341da99e9716686cf28dd0680d751ae9da0fadc) by @stonith404)
### Other
- bump image tag to `v2` ([cd2e9f3](https://github.com/pocket-id/pocket-id/commit/cd2e9f3a2ad753815ef8da998f9b54853d953a2a) by @stonith404)
**Full Changelog**: https://github.com/pocket-id/pocket-id/compare/v2.0.0...v2.0.1
## v2.0.0
### Bug Fixes
- update image format message to include WEBP ([#1133](https://github.com/pocket-id/pocket-id/pull/1133) by @sebdanielsson)
- add Japanese locale to inlang settings ([#1142](https://github.com/pocket-id/pocket-id/pull/1142) by @tai-ga)
- restrict email one time sign in token to same browser ([#1144](https://github.com/pocket-id/pocket-id/pull/1144) by @stonith404)
- rename `LDAP_ATTRIBUTE_ADMIN_GROUP` env variable to `LDAP_ADMIN_GROUP_NAME` ([e1c5021](https://github.com/pocket-id/pocket-id/commit/e1c5021eeedcbc54bad0eccd72d7ae760be61934) by @stonith404)
- make wildcard matching in callback URLs more stricter ([078152d](https://github.com/pocket-id/pocket-id/commit/078152d4dbb05dd027ff323f39d090ecb67927c7) by @stonith404)
- remove ambiguous characters from login code ([d9e7bf9](https://github.com/pocket-id/pocket-id/commit/d9e7bf9eef522d8c081fac2000bace6f95518039) by @stonith404)
- add missing translations to date picker ([894eaf3](https://github.com/pocket-id/pocket-id/commit/894eaf3cffdd9182b9c29e28b4dcb7e8bcbda26b) by @stonith404)
### Features
- add HTTP `HEAD` method support ([#1135](https://github.com/pocket-id/pocket-id/pull/1135) by @stonith404)
- add email logo customization ([#1150](https://github.com/pocket-id/pocket-id/pull/1150) by @MelvinSnijders)
- add ability define user groups for sign up tokens ([#1155](https://github.com/pocket-id/pocket-id/pull/1155) by @stonith404)
- minor redesign of auth pages ([08e4ffe](https://github.com/pocket-id/pocket-id/commit/08e4ffeb600a4a6644d91b1600b0205997ed1685) by @stonith404)
- allow audit log retention to be controlled by env variable ([#1158](https://github.com/pocket-id/pocket-id/pull/1158) by @jenic)
- restrict oidc clients by user groups per default ([#1164](https://github.com/pocket-id/pocket-id/pull/1164) by @stonith404)
- add "restricted" column to oidc client table ([1bc9f5f](https://github.com/pocket-id/pocket-id/commit/1bc9f5f7e780310d81608381544ba530df7f433b) by @stonith404)
- drop support for storing JWK on the filesystem ([f014458](https://github.com/pocket-id/pocket-id/commit/f0144584af90b918a3157a298f1bb95928a117b8) by @stonith404)
- add CLI command for importing and exporting Pocket ID data ([3420a00](https://github.com/pocket-id/pocket-id/commit/3420a000737d89a5c3c6c250d171d96126553beb) by @stonith404)
- remove DbProvider env variable and calculate it dynamically ([ba2f0f1](https://github.com/pocket-id/pocket-id/commit/ba2f0f18f4bacc5a86217dec0b0dcb6030c40cb9) by @kmendell)
- add support for SCIM provisioning ([#1182](https://github.com/pocket-id/pocket-id/pull/1182) by @stonith404)
### Other
- update AAGUIDs ([#1128](https://github.com/pocket-id/pocket-id/pull/1128) by @github-actions[bot])
- fix api key e2e test ([25f67bd](https://github.com/pocket-id/pocket-id/commit/25f67bd25a0ee0cab48d72107722e8c8428fa547) by @stonith404)
- update AAGUIDs ([#1140](https://github.com/pocket-id/pocket-id/pull/1140) by @github-actions[bot])
- upgrade dependencies ([90f555f](https://github.com/pocket-id/pocket-id/commit/90f555f7c12ff07545f7cd1a1754a8c19f5a4978) by @stonith404)
- fix type error after version bump ([edb32d8](https://github.com/pocket-id/pocket-id/commit/edb32d82b2c138433d8eb17d5a6a19f4728ae2d4) by @stonith404)
- remove `breaking/**` push trigger from actions ([461293b](https://github.com/pocket-id/pocket-id/commit/461293ba1da4ddbff2c77f23a42487b63964e474) by @stonith404)
- update AAGUIDs ([#1177](https://github.com/pocket-id/pocket-id/pull/1177) by @github-actions[bot])
- preparation for merge into main branch ([#1113](https://github.com/pocket-id/pocket-id/pull/1113) by @stonith404)
- bump pnpm to version 10.27.0 ([#1183](https://github.com/pocket-id/pocket-id/pull/1183) by @kmendell)
- update forms and other areas to use new shadcn components ([#1115](https://github.com/pocket-id/pocket-id/pull/1115) by @kmendell)
- run formatter ([e4a8ca4](https://github.com/pocket-id/pocket-id/commit/e4a8ca476cc3c7e8d8cdc8de21b5d7d99d07f7a0) by @stonith404)
- upgrade dependencies ([4776b70](https://github.com/pocket-id/pocket-id/commit/4776b70d96f3dc291394dc79c941738bbe48199a) by @stonith404)
- change translation string in e2e tests ([ffb2ef9](https://github.com/pocket-id/pocket-id/commit/ffb2ef91bd7bbe78eb29e86cd3675b695e821498) by @stonith404)
**Full Changelog**: https://github.com/pocket-id/pocket-id/compare/v1.16.0...v2.0.0
## v1.16.0
### Bug Fixes

View File

@@ -21,6 +21,7 @@ Before you submit the pull request for review please ensure that
```
Where `TYPE` can be:
- **feat** - is a new feature
- **doc** - documentation only changes
- **fix** - a bug fix
@@ -50,8 +51,8 @@ If you use [Dev Containers](https://code.visualstudio.com/docs/remote/containers
If you don't use Dev Containers, you need to install the following tools manually:
- [Node.js](https://nodejs.org/en/download/) >= 24
- [Go](https://golang.org/doc/install) >= 1.26
- [Node.js](https://nodejs.org/en/download/) >= 22
- [Go](https://golang.org/doc/install) >= 1.25
- [Git](https://git-scm.com/downloads)
### 2. Setup

View File

@@ -4,7 +4,7 @@ Pocket ID is a simple OIDC provider that allows users to authenticate with their
→ Try out the [Demo](https://demo.pocket-id.org)
<img src="https://github.com/user-attachments/assets/1e99ba44-76da-4b47-9b8a-dbe9b7f84512" width="1200"/>
<img src="https://github.com/user-attachments/assets/96ac549d-b897-404a-8811-f42b16ea58e2" width="1200"/>
The goal of Pocket ID is to be a simple and easy-to-use. There are other self-hosted OIDC providers like [Keycloak](https://www.keycloak.org/) or [ORY Hydra](https://www.ory.sh/hydra/) but they are often too complex for simple use cases.

View File

@@ -4,6 +4,6 @@ package frontend
import "github.com/gin-gonic/gin"
func RegisterFrontend(router *gin.Engine, rateLimitMiddleware gin.HandlerFunc) error {
func RegisterFrontend(router *gin.Engine) error {
return ErrFrontendNotIncluded
}

View File

@@ -8,10 +8,8 @@ import (
"fmt"
"io"
"io/fs"
"mime"
"net/http"
"os"
"path"
"strings"
"time"
@@ -54,23 +52,16 @@ func init() {
}
}
func RegisterFrontend(router *gin.Engine, rateLimitMiddleware gin.HandlerFunc) error {
func RegisterFrontend(router *gin.Engine) error {
distFS, err := fs.Sub(frontendFS, "dist")
if err != nil {
return fmt.Errorf("failed to create sub FS: %w", err)
}
// Load a map of all files to see which ones are available pre-compressed
preCompressed, err := listPreCompressedAssets(distFS)
if err != nil {
return fmt.Errorf("failed to index pre-compressed frontend assets: %w", err)
}
cacheMaxAge := time.Hour * 24
fileServer := NewFileServerWithCaching(http.FS(distFS), int(cacheMaxAge.Seconds()))
// Init the file server
fileServer := NewFileServerWithCaching(http.FS(distFS), preCompressed)
// Handler for Gin
handler := func(c *gin.Context) {
router.NoRoute(func(c *gin.Context) {
path := strings.TrimPrefix(c.Request.URL.Path, "/")
if strings.HasSuffix(path, "/") {
@@ -106,9 +97,7 @@ func RegisterFrontend(router *gin.Engine, rateLimitMiddleware gin.HandlerFunc) e
// Serve other static assets with caching
c.Request.URL.Path = "/" + path
fileServer.ServeHTTP(c.Writer, c.Request)
}
router.NoRoute(rateLimitMiddleware, handler)
})
return nil
}
@@ -117,138 +106,34 @@ func RegisterFrontend(router *gin.Engine, rateLimitMiddleware gin.HandlerFunc) e
type FileServerWithCaching struct {
root http.FileSystem
lastModified time.Time
cacheMaxAge int
lastModifiedHeaderValue string
preCompressed preCompressedMap
cacheControlHeaderValue string
}
func NewFileServerWithCaching(root http.FileSystem, preCompressed preCompressedMap) *FileServerWithCaching {
func NewFileServerWithCaching(root http.FileSystem, maxAge int) *FileServerWithCaching {
return &FileServerWithCaching{
root: root,
lastModified: time.Now(),
cacheMaxAge: maxAge,
lastModifiedHeaderValue: time.Now().UTC().Format(http.TimeFormat),
preCompressed: preCompressed,
cacheControlHeaderValue: fmt.Sprintf("public, max-age=%d", maxAge),
}
}
func (f *FileServerWithCaching) ServeHTTP(w http.ResponseWriter, r *http.Request) {
// First, set cache headers
// Check if the request is for an immutable asset
if isImmutableAsset(r) {
// Set the cache control header as immutable with a long expiration
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
} else {
// Check if the client has a cached version
ifModifiedSince := r.Header.Get("If-Modified-Since")
if ifModifiedSince != "" {
ifModifiedSinceTime, err := time.Parse(http.TimeFormat, ifModifiedSince)
if err == nil && f.lastModified.Before(ifModifiedSinceTime.Add(1*time.Second)) {
// Client's cached version is up to date
w.WriteHeader(http.StatusNotModified)
return
}
}
// Cache other assets for up to 24 hours, but set Last-Modified too
w.Header().Set("Last-Modified", f.lastModifiedHeaderValue)
w.Header().Set("Cache-Control", "public, max-age=86400")
}
// Check if the asset is available pre-compressed
_, ok := f.preCompressed[r.URL.Path]
if ok {
// Add a "Vary" with "Accept-Encoding" so CDNs are aware that content is pre-compressed
w.Header().Add("Vary", "Accept-Encoding")
// Select the encoding if any
ext, ce := f.selectEncoding(r)
if ext != "" {
// Set the content type explicitly before changing the path
ct := mime.TypeByExtension(path.Ext(r.URL.Path))
if ct != "" {
w.Header().Set("Content-Type", ct)
}
// Make the serve return the encoded content
w.Header().Set("Content-Encoding", ce)
r.URL.Path += "." + ext
// Check if the client has a cached version
if ifModifiedSince := r.Header.Get("If-Modified-Since"); ifModifiedSince != "" {
ifModifiedSinceTime, err := time.Parse(http.TimeFormat, ifModifiedSince)
if err == nil && f.lastModified.Before(ifModifiedSinceTime.Add(1*time.Second)) {
// Client's cached version is up to date
w.WriteHeader(http.StatusNotModified)
return
}
}
w.Header().Set("Last-Modified", f.lastModifiedHeaderValue)
w.Header().Set("Cache-Control", f.cacheControlHeaderValue)
http.FileServer(f.root).ServeHTTP(w, r)
}
func (f *FileServerWithCaching) selectEncoding(r *http.Request) (ext string, contentEnc string) {
available, ok := f.preCompressed[r.URL.Path]
if !ok {
return "", ""
}
// Check if the client accepts compressed files
acceptEncoding := strings.TrimSpace(strings.ToLower(r.Header.Get("Accept-Encoding")))
if acceptEncoding == "" {
return "", ""
}
// Prefer brotli over gzip when both are accepted.
if available.br && (acceptEncoding == "*" || acceptEncoding == "br" || strings.Contains(acceptEncoding, "br")) {
return "br", "br"
}
if available.gz && (acceptEncoding == "gzip" || strings.Contains(acceptEncoding, "gzip")) {
return "gz", "gzip"
}
return "", ""
}
func isImmutableAsset(r *http.Request) bool {
switch {
// Fonts
case strings.HasPrefix(r.URL.Path, "/fonts/"):
return true
// Compiled SvelteKit assets
case strings.HasPrefix(r.URL.Path, "/_app/immutable/"):
return true
default:
return false
}
}
type preCompressedMap map[string]struct {
br bool
gz bool
}
func listPreCompressedAssets(distFS fs.FS) (preCompressedMap, error) {
preCompressed := make(preCompressedMap, 0)
err := fs.WalkDir(distFS, ".", func(path string, d fs.DirEntry, walkErr error) error {
if walkErr != nil {
return walkErr
}
if d.IsDir() {
return nil
}
switch {
case strings.HasSuffix(path, ".br"):
originalPath := "/" + strings.TrimSuffix(path, ".br")
entry := preCompressed[originalPath]
entry.br = true
preCompressed[originalPath] = entry
case strings.HasSuffix(path, ".gz"):
originalPath := "/" + strings.TrimSuffix(path, ".gz")
entry := preCompressed[originalPath]
entry.gz = true
preCompressed[originalPath] = entry
}
return nil
})
if err != nil {
return nil, err
}
return preCompressed, nil
}

View File

@@ -1,58 +1,56 @@
module github.com/pocket-id/pocket-id/backend
go 1.26.0
go 1.25
require (
github.com/aws/aws-sdk-go-v2 v1.41.1
github.com/aws/aws-sdk-go-v2/config v1.32.9
github.com/aws/aws-sdk-go-v2/credentials v1.19.9
github.com/aws/aws-sdk-go-v2/service/s3 v1.96.0
github.com/aws/smithy-go v1.24.1
github.com/aws/aws-sdk-go-v2 v1.40.0
github.com/aws/aws-sdk-go-v2/config v1.32.2
github.com/aws/aws-sdk-go-v2/credentials v1.19.2
github.com/aws/aws-sdk-go-v2/service/s3 v1.92.1
github.com/aws/smithy-go v1.23.2
github.com/caarlos0/env/v11 v11.3.1
github.com/cenkalti/backoff/v5 v5.0.3
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/gin-contrib/slog v1.2.0
github.com/gin-gonic/gin v1.11.0
github.com/glebarez/go-sqlite v1.22.0
github.com/glebarez/sqlite v1.11.0
github.com/go-co-op/gocron/v2 v2.19.1
github.com/go-co-op/gocron/v2 v2.18.1
github.com/go-ldap/ldap/v3 v3.4.12
github.com/go-playground/validator/v10 v10.30.1
github.com/go-playground/validator/v10 v10.28.0
github.com/go-webauthn/webauthn v0.15.0
github.com/golang-migrate/migrate/v4 v4.19.1
github.com/golang-migrate/migrate/v4 v4.19.0
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.4
github.com/lestrrat-go/jwx/v3 v3.0.13
github.com/lmittmann/tint v1.1.3
github.com/lestrrat-go/httprc/v3 v3.0.1
github.com/lestrrat-go/jwx/v3 v3.0.12
github.com/lmittmann/tint v1.1.2
github.com/mattn/go-isatty v0.0.20
github.com/mileusna/useragent v1.3.5
github.com/orandin/slog-gorm v1.4.0
github.com/oschwald/maxminddb-golang/v2 v2.1.1
github.com/spf13/cobra v1.10.2
github.com/oschwald/maxminddb-golang/v2 v2.1.0
github.com/spf13/cobra v1.10.1
github.com/stretchr/testify v1.11.1
go.opentelemetry.io/contrib/bridges/otelslog v0.15.0
go.opentelemetry.io/contrib/exporters/autoexport v0.65.0
go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin v0.65.0
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0
go.opentelemetry.io/otel v1.40.0
go.opentelemetry.io/otel/log v0.16.0
go.opentelemetry.io/otel/metric v1.40.0
go.opentelemetry.io/otel/sdk v1.40.0
go.opentelemetry.io/otel/sdk/log v0.16.0
go.opentelemetry.io/otel/sdk/metric v1.40.0
go.opentelemetry.io/otel/trace v1.40.0
golang.org/x/crypto v0.48.0
golang.org/x/image v0.36.0
golang.org/x/net v0.50.0
golang.org/x/sync v0.19.0
golang.org/x/text v0.34.0
go.opentelemetry.io/contrib/bridges/otelslog v0.13.0
go.opentelemetry.io/contrib/exporters/autoexport v0.63.0
go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin v0.63.0
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0
go.opentelemetry.io/otel v1.38.0
go.opentelemetry.io/otel/log v0.14.0
go.opentelemetry.io/otel/metric v1.38.0
go.opentelemetry.io/otel/sdk v1.38.0
go.opentelemetry.io/otel/sdk/log v0.14.0
go.opentelemetry.io/otel/sdk/metric v1.38.0
go.opentelemetry.io/otel/trace v1.38.0
golang.org/x/crypto v0.45.0
golang.org/x/image v0.33.0
golang.org/x/sync v0.18.0
golang.org/x/text v0.31.0
golang.org/x/time v0.14.0
gorm.io/driver/postgres v1.6.0
gorm.io/gorm v1.31.1
@@ -60,53 +58,54 @@ require (
require (
github.com/Azure/go-ntlmssp v0.1.0 // indirect
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.17 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.17 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.17 // indirect
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.3 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.14 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.14 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.14 // indirect
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 // indirect
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.17 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.8 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.17 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.17 // indirect
github.com/aws/aws-sdk-go-v2/service/signin v1.0.5 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.30.10 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.14 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.41.6 // indirect
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.14 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.3 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.5 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.14 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.14 // indirect
github.com/aws/aws-sdk-go-v2/service/signin v1.0.2 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.30.5 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.10 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.41.2 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/bits-and-blooms/bitset v1.14.3 // indirect
github.com/bytedance/gopkg v0.1.3 // indirect
github.com/bytedance/sonic v1.15.0 // indirect
github.com/bytedance/sonic/loader v0.5.0 // indirect
github.com/bytedance/sonic v1.14.2 // indirect
github.com/bytedance/sonic/loader v0.4.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/cloudwego/base64x v0.1.6 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 // indirect
github.com/disintegration/gift v1.2.1 // 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.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.13 // indirect
github.com/gabriel-vasile/mimetype v1.4.11 // indirect
github.com/gin-contrib/sse v1.1.0 // indirect
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 // indirect
github.com/go-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.1 // indirect
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
github.com/go-webauthn/x v0.1.26 // indirect
github.com/goccy/go-json v0.10.5 // indirect
github.com/goccy/go-yaml v1.19.2 // indirect
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
github.com/goccy/go-yaml v1.18.0 // indirect
github.com/golang-jwt/jwt/v5 v5.3.0 // 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/google/go-querystring v1.1.0 // indirect
github.com/google/go-tpm v0.9.7 // indirect
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3 // indirect
github.com/hashicorp/errwrap v1.1.0 // indirect
github.com/hashicorp/go-multierror v1.1.1 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/pgx/v5 v5.8.0 // indirect
github.com/jackc/pgx/v5 v5.7.6 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
@@ -118,57 +117,58 @@ require (
github.com/lestrrat-go/dsig v1.0.0 // indirect
github.com/lestrrat-go/dsig-secp256k1 v1.0.0 // indirect
github.com/lestrrat-go/httpcc v1.0.1 // indirect
github.com/lestrrat-go/option v1.0.1 // indirect
github.com/lestrrat-go/option/v2 v2.0.0 // indirect
github.com/lib/pq v1.11.2 // indirect
github.com/mattn/go-sqlite3 v1.14.34 // indirect
github.com/lib/pq v1.10.9 // indirect
github.com/mattn/go-sqlite3 v1.14.32 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/nlnwa/whatwg-url v0.5.0 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/prometheus/client_golang v1.23.2 // indirect
github.com/prometheus/client_model v0.6.2 // indirect
github.com/prometheus/common v0.67.5 // indirect
github.com/prometheus/common v0.67.4 // indirect
github.com/prometheus/otlptranslator v1.0.0 // indirect
github.com/prometheus/procfs v0.19.2 // indirect
github.com/quic-go/qpack v0.6.0 // indirect
github.com/quic-go/quic-go v0.59.0 // indirect
github.com/quic-go/quic-go v0.57.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/robfig/cron/v3 v3.0.1 // indirect
github.com/segmentio/asm v1.2.1 // indirect
github.com/spf13/pflag v1.0.10 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.3.1 // indirect
github.com/valyala/fastjson v1.6.10 // indirect
github.com/valyala/fastjson v1.6.4 // indirect
github.com/x448/float16 v0.8.4 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/contrib/bridges/prometheus v0.65.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.16.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.16.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.40.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.40.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.40.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.40.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.40.0 // indirect
go.opentelemetry.io/otel/exporters/prometheus v0.62.0 // indirect
go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.16.0 // indirect
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.40.0 // indirect
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.40.0 // indirect
go.opentelemetry.io/contrib/bridges/prometheus v0.63.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.14.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.14.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.38.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.38.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.38.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.38.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.38.0 // indirect
go.opentelemetry.io/otel/exporters/prometheus v0.60.0 // indirect
go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.14.0 // indirect
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.38.0 // indirect
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.38.0 // indirect
go.opentelemetry.io/proto/otlp v1.9.0 // indirect
go.yaml.in/yaml/v2 v2.4.3 // indirect
golang.org/x/arch v0.24.0 // indirect
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa // indirect
golang.org/x/oauth2 v0.35.0 // indirect
golang.org/x/sys v0.41.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20260217215200-42d3e9bedb6d // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d // indirect
google.golang.org/grpc v1.79.3 // indirect
google.golang.org/protobuf v1.36.11 // indirect
golang.org/x/arch v0.23.0 // indirect
golang.org/x/exp v0.0.0-20251125195548-87e1e737ad39 // indirect
golang.org/x/net v0.47.0 // indirect
golang.org/x/oauth2 v0.33.0 // indirect
golang.org/x/sys v0.38.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20251124214823-79d6a2a48846 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20251124214823-79d6a2a48846 // indirect
google.golang.org/grpc v1.77.0 // indirect
google.golang.org/protobuf v1.36.10 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
modernc.org/libc v1.68.0 // indirect
modernc.org/libc v1.67.1 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
modernc.org/sqlite v1.46.1 // indirect
modernc.org/sqlite v1.40.1 // indirect
)

View File

@@ -6,55 +6,52 @@ 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.1 h1:ABlyEARCDLN034NhxlRUSZr4l71mh+T5KAeGh6cerhU=
github.com/aws/aws-sdk-go-v2 v1.41.1/go.mod h1:MayyLB8y+buD9hZqkCW3kX1AKq07Y5pXxtgB+rRFhz0=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4 h1:489krEF9xIGkOaaX3CE/Be2uWjiXrkCH6gUX+bZA/BU=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4/go.mod h1:IOAPF6oT9KCsceNTvvYMNHy0+kMF8akOjeDvPENWxp4=
github.com/aws/aws-sdk-go-v2/config v1.32.9 h1:ktda/mtAydeObvJXlHzyGpK1xcsLaP16zfUPDGoW90A=
github.com/aws/aws-sdk-go-v2/config v1.32.9/go.mod h1:U+fCQ+9QKsLW786BCfEjYRj34VVTbPdsLP3CHSYXMOI=
github.com/aws/aws-sdk-go-v2/credentials v1.19.9 h1:sWvTKsyrMlJGEuj/WgrwilpoJ6Xa1+KhIpGdzw7mMU8=
github.com/aws/aws-sdk-go-v2/credentials v1.19.9/go.mod h1:+J44MBhmfVY/lETFiKI+klz0Vym2aCmIjqgClMmW82w=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.17 h1:I0GyV8wiYrP8XpA70g1HBcQO1JlQxCMTW9npl5UbDHY=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.17/go.mod h1:tyw7BOl5bBe/oqvoIeECFJjMdzXoa/dfVz3QQ5lgHGA=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.17 h1:xOLELNKGp2vsiteLsvLPwxC+mYmO6OZ8PYgiuPJzF8U=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.17/go.mod h1:5M5CI3D12dNOtH3/mk6minaRwI2/37ifCURZISxA/IQ=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.17 h1:WWLqlh79iO48yLkj1v3ISRNiv+3KdQoZ6JWyfcsyQik=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.17/go.mod h1:EhG22vHRrvF8oXSTYStZhJc1aUgKtnJe+aOiFEV90cM=
github.com/aws/aws-sdk-go-v2 v1.40.0 h1:/WMUA0kjhZExjOQN2z3oLALDREea1A7TobfuiBrKlwc=
github.com/aws/aws-sdk-go-v2 v1.40.0/go.mod h1:c9pm7VwuW0UPxAEYGyTmyurVcNrbF6Rt/wixFqDhcjE=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.3 h1:DHctwEM8P8iTXFxC/QK0MRjwEpWQeM9yzidCRjldUz0=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.3/go.mod h1:xdCzcZEtnSTKVDOmUZs4l/j3pSV6rpo1WXl5ugNsL8Y=
github.com/aws/aws-sdk-go-v2/config v1.32.2 h1:4liUsdEpUUPZs5WVapsJLx5NPmQhQdez7nYFcovrytk=
github.com/aws/aws-sdk-go-v2/config v1.32.2/go.mod h1:l0hs06IFz1eCT+jTacU/qZtC33nvcnLADAPL/XyrkZI=
github.com/aws/aws-sdk-go-v2/credentials v1.19.2 h1:qZry8VUyTK4VIo5aEdUcBjPZHL2v4FyQ3QEOaWcFLu4=
github.com/aws/aws-sdk-go-v2/credentials v1.19.2/go.mod h1:YUqm5a1/kBnoK+/NY5WEiMocZihKSo15/tJdmdXnM5g=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.14 h1:WZVR5DbDgxzA0BJeudId89Kmgy6DIU4ORpxwsVHz0qA=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.14/go.mod h1:Dadl9QO0kHgbrH1GRqGiZdYtW5w+IXXaBNCHTIaheM4=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.14 h1:PZHqQACxYb8mYgms4RZbhZG0a7dPW06xOjmaH0EJC/I=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.14/go.mod h1:VymhrMJUWs69D8u0/lZ7jSB6WgaG/NqHi3gX0aYf6U0=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.14 h1:bOS19y6zlJwagBfHxs0ESzr1XCOU2KXJCWcq3E2vfjY=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.14/go.mod h1:1ipeGBMAxZ0xcTm6y6paC2C/J6f6OO7LBODV9afuAyM=
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 h1:WKuaxf++XKWlHWu9ECbMlha8WOEGm0OUEZqm4K/Gcfk=
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4/go.mod h1:ZWy7j6v1vWGmPReu0iSGvRiise4YI5SkR3OHKTZ6Wuc=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.17 h1:JqcdRG//czea7Ppjb+g/n4o8i/R50aTBHkA7vu0lK+k=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.17/go.mod h1:CO+WeGmIdj/MlPel2KwID9Gt7CNq4M65HUfBW97liM0=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4 h1:0ryTNEdJbzUCEWkVXEXoqlXV72J5keC1GvILMOuD00E=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4/go.mod h1:HQ4qwNZh32C3CBeO6iJLQlgtMzqeG17ziAA/3KDJFow=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.8 h1:Z5EiPIzXKewUQK0QTMkutjiaPVeVYXX7KIqhXu/0fXs=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.8/go.mod h1:FsTpJtvC4U1fyDXk7c71XoDv3HlRm8V3NiYLeYLh5YE=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.17 h1:RuNSMoozM8oXlgLG/n6WLaFGoea7/CddrCfIiSA+xdY=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.17/go.mod h1:F2xxQ9TZz5gDWsclCtPQscGpP0VUOc8RqgFM3vDENmU=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.17 h1:bGeHBsGZx0Dvu/eJC0Lh9adJa3M1xREcndxLNZlve2U=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.17/go.mod h1:dcW24lbU0CzHusTE8LLHhRLI42ejmINN8Lcr22bwh/g=
github.com/aws/aws-sdk-go-v2/service/s3 v1.96.0 h1:oeu8VPlOre74lBA/PMhxa5vewaMIMmILM+RraSyB8KA=
github.com/aws/aws-sdk-go-v2/service/s3 v1.96.0/go.mod h1:5jggDlZ2CLQhwJBiZJb4vfk4f0GxWdEDruWKEJ1xOdo=
github.com/aws/aws-sdk-go-v2/service/signin v1.0.5 h1:VrhDvQib/i0lxvr3zqlUwLwJP4fpmpyD9wYG1vfSu+Y=
github.com/aws/aws-sdk-go-v2/service/signin v1.0.5/go.mod h1:k029+U8SY30/3/ras4G/Fnv/b88N4mAfliNn08Dem4M=
github.com/aws/aws-sdk-go-v2/service/sso v1.30.10 h1:+VTRawC4iVY58pS/lzpo0lnoa/SYNGF4/B/3/U5ro8Y=
github.com/aws/aws-sdk-go-v2/service/sso v1.30.10/go.mod h1:yifAsgBxgJWn3ggx70A3urX2AN49Y5sJTD1UQFlfqBw=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.14 h1:0jbJeuEHlwKJ9PfXtpSFc4MF+WIWORdhN1n30ITZGFM=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.14/go.mod h1:sTGThjphYE4Ohw8vJiRStAcu3rbjtXRsdNB0TvZ5wwo=
github.com/aws/aws-sdk-go-v2/service/sts v1.41.6 h1:5fFjR/ToSOzB2OQ/XqWpZBmNvmP/pJ1jOWYlFDJTjRQ=
github.com/aws/aws-sdk-go-v2/service/sts v1.41.6/go.mod h1:qgFDZQSD/Kys7nJnVqYlWKnh0SSdMjAi0uSwON4wgYQ=
github.com/aws/smithy-go v1.24.1 h1:VbyeNfmYkWoxMVpGUAbQumkODcYmfMRfZ8yQiH30SK0=
github.com/aws/smithy-go v1.24.1/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.14 h1:ITi7qiDSv/mSGDSWNpZ4k4Ve0DQR6Ug2SJQ8zEHoDXg=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.14/go.mod h1:k1xtME53H1b6YpZt74YmwlONMWf4ecM+lut1WQLAF/U=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.3 h1:x2Ibm/Af8Fi+BH+Hsn9TXGdT+hKbDd5XOTZxTMxDk7o=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.3/go.mod h1:IW1jwyrQgMdhisceG8fQLmQIydcT/jWY21rFhzgaKwo=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.5 h1:Hjkh7kE6D81PgrHlE/m9gx+4TyyeLHuY8xJs7yXN5C4=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.5/go.mod h1:nPRXgyCfAurhyaTMoBMwRBYBhaHI4lNPAnJmjM0Tslc=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.14 h1:FIouAnCE46kyYqyhs0XEBDFFSREtdnr8HQuLPQPLCrY=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.14/go.mod h1:UTwDc5COa5+guonQU8qBikJo1ZJ4ln2r1MkF7Dqag1E=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.14 h1:FzQE21lNtUor0Fb7QNgnEyiRCBlolLTX/Z1j65S7teM=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.14/go.mod h1:s1ydyWG9pm3ZwmmYN21HKyG9WzAZhYVW85wMHs5FV6w=
github.com/aws/aws-sdk-go-v2/service/s3 v1.92.1 h1:OgQy/+0+Kc3khtqiEOk23xQAglXi3Tj0y5doOxbi5tg=
github.com/aws/aws-sdk-go-v2/service/s3 v1.92.1/go.mod h1:wYNqY3L02Z3IgRYxOBPH9I1zD9Cjh9hI5QOy/eOjQvw=
github.com/aws/aws-sdk-go-v2/service/signin v1.0.2 h1:MxMBdKTYBjPQChlJhi4qlEueqB1p1KcbTEa7tD5aqPs=
github.com/aws/aws-sdk-go-v2/service/signin v1.0.2/go.mod h1:iS6EPmNeqCsGo+xQmXv0jIMjyYtQfnwg36zl2FwEouk=
github.com/aws/aws-sdk-go-v2/service/sso v1.30.5 h1:ksUT5KtgpZd3SAiFJNJ0AFEJVva3gjBmN7eXUZjzUwQ=
github.com/aws/aws-sdk-go-v2/service/sso v1.30.5/go.mod h1:av+ArJpoYf3pgyrj6tcehSFW+y9/QvAY8kMooR9bZCw=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.10 h1:GtsxyiF3Nd3JahRBJbxLCCdYW9ltGQYrFWg8XdkGDd8=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.10/go.mod h1:/j67Z5XBVDx8nZVp9EuFM9/BS5dvBznbqILGuu73hug=
github.com/aws/aws-sdk-go-v2/service/sts v1.41.2 h1:a5UTtD4mHBU3t0o6aHQZFJTNKVfxFWfPX7J0Lr7G+uY=
github.com/aws/aws-sdk-go-v2/service/sts v1.41.2/go.mod h1:6TxbXoDSgBQ225Qd8Q+MbxUxUh6TtNKwbRt/EPS9xso=
github.com/aws/smithy-go v1.23.2 h1:Crv0eatJUQhaManss33hS5r40CG3ZFH+21XSkqMrIUM=
github.com/aws/smithy-go v1.23.2/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/bits-and-blooms/bitset v1.13.0/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8=
github.com/bits-and-blooms/bitset v1.14.3 h1:Gd2c8lSNf9pKXom5JtD7AaKO8o7fGQ2LtFj1436qilA=
github.com/bits-and-blooms/bitset v1.14.3/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8=
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE=
github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k=
github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE=
github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
github.com/bytedance/sonic v1.14.2 h1:k1twIoe97C1DtYUo+fZQy865IuHia4PR5RPiuGPPIIE=
github.com/bytedance/sonic v1.14.2/go.mod h1:T80iDELeHiHKSc0C9tubFygiuXoGzrkjKzX2quAx980=
github.com/bytedance/sonic/loader v0.4.0 h1:olZ7lEqcxtZygCK9EKYKADnpQoYkRQxaeY2NYzevs+o=
github.com/bytedance/sonic/loader v0.4.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
github.com/caarlos0/env/v11 v11.3.1 h1:cArPWC15hWmEt+gWk7YBi7lEXTXCvpaSdCiZE2X5mCA=
github.com/caarlos0/env/v11 v11.3.1/go.mod h1:qupehSf/Y0TUTsxKywqRt/vJjN5nz6vauiYEUUr8P4U=
github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM=
@@ -69,9 +66,8 @@ github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151X
github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk=
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 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 h1:NMZiJj8QnKe1LgsbDayM4UoHwbvwDRwnI3hwNaAHRnc=
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40=
github.com/dhui/dktest v0.4.6 h1:+DPKyScKSEp3VLtbMDHcUq6V5Lm5zfZZVb0Sk7Ahom4=
@@ -91,8 +87,6 @@ github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj
github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc=
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
github.com/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=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 h1:oP4q0fw+fOSWn3DfFi4EXdT+B+gTtzx8GC9xsc26Znk=
@@ -103,8 +97,8 @@ github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM=
github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM=
github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
github.com/gabriel-vasile/mimetype v1.4.11 h1:AQvxbp830wPhHTqc1u7nzoLT+ZFxGY7emj5DR5DYFik=
github.com/gabriel-vasile/mimetype v1.4.11/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
github.com/gin-contrib/slog v1.2.0 h1:vAxZfr7knD1ZYK5+pMJLP52sZXIkJXkcRPa/0dx9hSk=
github.com/gin-contrib/slog v1.2.0/go.mod h1:vYK6YltmpsEFkO0zfRMLTKHrWS3DwUSn0TMpT+kMagI=
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
@@ -117,8 +111,8 @@ 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.19.1 h1:B4iLeA0NB/2iO3EKQ7NfKn5KsQgZfjb2fkvoZJU3yBI=
github.com/go-co-op/gocron/v2 v2.19.1/go.mod h1:5lEiCKk1oVJV39Zg7/YG10OnaVrDAV5GGR6O0663k6U=
github.com/go-co-op/gocron/v2 v2.18.1 h1:VVxgAghLW1Q6VHi/rc+B0ZSpFoUVlWgkw09Yximvn58=
github.com/go-co-op/gocron/v2 v2.18.1/go.mod h1:Zii6he+Zfgy5W9B+JKk/KwejFOW0kZTFvHtwIpR4aBI=
github.com/go-ldap/ldap/v3 v3.4.12 h1:1b81mv7MagXZ7+1r7cLTWmyuTqVqdwbtJSjC0DAp9s4=
github.com/go-ldap/ldap/v3 v3.4.12/go.mod h1:+SPAGcTtOfmGsCb3h1RFiq4xpp4N636G75OEace8lNo=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
@@ -132,47 +126,50 @@ github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/o
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w=
github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM=
github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro=
github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/go-playground/validator/v10 v10.28.0 h1:Q7ibns33JjyW48gHkuFT91qX48KG0ktULL6FgHdG688=
github.com/go-playground/validator/v10 v10.28.0/go.mod h1:GoI6I1SjPBh9p7ykNE/yj3fFYbyDOpwMn5KXd+m2hUU=
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/go-webauthn/webauthn v0.15.0 h1:LR1vPv62E0/6+sTenX35QrCmpMCzLeVAcnXeH4MrbJY=
github.com/go-webauthn/webauthn v0.15.0/go.mod h1:hcAOhVChPRG7oqG7Xj6XKN1mb+8eXTGP/B7zBLzkX5A=
github.com/go-webauthn/x v0.2.1 h1:/oB8i0FhSANuoN+YJF5XHMtppa7zGEYaQrrf6ytotjc=
github.com/go-webauthn/x v0.2.1/go.mod h1:Wm0X0zXkzznit4gHj4m82GiBZRMEm+TDUIoJWIQLsE4=
github.com/go-webauthn/x v0.1.26 h1:eNzreFKnwNLDFoywGh9FA8YOMebBWTUNlNSdolQRebs=
github.com/go-webauthn/x v0.1.26/go.mod h1:jmf/phPV6oIsF6hmdVre+ovHkxjDOmNH0t6fekWUxvg=
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw=
github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
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-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/golang-migrate/migrate/v4 v4.19.0 h1:RcjOnCGz3Or6HQYEJ/EEVLfWnmw9KnoigPSjzhCuaSE=
github.com/golang-migrate/migrate/v4 v4.19.0/go.mod h1:9dyEcu+hO+G9hPSw8AIg50yg622pXJsoHItQnDGZkI0=
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=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/go-github/v39 v39.2.0 h1:rNNM311XtPOz5rDdsJXAp2o8F67X9FnROXTvto3aSnQ=
github.com/google/go-github/v39 v39.2.0/go.mod h1:C1s8C5aCC9L+JXIYpJM5GYytdX52vC1bLvHEF1IhBrE=
github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8=
github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU=
github.com/google/go-querystring v1.2.0 h1:yhqkPbu2/OH+V9BfpCVPZkNmUXhb2gBxJArfhIxNtP0=
github.com/google/go-querystring v1.2.0/go.mod h1:8IFJqpSRITyJ8QhQ13bmbeMBDfmeEJZD5A0egEOmkqU=
github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo=
github.com/google/go-tpm v0.9.8/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY=
github.com/google/go-tpm v0.9.7 h1:u89J4tUUeDTlH8xxC3CTW7OHZjbjKoHdQ9W7gCUhtxA=
github.com/google/go-tpm v0.9.7/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
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/grpc-ecosystem/grpc-gateway/v2 v2.27.3 h1:NmZ1PKzSTQbuGHw9DGPFomqkkLWMC+vZCkfs+FHv1Vg=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3/go.mod h1:zQrxl1YP88HQlA6i9c63DSVPFklWpGX4OWAc9bFuaH4=
github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I=
github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo=
github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM=
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=
@@ -183,8 +180,8 @@ github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsI
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.8.0 h1:TYPDoleBBme0xGSAX3/+NujXXtpZn9HBONkQC7IEZSo=
github.com/jackc/pgx/v5 v5.8.0/go.mod h1:QVeDInX2m9VyzvNeiCJVjCkNFqzsNb43204HshNSZKw=
github.com/jackc/pgx/v5 v5.7.6 h1:rWQc5FwZSPX58r1OQmkuaNicxdmExaEz5A2DO2hUuTk=
github.com/jackc/pgx/v5 v5.7.6/go.mod h1:aruU7o91Tc2q2cFp5h4uP3f6ztExVpyVv88Xl/8Vl8M=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/jcmturner/aescts/v2 v2.0.0 h1:9YKLH6ey7H4eDBXW8khjYslgyqG2xZikXP0EQFKrle8=
@@ -231,20 +228,22 @@ github.com/lestrrat-go/dsig-secp256k1 v1.0.0 h1:JpDe4Aybfl0soBvoVwjqDbp+9S1Y2OM7
github.com/lestrrat-go/dsig-secp256k1 v1.0.0/go.mod h1:CxUgAhssb8FToqbL8NjSPoGQlnO4w3LG1P0qPWQm/NU=
github.com/lestrrat-go/httpcc v1.0.1 h1:ydWCStUeJLkpYyjLDHihupbn2tYmZ7m22BGkcvZZrIE=
github.com/lestrrat-go/httpcc v1.0.1/go.mod h1:qiltp3Mt56+55GPVCbTdM9MlqhvzyuL6W/NMDA8vA5E=
github.com/lestrrat-go/httprc/v3 v3.0.4 h1:pXyH2ppK8GYYggygxJ3TvxpCZnbEUWc9qSwRTTApaLA=
github.com/lestrrat-go/httprc/v3 v3.0.4/go.mod h1:mSMtkZW92Z98M5YoNNztbRGxbXHql7tSitCvaxvo9l0=
github.com/lestrrat-go/jwx/v3 v3.0.13 h1:AdHKiPIYeCSnOJtvdpipPg/0SuFh9rdkN+HF3O0VdSk=
github.com/lestrrat-go/jwx/v3 v3.0.13/go.mod h1:2m0PV1A9tM4b/jVLMx8rh6rBl7F6WGb3EG2hufN9OQU=
github.com/lestrrat-go/httprc/v3 v3.0.1 h1:3n7Es68YYGZb2Jf+k//llA4FTZMl3yCwIjFIk4ubevI=
github.com/lestrrat-go/httprc/v3 v3.0.1/go.mod h1:2uAvmbXE4Xq8kAUjVrZOq1tZVYYYs5iP62Cmtru00xk=
github.com/lestrrat-go/jwx/v3 v3.0.12 h1:p25r68Y4KrbBdYjIsQweYxq794CtGCzcrc5dGzJIRjg=
github.com/lestrrat-go/jwx/v3 v3.0.12/go.mod h1:HiUSaNmMLXgZ08OmGBaPVvoZQgJVOQphSrGr5zMamS8=
github.com/lestrrat-go/option v1.0.1 h1:oAzP2fvZGQKWkvHa1/SAcFolBEca1oN+mQ7eooNBEYU=
github.com/lestrrat-go/option v1.0.1/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I=
github.com/lestrrat-go/option/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.11.2 h1:x6gxUeu39V0BHZiugWe8LXZYZ+Utk7hSJGThs8sdzfs=
github.com/lib/pq v1.11.2/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA=
github.com/lmittmann/tint v1.1.3 h1:Hv4EaHWXQr+GTFnOU4VKf8UvAtZgn0VuKT+G0wFlO3I=
github.com/lmittmann/tint v1.1.3/go.mod h1:HIS3gSy7qNwGCj+5oRjAutErFBl4BzdQP6cJZ0NfMwE=
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/lmittmann/tint v1.1.2 h1:2CQzrL6rslrsyjqLDwD11bZ5OpLBPU+g3G/r5LSfS8w=
github.com/lmittmann/tint v1.1.2/go.mod h1:HIS3gSy7qNwGCj+5oRjAutErFBl4BzdQP6cJZ0NfMwE=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-sqlite3 v1.14.34 h1:3NtcvcUnFBPsuRcno8pUtupspG/GM+9nZ88zgJcp6Zk=
github.com/mattn/go-sqlite3 v1.14.34/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/mattn/go-sqlite3 v1.14.32 h1:JD12Ag3oLy1zQA+BNn74xRgaBbdhbNIDYvQUEuuErjs=
github.com/mattn/go-sqlite3 v1.14.32/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/mileusna/useragent v1.3.5 h1:SJM5NzBmh/hO+4LGeATKpaEX9+b4vcGg2qXGLiNGDws=
github.com/mileusna/useragent v1.3.5/go.mod h1:3d8TOmwL/5I8pJjyVDteHtgDGcefrFUX4ccGOMKNYYc=
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
@@ -262,37 +261,34 @@ github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/nlnwa/whatwg-url v0.5.0 h1:l71cqfqG44+VCQZQX3wD4bwheFWicPxuwaCimLEfpDo=
github.com/nlnwa/whatwg-url v0.5.0/go.mod h1:X/ejnFFVbaOWdSul+cnlsSHviCzGZJdvPkgc9zD8IY8=
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug=
github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM=
github.com/orandin/slog-gorm v1.4.0 h1:FgA8hJufF9/jeNSYoEXmHPPBwET2gwlF3B85JdpsTUU=
github.com/orandin/slog-gorm v1.4.0/go.mod h1:MoZ51+b7xE9lwGNPYEhxcUtRNrYzjdcKvA8QXQQGEPA=
github.com/oschwald/maxminddb-golang/v2 v2.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.1.0 h1:2Iv7lmG9XtxuZA/jFAsd7LnZaC1E59pFsj5O/nU15pw=
github.com/oschwald/maxminddb-golang/v2 v2.1.0/go.mod h1:gG4V88LsawPEqtbL1Veh1WRh+nVSYwXzJ1P5Fcn77g0=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
github.com/prometheus/common v0.67.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTUGI4=
github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw=
github.com/prometheus/common v0.67.4 h1:yR3NqWO1/UyO1w2PhUvXlGQs/PtFmoveVO0KZ4+Lvsc=
github.com/prometheus/common v0.67.4/go.mod h1:gP0fq6YjjNCLssJCQp0yk4M8W6ikLURwkdd/YKtTbyI=
github.com/prometheus/otlptranslator v1.0.0 h1:s0LJW/iN9dkIH+EnhiD3BlkkP5QVIUVEoIwkU+A6qos=
github.com/prometheus/otlptranslator v1.0.0/go.mod h1:vRYWnXvI6aWGpsdY/mOT/cbeVRBlPWtBNDb7kGR3uKM=
github.com/prometheus/procfs v0.19.2 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4Vws=
github.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw=
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw=
github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
github.com/quic-go/quic-go v0.57.0 h1:AsSSrrMs4qI/hLrKlTH/TGQeTMY0ib1pAOX7vA3AdqE=
github.com/quic-go/quic-go v0.57.0/go.mod h1:ly4QBAjHA2VhdnxhojRsCUOeJwKYg+taDlos92xb1+s=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
@@ -302,8 +298,8 @@ github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/segmentio/asm v1.2.1 h1:DTNbBqs57ioxAD4PrArqftgypG4/qNpXoJx8TVXxPR0=
github.com/segmentio/asm v1.2.1/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs=
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
github.com/spf13/cobra v1.10.1 h1:lJeBwCfmrnXthfAupyUTzJ/J4Nc1RsHC/mSRU2dll/s=
github.com/spf13/cobra v1.10.1/go.mod h1:7SmJGaTHFVBY0jW4NXGluQoLvhqFQM+6XSKD+P4XaB0=
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
@@ -312,6 +308,7 @@ github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSS
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
@@ -323,63 +320,62 @@ github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS
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=
github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
github.com/valyala/fastjson v1.6.10 h1:/yjJg8jaVQdYR3arGxPE2X5z89xrlhS0eGXdv+ADTh4=
github.com/valyala/fastjson v1.6.10/go.mod h1:e6FubmQouUNP73jtMLmcbxS6ydWIpOfhz34TSfO3JaE=
github.com/valyala/fastjson v1.6.4 h1:uAUNq9Z6ymTgGhcm0UynUAB6tlbakBrz6CQFax3BXVQ=
github.com/valyala/fastjson v1.6.4/go.mod h1:CLCAqky6SMuOcxStkYQvblddUtoRxhYMGLrsQns1aXY=
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=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/contrib/bridges/otelslog v0.15.0 h1:yOYhGNPZseueTTvWp5iBD3/CthrmvayUXYEX862dDi4=
go.opentelemetry.io/contrib/bridges/otelslog v0.15.0/go.mod h1:CvaNVqIfcybc+7xqZNubbE+26K6P7AKZF/l0lE2kdCk=
go.opentelemetry.io/contrib/bridges/prometheus v0.65.0 h1:I/7S/yWobR3QHFLqHsJ8QOndoiFsj1VgHpQiq43KlUI=
go.opentelemetry.io/contrib/bridges/prometheus v0.65.0/go.mod h1:jPF6gn3y1E+nozCAEQj3c6NZ8KY+tvAgSVfvoOJUFac=
go.opentelemetry.io/contrib/exporters/autoexport v0.65.0 h1:2gApdml7SznX9szEKFjKjM4qGcGSvAybYLBY319XG3g=
go.opentelemetry.io/contrib/exporters/autoexport v0.65.0/go.mod h1:0QqAGlbHXhmPYACG3n5hNzO5DnEqqtg4VcK5pr22RI0=
go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin v0.65.0 h1:LSJsvNqhj2sBNFb5NWHbyDK4QJ/skQ2ydjeOZ9OYNZ4=
go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin v0.65.0/go.mod h1:0Q5ocj6h/+C6KYq8cnl4tDFVd4I1HBdsJ440aeagHos=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0 h1:7iP2uCb7sGddAr30RRS6xjKy7AZ2JtTOPA3oolgVSw8=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0/go.mod h1:c7hN3ddxs/z6q9xwvfLPk+UHlWRQyaeR1LdgfL/66l0=
go.opentelemetry.io/contrib/propagators/b3 v1.40.0 h1:xariChe8OOVF3rNlfzGFgQc61npQmXhzZj/i82mxMfg=
go.opentelemetry.io/contrib/propagators/b3 v1.40.0/go.mod h1:72WvbdxbOfXaELEQfonFfOL6osvcVjI7uJEE8C2nkrs=
go.opentelemetry.io/otel v1.40.0 h1:oA5YeOcpRTXq6NN7frwmwFR0Cn3RhTVZvXsP4duvCms=
go.opentelemetry.io/otel v1.40.0/go.mod h1:IMb+uXZUKkMXdPddhwAHm6UfOwJyh4ct1ybIlV14J0g=
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.16.0 h1:ZVg+kCXxd9LtAaQNKBxAvJ5NpMf7LpvEr4MIZqb0TMQ=
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.16.0/go.mod h1:hh0tMeZ75CCXrHd9OXRYxTlCAdxcXioWHFIpYw2rZu8=
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.16.0 h1:djrxvDxAe44mJUrKataUbOhCKhR3F8QCyWucO16hTQs=
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.16.0/go.mod h1:dt3nxpQEiSoKvfTVxp3TUg5fHPLhKtbcnN3Z1I1ePD0=
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.40.0 h1:NOyNnS19BF2SUDApbOKbDtWZ0IK7b8FJ2uAGdIWOGb0=
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.40.0/go.mod h1:VL6EgVikRLcJa9ftukrHu/ZkkhFBSo1lzvdBC9CF1ss=
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.40.0 h1:9y5sHvAxWzft1WQ4BwqcvA+IFVUJ1Ya75mSAUnFEVwE=
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.40.0/go.mod h1:eQqT90eR3X5Dbs1g9YSM30RavwLF725Ris5/XSXWvqE=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.40.0 h1:QKdN8ly8zEMrByybbQgv8cWBcdAarwmIPZ6FThrWXJs=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.40.0/go.mod h1:bTdK1nhqF76qiPoCCdyFIV+N/sRHYXYCTQc+3VCi3MI=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.40.0 h1:DvJDOPmSWQHWywQS6lKL+pb8s3gBLOZUtw4N+mavW1I=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.40.0/go.mod h1:EtekO9DEJb4/jRyN4v4Qjc2yA7AtfCBuz2FynRUWTXs=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.40.0 h1:wVZXIWjQSeSmMoxF74LzAnpVQOAFDo3pPji9Y4SOFKc=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.40.0/go.mod h1:khvBS2IggMFNwZK/6lEeHg/W57h/IX6J4URh57fuI40=
go.opentelemetry.io/otel/exporters/prometheus v0.62.0 h1:krvC4JMfIOVdEuNPTtQ0ZjCiXrybhv+uOHMfHRmnvVo=
go.opentelemetry.io/otel/exporters/prometheus v0.62.0/go.mod h1:fgOE6FM/swEnsVQCqCnbOfRV4tOnWPg7bVeo4izBuhQ=
go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.16.0 h1:ivlbaajBWJqhcCPniDqDJmRwj4lc6sRT+dCAVKNmxlQ=
go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.16.0/go.mod h1:u/G56dEKDDwXNCVLsbSrllB2o8pbtFLUC4HpR66r2dc=
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.40.0 h1:ZrPRak/kS4xI3AVXy8F7pipuDXmDsrO8Lg+yQjBLjw0=
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.40.0/go.mod h1:3y6kQCWztq6hyW8Z9YxQDDm0Je9AJoFar2G0yDcmhRk=
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.40.0 h1:MzfofMZN8ulNqobCmCAVbqVL5syHw+eB2qPRkCMA/fQ=
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.40.0/go.mod h1:E73G9UFtKRXrxhBsHtG00TB5WxX57lpsQzogDkqBTz8=
go.opentelemetry.io/otel/log v0.16.0 h1:DeuBPqCi6pQwtCK0pO4fvMB5eBq6sNxEnuTs88pjsN4=
go.opentelemetry.io/otel/log v0.16.0/go.mod h1:rWsmqNVTLIA8UnwYVOItjyEZDbKIkMxdQunsIhpUMes=
go.opentelemetry.io/otel/metric v1.40.0 h1:rcZe317KPftE2rstWIBitCdVp89A2HqjkxR3c11+p9g=
go.opentelemetry.io/otel/metric v1.40.0/go.mod h1:ib/crwQH7N3r5kfiBZQbwrTge743UDc7DTFVZrrXnqc=
go.opentelemetry.io/otel/sdk v1.40.0 h1:KHW/jUzgo6wsPh9At46+h4upjtccTmuZCFAc9OJ71f8=
go.opentelemetry.io/otel/sdk v1.40.0/go.mod h1:Ph7EFdYvxq72Y8Li9q8KebuYUr2KoeyHx0DRMKrYBUE=
go.opentelemetry.io/otel/sdk/log v0.16.0 h1:e/b4bdlQwC5fnGtG3dlXUrNOnP7c8YLVSpSfEBIkTnI=
go.opentelemetry.io/otel/sdk/log v0.16.0/go.mod h1:JKfP3T6ycy7QEuv3Hj8oKDy7KItrEkus8XJE6EoSzw4=
go.opentelemetry.io/otel/sdk/log/logtest v0.16.0 h1:/XVkpZ41rVRTP4DfMgYv1nEtNmf65XPPyAdqV90TMy4=
go.opentelemetry.io/otel/sdk/log/logtest v0.16.0/go.mod h1:iOOPgQr5MY9oac/F5W86mXdeyWZGleIx3uXO98X2R6Y=
go.opentelemetry.io/otel/sdk/metric v1.40.0 h1:mtmdVqgQkeRxHgRv4qhyJduP3fYJRMX4AtAlbuWdCYw=
go.opentelemetry.io/otel/sdk/metric v1.40.0/go.mod h1:4Z2bGMf0KSK3uRjlczMOeMhKU2rhUqdWNoKcYrtcBPg=
go.opentelemetry.io/otel/trace v1.40.0 h1:WA4etStDttCSYuhwvEa8OP8I5EWu24lkOzp+ZYblVjw=
go.opentelemetry.io/otel/trace v1.40.0/go.mod h1:zeAhriXecNGP/s2SEG3+Y8X9ujcJOTqQ5RgdEJcawiA=
go.opentelemetry.io/contrib/bridges/otelslog v0.13.0 h1:bwnLpizECbPr1RrQ27waeY2SPIPeccCx/xLuoYADZ9s=
go.opentelemetry.io/contrib/bridges/otelslog v0.13.0/go.mod h1:3nWlOiiqA9UtUnrcNk82mYasNxD8ehOspL0gOfEo6Y4=
go.opentelemetry.io/contrib/bridges/prometheus v0.63.0 h1:/Rij/t18Y7rUayNg7Id6rPrEnHgorxYabm2E6wUdPP4=
go.opentelemetry.io/contrib/bridges/prometheus v0.63.0/go.mod h1:AdyDPn6pkbkt2w01n3BubRVk7xAsCRq1Yg1mpfyA/0E=
go.opentelemetry.io/contrib/exporters/autoexport v0.63.0 h1:NLnZybb9KkfMXPwZhd5diBYJoVxiO9Qa06dacEA7ySY=
go.opentelemetry.io/contrib/exporters/autoexport v0.63.0/go.mod h1:OvRg7gm5WRSCtxzGSsrFHbDLToYlStHNZQ+iPNIyD6g=
go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin v0.63.0 h1:5kSIJ0y8ckZZKoDhZHdVtcyjVi6rXyAwyaR8mp4zLbg=
go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin v0.63.0/go.mod h1:i+fIMHvcSQtsIY82/xgiVWRklrNt/O6QriHLjzGeY+s=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0 h1:RbKq8BG0FI8OiXhBfcRtqqHcZcka+gU3cskNuf05R18=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0/go.mod h1:h06DGIukJOevXaj/xrNjhi/2098RZzcLTbc0jDAUbsg=
go.opentelemetry.io/contrib/propagators/b3 v1.38.0 h1:uHsCCOSKl0kLrV2dLkFK+8Ywk9iKa/fptkytc6aFFEo=
go.opentelemetry.io/contrib/propagators/b3 v1.38.0/go.mod h1:wMRSZJZcY8ya9mApLLhwIMjqmApy2o/Ml+62lhvxyHU=
go.opentelemetry.io/otel v1.38.0 h1:RkfdswUDRimDg0m2Az18RKOsnI8UDzppJAtj01/Ymk8=
go.opentelemetry.io/otel v1.38.0/go.mod h1:zcmtmQ1+YmQM9wrNsTGV/q/uyusom3P8RxwExxkZhjM=
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.14.0 h1:OMqPldHt79PqWKOMYIAQs3CxAi7RLgPxwfFSwr4ZxtM=
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.14.0/go.mod h1:1biG4qiqTxKiUCtoWDPpL3fB3KxVwCiGw81j3nKMuHE=
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.14.0 h1:QQqYw3lkrzwVsoEX0w//EhH/TCnpRdEenKBOOEIMjWc=
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.14.0/go.mod h1:gSVQcr17jk2ig4jqJ2DX30IdWH251JcNAecvrqTxH1s=
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.38.0 h1:vl9obrcoWVKp/lwl8tRE33853I8Xru9HFbw/skNeLs8=
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.38.0/go.mod h1:GAXRxmLJcVM3u22IjTg74zWBrRCKq8BnOqUVLodpcpw=
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.38.0 h1:Oe2z/BCg5q7k4iXC3cqJxKYg0ieRiOqF0cecFYdPTwk=
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.38.0/go.mod h1:ZQM5lAJpOsKnYagGg/zV2krVqTtaVdYdDkhMoX6Oalg=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.38.0 h1:GqRJVj7UmLjCVyVJ3ZFLdPRmhDUp2zFmQe3RHIOsw24=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.38.0/go.mod h1:ri3aaHSmCTVYu2AWv44YMauwAQc0aqI9gHKIcSbI1pU=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.38.0 h1:lwI4Dc5leUqENgGuQImwLo4WnuXFPetmPpkLi2IrX54=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.38.0/go.mod h1:Kz/oCE7z5wuyhPxsXDuaPteSWqjSBD5YaSdbxZYGbGk=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.38.0 h1:aTL7F04bJHUlztTsNGJ2l+6he8c+y/b//eR0jjjemT4=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.38.0/go.mod h1:kldtb7jDTeol0l3ewcmd8SDvx3EmIE7lyvqbasU3QC4=
go.opentelemetry.io/otel/exporters/prometheus v0.60.0 h1:cGtQxGvZbnrWdC2GyjZi0PDKVSLWP/Jocix3QWfXtbo=
go.opentelemetry.io/otel/exporters/prometheus v0.60.0/go.mod h1:hkd1EekxNo69PTV4OWFGZcKQiIqg0RfuWExcPKFvepk=
go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.14.0 h1:B/g+qde6Mkzxbry5ZZag0l7QrQBCtVm7lVjaLgmpje8=
go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.14.0/go.mod h1:mOJK8eMmgW6ocDJn6Bn11CcZ05gi3P8GylBXEkZtbgA=
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.38.0 h1:wm/Q0GAAykXv83wzcKzGGqAnnfLFyFe7RslekZuv+VI=
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.38.0/go.mod h1:ra3Pa40+oKjvYh+ZD3EdxFZZB0xdMfuileHAm4nNN7w=
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.38.0 h1:kJxSDN4SgWWTjG/hPp3O7LCGLcHXFlvS2/FFOrwL+SE=
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.38.0/go.mod h1:mgIOzS7iZeKJdeB8/NYHrJ48fdGc71Llo5bJ1J4DWUE=
go.opentelemetry.io/otel/log v0.14.0 h1:2rzJ+pOAZ8qmZ3DDHg73NEKzSZkhkGIua9gXtxNGgrM=
go.opentelemetry.io/otel/log v0.14.0/go.mod h1:5jRG92fEAgx0SU/vFPxmJvhIuDU9E1SUnEQrMlJpOno=
go.opentelemetry.io/otel/metric v1.38.0 h1:Kl6lzIYGAh5M159u9NgiRkmoMKjvbsKtYRwgfrA6WpA=
go.opentelemetry.io/otel/metric v1.38.0/go.mod h1:kB5n/QoRM8YwmUahxvI3bO34eVtQf2i4utNVLr9gEmI=
go.opentelemetry.io/otel/sdk v1.38.0 h1:l48sr5YbNf2hpCUj/FoGhW9yDkl+Ma+LrVl8qaM5b+E=
go.opentelemetry.io/otel/sdk v1.38.0/go.mod h1:ghmNdGlVemJI3+ZB5iDEuk4bWA3GkTpW+DOoZMYBVVg=
go.opentelemetry.io/otel/sdk/log v0.14.0 h1:JU/U3O7N6fsAXj0+CXz21Czg532dW2V4gG1HE/e8Zrg=
go.opentelemetry.io/otel/sdk/log v0.14.0/go.mod h1:imQvII+0ZylXfKU7/wtOND8Hn4OpT3YUoIgqJVksUkM=
go.opentelemetry.io/otel/sdk/log/logtest v0.14.0 h1:Ijbtz+JKXl8T2MngiwqBlPaHqc4YCaP/i13Qrow6gAM=
go.opentelemetry.io/otel/sdk/log/logtest v0.14.0/go.mod h1:dCU8aEL6q+L9cYTqcVOk8rM9Tp8WdnHOPLiBgp0SGOA=
go.opentelemetry.io/otel/sdk/metric v1.38.0 h1:aSH66iL0aZqo//xXzQLYozmWrXxyFkBJ6qT5wthqPoM=
go.opentelemetry.io/otel/sdk/metric v1.38.0/go.mod h1:dg9PBnW9XdQ1Hd6ZnRz689CbtrUp0wMMs9iPcgT9EZA=
go.opentelemetry.io/otel/trace v1.38.0 h1:Fxk5bKrDZJUH+AMyyIXGcFAPah0oRcT+LuNtJrmcNLE=
go.opentelemetry.io/otel/trace v1.38.0/go.mod h1:j1P9ivuFsTceSWe1oY+EeW3sc+Pp42sO++GHkg4wwhs=
go.opentelemetry.io/proto/otlp v1.9.0 h1:l706jCMITVouPOqEnii2fIAuO3IVGBRPV5ICjceRb/A=
go.opentelemetry.io/proto/otlp v1.9.0/go.mod h1:xE+Cx5E/eEHw+ISFkwPLwCZefwVjY+pqKg1qcK03+/4=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
@@ -388,92 +384,57 @@ go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0=
go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/arch v0.24.0 h1:qlJ3M9upxvFfwRM51tTg3Yl+8CP9vCC1E7vlFpgv99Y=
golang.org/x/arch v0.24.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
golang.org/x/arch v0.23.0 h1:lKF64A2jF6Zd8L0knGltUnegD62JMFBiCPBmQpToHhg=
golang.org/x/arch v0.23.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210817164053-32db794688a5/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.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.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa h1:Zt3DZoOFFYkKhDT3v7Lm9FDMEV06GpzjG2jrqW+QTE0=
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa/go.mod h1:K79w1Vqn7PoiZn+TkNpx3BUWUQksGO3JcVX6qIjytmA=
golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
golang.org/x/exp v0.0.0-20251125195548-87e1e737ad39 h1:DHNhtq3sNNzrvduZZIiFyXWOL9IWaDPHqTnLJp+rCBY=
golang.org/x/exp v0.0.0-20251125195548-87e1e737ad39/go.mod h1:46edojNIoXTNOhySWIWdix628clX9ODXwPsQuG6hsK0=
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/image v0.36.0 h1:Iknbfm1afbgtwPTmHnS2gTM/6PPZfH+z2EFuOkSbqwc=
golang.org/x/image v0.36.0/go.mod h1:YsWD2TyyGKiIX1kZlu9QfKIsQ4nAAK9bdgdrIsE7xy4=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
golang.org/x/image v0.33.0 h1:LXRZRnv1+zGd5XBUVRFmYEphyyKJjQjCRiOuAP3sZfQ=
golang.org/x/image v0.33.0/go.mod h1:DD3OsTYT9chzuzTQt+zMcOlBHgfoKQb1gry8p76Y1sc=
golang.org/x/mod v0.30.0 h1:fDEXFVZ/fmCKProc/yAXXUijritrDzahmwwefnjoPFk=
golang.org/x/mod v0.30.0/go.mod h1:lAsf5O2EvJeSFMiBxXDki7sCgAxEUcZHXoXMKT4GJKc=
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-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ=
golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/oauth2 v0.33.0 h1:4Q+qn+E5z8gPRJfmRy7C2gGG3T4jIprK6aSYgTXGRpo=
golang.org/x/oauth2 v0.33.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.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.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/tools v0.39.0 h1:ik4ho21kwuQln40uelmciQPp9SipgNDdrafrYA4TmQQ=
golang.org/x/tools v0.39.0/go.mod h1:JnefbkDPyD8UU2kI5fuf8ZX4/yUeh9W877ZeBONxUqQ=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
google.golang.org/genproto/googleapis/api v0.0.0-20260217215200-42d3e9bedb6d h1:EocjzKLywydp5uZ5tJ79iP6Q0UjDnyiHkGRWxuPBP8s=
google.golang.org/genproto/googleapis/api v0.0.0-20260217215200-42d3e9bedb6d/go.mod h1:48U2I+QQUYhsFrg2SY6r+nJzeOtjey7j//WBESw+qyQ=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d h1:t/LOSXPJ9R0B6fnZNyALBRfZBH0Uy0gT+uR+SJ6syqQ=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE=
google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
google.golang.org/genproto/googleapis/api v0.0.0-20251124214823-79d6a2a48846 h1:ZdyUkS9po3H7G0tuh955QVyyotWvOD4W0aEapeGeUYk=
google.golang.org/genproto/googleapis/api v0.0.0-20251124214823-79d6a2a48846/go.mod h1:Fk4kyraUvqD7i5H6S43sj2W98fbZa75lpZz/eUyhfO0=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251124214823-79d6a2a48846 h1:Wgl1rcDNThT+Zn47YyCXOXyX/COgMTIdhJ717F0l4xk=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251124214823-79d6a2a48846/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
google.golang.org/grpc v1.77.0 h1:wVVY6/8cGA6vvffn+wWK5ToddbgdU3d8MNENr4evgXM=
google.golang.org/grpc v1.77.0/go.mod h1:z0BY1iVj0q8E1uSQCjL9cppRj+gnZjzDnzV0dHhrNig=
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
@@ -486,18 +447,18 @@ gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg=
gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=
modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
modernc.org/ccgo/v4 v4.30.2 h1:4yPaaq9dXYXZ2V8s1UgrC3KIj580l2N4ClrLwnbv2so=
modernc.org/ccgo/v4 v4.30.2/go.mod h1:yZMnhWEdW0qw3EtCndG1+ldRrVGS+bIwyWmAWzS0XEw=
modernc.org/ccgo/v4 v4.30.1 h1:4r4U1J6Fhj98NKfSjnPUN7Ze2c6MnAdL0hWw6+LrJpc=
modernc.org/ccgo/v4 v4.30.1/go.mod h1:bIOeI1JL54Utlxn+LwrFyjCx2n2RDiYEaJVSrgdrRfM=
modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA=
modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo=
modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
modernc.org/gc/v3 v3.1.1 h1:k8T3gkXWY9sEiytKhcgyiZ2L0DTyCQ/nvX+LoCljoRE=
modernc.org/gc/v3 v3.1.1/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.68.0 h1:PJ5ikFOV5pwpW+VqCK1hKJuEWsonkIJhhIXyuF/91pQ=
modernc.org/libc v1.68.0/go.mod h1:NnKCYeoYgsEqnY3PgvNgAeaJnso968ygU8Z0DxjoEc0=
modernc.org/libc v1.67.1 h1:bFaqOaa5/zbWYJo8aW0tXPX21hXsngG2M7mckCnFSVk=
modernc.org/libc v1.67.1/go.mod h1:QvvnnJ5P7aitu0ReNpVIEyesuhmDLQ8kaEoyMjIFZJA=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
@@ -506,8 +467,8 @@ modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.46.1 h1:eFJ2ShBLIEnUWlLy12raN0Z1plqmFX9Qe3rjQTKt6sU=
modernc.org/sqlite v1.46.1/go.mod h1:CzbrU2lSB1DKUusvwGz7rqEKIq+NUd8GWuBBZDs9/nA=
modernc.org/sqlite v1.40.1 h1:VfuXcxcUWWKRBuP8+BR9L7VnmusMgBNNnBYGEe9w/iY=
modernc.org/sqlite v1.40.1/go.mod h1:9fjQZ0mB1LLP0GYrp39oOJXx/I2sxEnZtzCmEQIKvGE=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=

View File

@@ -12,7 +12,6 @@ import (
"log/slog"
"os"
"path"
"strings"
"github.com/pocket-id/pocket-id/backend/internal/storage"
"github.com/pocket-id/pocket-id/backend/internal/utils"
@@ -21,14 +20,11 @@ import (
// initApplicationImages copies the images from the embedded directory to the storage backend
// and returns a map containing the detected file extensions in the application-images directory.
//
//nolint:gocognit
func initApplicationImages(ctx context.Context, fileStorage storage.FileStorage) (map[string]string, error) {
// Previous versions of images
// If these are found, they are deleted
legacyImageHashes := imageHashMap{
"background.jpg": mustDecodeHex("138d510030ed845d1d74de34658acabff562d306476454369a60ab8ade31933f"),
"background.webp": mustDecodeHex("3fc436a66d6b872b01d96a4e75046c46b5c3e2daccd51e98ecdf98fd445599ab"),
"background.jpg": mustDecodeHex("138d510030ed845d1d74de34658acabff562d306476454369a60ab8ade31933f"),
}
sourceFiles, err := resources.FS.ReadDir("images")
@@ -79,18 +75,6 @@ func initApplicationImages(ctx context.Context, fileStorage storage.FileStorage)
dstNameToExt[nameWithoutExt] = ext
}
initedPath := path.Join("application-images", ".inited")
if _, _, err := fileStorage.Open(ctx, initedPath); err == nil {
return dstNameToExt, nil
} else if !os.IsNotExist(err) {
return nil, fmt.Errorf("failed to read .inited: %w", err)
} else {
err := fileStorage.Save(ctx, initedPath, strings.NewReader(""))
if err != nil {
return nil, fmt.Errorf("failed to store .inited: %w", err)
}
}
// Copy images from the images directory to the application-images directory if they don't already exist
for _, sourceFile := range sourceFiles {
if sourceFile.IsDir() {

View File

@@ -2,7 +2,6 @@ package bootstrap
import (
"context"
"errors"
"fmt"
"log/slog"
"time"
@@ -12,7 +11,6 @@ import (
"github.com/pocket-id/pocket-id/backend/internal/common"
"github.com/pocket-id/pocket-id/backend/internal/job"
"github.com/pocket-id/pocket-id/backend/internal/service"
"github.com/pocket-id/pocket-id/backend/internal/storage"
"github.com/pocket-id/pocket-id/backend/internal/utils"
)
@@ -50,21 +48,14 @@ func Bootstrap(ctx context.Context) error {
return fmt.Errorf("failed to initialize application images: %w", err)
}
scheduler, err := job.NewScheduler()
if err != nil {
return fmt.Errorf("failed to create job scheduler: %w", err)
}
// Create all services
svc, err := initServices(ctx, db, httpClient, imageExtensions, fileStorage, scheduler)
svc, err := initServices(ctx, db, httpClient, imageExtensions, fileStorage)
if err != nil {
return fmt.Errorf("failed to initialize services: %w", err)
}
waitUntil, err := svc.appLockService.Acquire(ctx, false)
if errors.Is(err, service.ErrLockUnavailable) {
return errors.New("it appears that there's already one instance of Pocket ID running; running multiple replicas of Pocket ID is currently not supported")
} else if err != nil {
if err != nil {
return fmt.Errorf("failed to acquire application lock: %w", err)
}
@@ -83,7 +74,11 @@ func Bootstrap(ctx context.Context) error {
}
shutdownFns = append(shutdownFns, shutdownFn)
// Register scheduled jobs
// Init the job scheduler
scheduler, err := job.NewScheduler()
if err != nil {
return fmt.Errorf("failed to create job scheduler: %w", err)
}
err = registerScheduledJobs(ctx, db, svc, httpClient, scheduler)
if err != nil {
return fmt.Errorf("failed to register scheduled jobs: %w", err)

View File

@@ -34,8 +34,7 @@ func NewDatabase() (db *gorm.DB, err error) {
}
// Run migrations
err = utils.MigrateDatabase(sqlDb)
if err != nil {
if err := utils.MigrateDatabase(sqlDb); err != nil {
return nil, fmt.Errorf("failed to run migrations: %w", err)
}
@@ -43,10 +42,7 @@ func NewDatabase() (db *gorm.DB, err error) {
}
func ConnectDatabase() (db *gorm.DB, err error) {
var (
dialector gorm.Dialector
sqliteNetworkFilesystem bool
)
var dialector gorm.Dialector
// Choose the correct database provider
var onConnFn func(conn *sql.DB)
@@ -67,14 +63,6 @@ func ConnectDatabase() (db *gorm.DB, err error) {
if err := ensureSqliteDatabaseDir(dbPath); err != nil {
return nil, err
}
sqliteNetworkFilesystem, err = utils.IsNetworkedFileSystem(filepath.Dir(dbPath))
if err != nil {
// Log the error only
slog.Warn("Failed to detect filesystem type for the SQLite database directory", slog.String("path", filepath.Dir(dbPath)), slog.Any("error", err))
} else if sqliteNetworkFilesystem {
slog.Warn("⚠️⚠️⚠️ SQLite databases should not be stored on a networked file system like NFS, SMB, or FUSE, as there's a risk of crashes and even database corruption", slog.String("path", filepath.Dir(dbPath)))
}
}
// Before we connect, also make sure that there's a temporary folder for SQLite to write its data

View File

@@ -118,10 +118,11 @@ func initOtelLogging(ctx context.Context, resource *resource.Resource) error {
// Set the logger provider globally
globallog.SetLoggerProvider(provider)
handler = slog.NewMultiHandler(
// Wrap the handler in a "fanout" one
handler = utils.LogFanoutHandler{
handler,
otelslog.NewHandler(common.Name, otelslog.WithLoggerProvider(provider)),
)
}
// Set the default slog to send logs to OTel and add the app name
log := slog.New(handler).

View File

@@ -15,8 +15,6 @@ import (
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"
@@ -49,14 +47,12 @@ func initRouter(db *gorm.DB, svc *services) (utils.Service, error) {
_ = r.SetTrustedProxies(nil)
}
if common.EnvConfig.TrustedPlatform != "" {
r.TrustedPlatform = common.EnvConfig.TrustedPlatform
}
if common.EnvConfig.TracingEnabled {
r.Use(otelgin.Middleware(common.Name))
}
rateLimitMiddleware := middleware.NewRateLimitMiddleware().Add(rate.Every(time.Second), 60)
// Setup global middleware
r.Use(middleware.HeadMiddleware())
r.Use(middleware.NewCacheControlMiddleware().Add())
@@ -64,8 +60,7 @@ func initRouter(db *gorm.DB, svc *services) (utils.Service, error) {
r.Use(middleware.NewCspMiddleware().Add())
r.Use(middleware.NewErrorHandlerMiddleware().Add())
frontendRateLimitMiddleware := middleware.NewRateLimitMiddleware().Add(rate.Every(100*time.Millisecond), 300)
err := frontend.RegisterFrontend(r, frontendRateLimitMiddleware)
err := frontend.RegisterFrontend(r)
if errors.Is(err, frontend.ErrFrontendNotIncluded) {
slog.Warn("Frontend is not included in the build. Skipping frontend registration.")
} else if err != nil {
@@ -76,22 +71,18 @@ func initRouter(db *gorm.DB, svc *services) (utils.Service, error) {
authMiddleware := middleware.NewAuthMiddleware(svc.apiKeyService, svc.userService, svc.jwtService)
fileSizeLimitMiddleware := middleware.NewFileSizeLimitMiddleware()
apiRateLimitMiddleware := middleware.NewRateLimitMiddleware().Add(rate.Every(time.Second), 100)
// Set up API routes
apiGroup := r.Group("/api", apiRateLimitMiddleware)
apiGroup := r.Group("/api", rateLimitMiddleware)
controller.NewApiKeyController(apiGroup, authMiddleware, svc.apiKeyService)
controller.NewWebauthnController(apiGroup, authMiddleware, middleware.NewRateLimitMiddleware(), svc.webauthnService, svc.appConfigService)
controller.NewOidcController(apiGroup, authMiddleware, fileSizeLimitMiddleware, svc.oidcService, svc.jwtService)
controller.NewUserController(apiGroup, authMiddleware, middleware.NewRateLimitMiddleware(), svc.userService, svc.oneTimeAccessService, svc.appConfigService)
controller.NewUserController(apiGroup, authMiddleware, middleware.NewRateLimitMiddleware(), svc.userService, svc.appConfigService)
controller.NewAppConfigController(apiGroup, authMiddleware, svc.appConfigService, svc.emailService, svc.ldapService)
controller.NewAppImagesController(apiGroup, authMiddleware, svc.appImagesService)
controller.NewAuditLogController(apiGroup, svc.auditLogService, authMiddleware)
controller.NewUserGroupController(apiGroup, authMiddleware, svc.userGroupService)
controller.NewCustomClaimController(apiGroup, authMiddleware, svc.customClaimService)
controller.NewVersionController(apiGroup, authMiddleware, svc.versionService)
controller.NewScimController(apiGroup, authMiddleware, svc.scimService)
controller.NewUserSignupController(apiGroup, authMiddleware, middleware.NewRateLimitMiddleware(), svc.userSignUpService, svc.appConfigService)
controller.NewVersionController(apiGroup, svc.versionService)
// Add test controller in non-production environments
if !common.EnvConfig.AppEnv.IsProduction() {
@@ -101,23 +92,18 @@ func initRouter(db *gorm.DB, svc *services) (utils.Service, error) {
}
// Set up base routes
baseGroup := r.Group("/", apiRateLimitMiddleware)
baseGroup := r.Group("/", rateLimitMiddleware)
controller.NewWellKnownController(baseGroup, svc.jwtService)
// Set up healthcheck routes
// These are not rate-limited
controller.NewHealthzController(r)
var protocols http.Protocols
protocols.SetHTTP1(true)
protocols.SetUnencryptedHTTP2(true)
// Set up the server
srv := &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 {
@@ -127,7 +113,7 @@ func initRouter(db *gorm.DB, svc *services) (utils.Service, error) {
}
r.ServeHTTP(w, req)
}), &http2.Server{}),
}),
}
// Set up the listener
@@ -203,7 +189,6 @@ func initLogger(r *gin.Engine) {
"GET /api/application-images/logo",
"GET /api/application-images/background",
"GET /api/application-images/favicon",
"GET /api/application-images/email",
"GET /_app",
"GET /fonts",
"GET /healthz",

View File

@@ -35,10 +35,6 @@ func registerScheduledJobs(ctx context.Context, db *gorm.DB, svc *services, http
if err != nil {
return fmt.Errorf("failed to register analytics job in scheduler: %w", err)
}
err = scheduler.RegisterScimJobs(ctx, svc.scimService)
if err != nil {
return fmt.Errorf("failed to register SCIM scheduler job: %w", err)
}
return nil
}

View File

@@ -5,7 +5,6 @@ import (
"fmt"
"net/http"
"github.com/pocket-id/pocket-id/backend/internal/job"
"gorm.io/gorm"
"github.com/pocket-id/pocket-id/backend/internal/service"
@@ -13,29 +12,26 @@ 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
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
}
// Initializes all services
func initServices(ctx context.Context, db *gorm.DB, httpClient *http.Client, imageExtensions map[string]string, fileStorage storage.FileStorage, scheduler *job.Scheduler) (svc *services, err error) {
func initServices(ctx context.Context, db *gorm.DB, httpClient *http.Client, imageExtensions map[string]string, fileStorage storage.FileStorage) (svc *services, err error) {
svc = &services{}
svc.appConfigService, err = service.NewAppConfigService(ctx, db)
@@ -54,7 +50,7 @@ func initServices(ctx context.Context, db *gorm.DB, httpClient *http.Client, ima
svc.geoLiteService = service.NewGeoLiteService(httpClient)
svc.auditLogService = service.NewAuditLogService(db, svc.appConfigService, svc.emailService, svc.geoLiteService)
svc.jwtService, err = service.NewJwtService(ctx, db, svc.appConfigService)
svc.jwtService, err = service.NewJwtService(db, svc.appConfigService)
if err != nil {
return nil, fmt.Errorf("failed to create JWT service: %w", err)
}
@@ -65,24 +61,15 @@ func initServices(ctx context.Context, db *gorm.DB, httpClient *http.Client, ima
return nil, fmt.Errorf("failed to create WebAuthn service: %w", err)
}
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.customClaimService, svc.webauthnService, 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.userService = service.NewUserService(db, svc.jwtService, svc.auditLogService, svc.emailService, svc.appConfigService, svc.customClaimService, svc.appImagesService, fileStorage)
svc.ldapService = service.NewLdapService(db, httpClient, svc.appConfigService, svc.userService, svc.userGroupService, fileStorage)
svc.apiKeyService, err = service.NewApiKeyService(ctx, db, svc.emailService)
if err != nil {
return nil, fmt.Errorf("failed to create API key service: %w", err)
}
svc.userSignUpService = service.NewUserSignupService(db, svc.jwtService, svc.auditLogService, svc.appConfigService, svc.userService)
svc.oneTimeAccessService = service.NewOneTimeAccessService(db, svc.userService, svc.jwtService, svc.auditLogService, svc.emailService, svc.appConfigService)
svc.apiKeyService = service.NewApiKeyService(db, svc.emailService)
svc.versionService = service.NewVersionService(httpClient)

View File

@@ -1,187 +0,0 @@
package cmds
import (
"context"
"errors"
"fmt"
"os"
"github.com/pocket-id/pocket-id/backend/internal/model"
"github.com/spf13/cobra"
"gorm.io/gorm"
"github.com/pocket-id/pocket-id/backend/internal/bootstrap"
"github.com/pocket-id/pocket-id/backend/internal/common"
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
"github.com/pocket-id/pocket-id/backend/internal/service"
"github.com/pocket-id/pocket-id/backend/internal/utils"
jwkutils "github.com/pocket-id/pocket-id/backend/internal/utils/jwk"
)
type encryptionKeyRotateFlags struct {
NewKey string
Yes bool
}
func init() {
var flags encryptionKeyRotateFlags
encryptionKeyRotateCmd := &cobra.Command{
Use: "encryption-key-rotate",
Short: "Re-encrypts data using a new encryption key",
RunE: func(cmd *cobra.Command, args []string) error {
db, err := bootstrap.NewDatabase()
if err != nil {
return err
}
return encryptionKeyRotate(cmd.Context(), flags, db, &common.EnvConfig)
},
}
encryptionKeyRotateCmd.Flags().StringVar(&flags.NewKey, "new-key", "", "New encryption key to re-encrypt data with")
encryptionKeyRotateCmd.Flags().BoolVarP(&flags.Yes, "yes", "y", false, "Do not prompt for confirmation")
rootCmd.AddCommand(encryptionKeyRotateCmd)
}
func encryptionKeyRotate(ctx context.Context, flags encryptionKeyRotateFlags, db *gorm.DB, envConfig *common.EnvConfigSchema) error {
oldKey := envConfig.EncryptionKey
newKey := []byte(flags.NewKey)
if len(newKey) == 0 {
return errors.New("new encryption key is required (--new-key)")
}
if len(newKey) < 16 {
return errors.New("new encryption key must be at least 16 bytes long")
}
if !flags.Yes {
fmt.Println("WARNING: Rotating the encryption key will re-encrypt secrets in the database. Pocket-ID must be restarted with the new ENCRYPTION_KEY after rotation is complete.")
ok, err := utils.PromptForConfirmation("Continue")
if err != nil {
return err
}
if !ok {
fmt.Println("Aborted")
os.Exit(1)
}
}
appConfigService, err := service.NewAppConfigService(ctx, db)
if err != nil {
return fmt.Errorf("failed to create app config service: %w", err)
}
instanceID := appConfigService.GetDbConfig().InstanceID.Value
// Derive the encryption keys used for the JWK encryption
oldKek, err := jwkutils.LoadKeyEncryptionKey(&common.EnvConfigSchema{EncryptionKey: oldKey}, instanceID)
if err != nil {
return fmt.Errorf("failed to derive old key encryption key: %w", err)
}
newKek, err := jwkutils.LoadKeyEncryptionKey(&common.EnvConfigSchema{EncryptionKey: newKey}, instanceID)
if err != nil {
return fmt.Errorf("failed to derive new key encryption key: %w", err)
}
// Derive the encryption keys used for EncryptedString fields
oldEncKey, err := datatype.DeriveEncryptedStringKey(oldKey)
if err != nil {
return fmt.Errorf("failed to derive old encrypted string key: %w", err)
}
newEncKey, err := datatype.DeriveEncryptedStringKey(newKey)
if err != nil {
return fmt.Errorf("failed to derive new encrypted string key: %w", err)
}
err = db.Transaction(func(tx *gorm.DB) error {
err = rotateSigningKeyEncryption(ctx, tx, oldKek, newKek)
if err != nil {
return err
}
err = rotateScimTokens(tx, oldEncKey, newEncKey)
if err != nil {
return err
}
return nil
})
if err != nil {
return err
}
fmt.Println("Encryption key rotation completed successfully.")
fmt.Println("Restart pocket-id with the new ENCRYPTION_KEY to use the rotated data.")
return nil
}
func rotateSigningKeyEncryption(ctx context.Context, db *gorm.DB, oldKek []byte, newKek []byte) error {
oldProvider := &jwkutils.KeyProviderDatabase{}
err := oldProvider.Init(jwkutils.KeyProviderOpts{
DB: db,
Kek: oldKek,
})
if err != nil {
return fmt.Errorf("failed to init key provider with old encryption key: %w", err)
}
key, err := oldProvider.LoadKey(ctx)
if err != nil {
return fmt.Errorf("failed to load signing key using old encryption key: %w", err)
}
if key == nil {
return nil
}
newProvider := &jwkutils.KeyProviderDatabase{}
err = newProvider.Init(jwkutils.KeyProviderOpts{
DB: db,
Kek: newKek,
})
if err != nil {
return fmt.Errorf("failed to init key provider with new encryption key: %w", err)
}
if err := newProvider.SaveKey(ctx, key); err != nil {
return fmt.Errorf("failed to store signing key with new encryption key: %w", err)
}
return nil
}
type scimTokenRow struct {
ID string
Token string
}
func rotateScimTokens(db *gorm.DB, oldEncKey []byte, newEncKey []byte) error {
var rows []scimTokenRow
err := db.Model(&model.ScimServiceProvider{}).Select("id, token").Scan(&rows).Error
if err != nil {
return fmt.Errorf("failed to list SCIM service providers: %w", err)
}
for _, row := range rows {
if row.Token == "" {
continue
}
decBytes, err := datatype.DecryptEncryptedStringWithKey(oldEncKey, row.Token)
if err != nil {
return fmt.Errorf("failed to decrypt SCIM token for provider %s: %w", row.ID, err)
}
encValue, err := datatype.EncryptEncryptedStringWithKey(newEncKey, decBytes)
if err != nil {
return fmt.Errorf("failed to encrypt SCIM token for provider %s: %w", row.ID, err)
}
err = db.Model(&model.ScimServiceProvider{}).Where("id = ?", row.ID).Update("token", encValue).Error
if err != nil {
return fmt.Errorf("failed to update SCIM token for provider %s: %w", row.ID, err)
}
}
return nil
}

View File

@@ -1,89 +0,0 @@
package cmds
import (
"testing"
"time"
"github.com/pocket-id/pocket-id/backend/internal/model"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/pocket-id/pocket-id/backend/internal/common"
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
"github.com/pocket-id/pocket-id/backend/internal/service"
jwkutils "github.com/pocket-id/pocket-id/backend/internal/utils/jwk"
testingutils "github.com/pocket-id/pocket-id/backend/internal/utils/testing"
)
func TestEncryptionKeyRotate(t *testing.T) {
oldKey := []byte("old-encryption-key-123456")
newKey := []byte("new-encryption-key-654321")
envConfig := &common.EnvConfigSchema{
EncryptionKey: oldKey,
}
db := testingutils.NewDatabaseForTest(t)
appConfigService, err := service.NewAppConfigService(t.Context(), db)
require.NoError(t, err)
instanceID := appConfigService.GetDbConfig().InstanceID.Value
oldKek, err := jwkutils.LoadKeyEncryptionKey(envConfig, instanceID)
require.NoError(t, err)
oldProvider := &jwkutils.KeyProviderDatabase{}
require.NoError(t, oldProvider.Init(jwkutils.KeyProviderOpts{
DB: db,
Kek: oldKek,
}))
signingKey, err := jwkutils.GenerateKey("RS256", "")
require.NoError(t, err)
require.NoError(t, oldProvider.SaveKey(t.Context(), signingKey))
oldEncKey, err := datatype.DeriveEncryptedStringKey(oldKey)
require.NoError(t, err)
encToken, err := datatype.EncryptEncryptedStringWithKey(oldEncKey, []byte("scim-token-123"))
require.NoError(t, err)
err = db.Exec(
`INSERT INTO scim_service_providers (id, created_at, endpoint, token, oidc_client_id) VALUES (?, ?, ?, ?, ?)`,
"scim-1",
time.Now(),
"https://example.com/scim",
encToken,
"client-1",
).Error
require.NoError(t, err)
flags := encryptionKeyRotateFlags{
NewKey: string(newKey),
Yes: true,
}
require.NoError(t, encryptionKeyRotate(t.Context(), flags, db, envConfig))
newKek, err := jwkutils.LoadKeyEncryptionKey(&common.EnvConfigSchema{EncryptionKey: newKey}, instanceID)
require.NoError(t, err)
newProvider := &jwkutils.KeyProviderDatabase{}
require.NoError(t, newProvider.Init(jwkutils.KeyProviderOpts{
DB: db,
Kek: newKek,
}))
rotatedKey, err := newProvider.LoadKey(t.Context())
require.NoError(t, err)
require.NotNil(t, rotatedKey)
var storedToken string
err = db.Model(&model.ScimServiceProvider{}).Where("id = ?", "scim-1").Pluck("token", &storedToken).Error
require.NoError(t, err)
newEncKey, err := datatype.DeriveEncryptedStringKey(newKey)
require.NoError(t, err)
decBytes, err := datatype.DecryptEncryptedStringWithKey(newEncKey, storedToken)
require.NoError(t, err)
assert.Equal(t, "scim-token-123", string(decBytes))
}

View File

@@ -119,10 +119,11 @@ func acquireImportLock(ctx context.Context, db *gorm.DB, force bool) error {
defer cancel()
waitUntil, err := appLockService.Acquire(opCtx, force)
if errors.Is(err, service.ErrLockUnavailable) {
//nolint:staticcheck
return errors.New("Pocket ID must be stopped before importing data; please stop the running instance or run with --forcefully-acquire-lock to terminate the other instance")
} else if err != nil {
if err != nil {
if errors.Is(err, service.ErrLockUnavailable) {
//nolint:staticcheck
return errors.New("Pocket ID must be stopped before importing data; please stop the running instance or run with --forcefully-acquire-lock to terminate the other instance")
}
return fmt.Errorf("failed to acquire application lock: %w", err)
}

View File

@@ -102,7 +102,7 @@ func keyRotate(ctx context.Context, flags keyRotateFlags, db *gorm.DB, envConfig
}
// Save the key
err = keyProvider.SaveKey(ctx, key)
err = keyProvider.SaveKey(key)
if err != nil {
return fmt.Errorf("failed to store new key: %w", err)
}

View File

@@ -104,7 +104,7 @@ func testKeyRotateWithDatabaseStorage(t *testing.T, flags keyRotateFlags, wantEr
require.NoError(t, err)
// Verify key was created
key, err := keyProvider.LoadKey(t.Context())
key, err := keyProvider.LoadKey()
require.NoError(t, err)
require.NotNil(t, key)

View File

@@ -51,7 +51,7 @@ var oneTimeAccessTokenCmd = &cobra.Command{
}
// Create a new access token that expires in 1 hour
oneTimeAccessToken, txErr = service.NewOneTimeAccessToken(user.ID, time.Hour, false)
oneTimeAccessToken, txErr = service.NewOneTimeAccessToken(user.ID, time.Hour)
if txErr != nil {
return fmt.Errorf("failed to generate access token: %w", txErr)
}

View File

@@ -38,24 +38,33 @@ const (
)
type EnvConfigSchema struct {
AppEnv AppEnv `env:"APP_ENV" options:"toLower"`
EncryptionKey []byte `env:"ENCRYPTION_KEY" options:"file"`
AppURL string `env:"APP_URL" options:"toLower,trimTrailingSlash"`
DbProvider DbProvider
DbConnectionString string `env:"DB_CONNECTION_STRING" options:"file"`
TrustProxy bool `env:"TRUST_PROXY"`
TrustedPlatform string `env:"TRUSTED_PLATFORM"`
AuditLogRetentionDays int `env:"AUDIT_LOG_RETENTION_DAYS"`
AnalyticsDisabled bool `env:"ANALYTICS_DISABLED"`
AllowDowngrade bool `env:"ALLOW_DOWNGRADE"`
InternalAppURL string `env:"INTERNAL_APP_URL"`
UiConfigDisabled bool `env:"UI_CONFIG_DISABLED"`
DisableRateLimiting bool `env:"DISABLE_RATE_LIMITING"`
VersionCheckDisabled bool `env:"VERSION_CHECK_DISABLED"`
StaticApiKey string `env:"STATIC_API_KEY" options:"file"`
AppEnv AppEnv `env:"APP_ENV" options:"toLower"`
LogLevel string `env:"LOG_LEVEL" options:"toLower"`
LogJSON bool `env:"LOG_JSON"`
AppURL string `env:"APP_URL" options:"toLower,trimTrailingSlash"`
DbProvider DbProvider
DbConnectionString string `env:"DB_CONNECTION_STRING" options:"file"`
EncryptionKey []byte `env:"ENCRYPTION_KEY" options:"file"`
Port string `env:"PORT"`
Host string `env:"HOST" options:"toLower"`
UnixSocket string `env:"UNIX_SOCKET"`
UnixSocketMode string `env:"UNIX_SOCKET_MODE"`
LocalIPv6Ranges string `env:"LOCAL_IPV6_RANGES"`
UiConfigDisabled bool `env:"UI_CONFIG_DISABLED"`
MetricsEnabled bool `env:"METRICS_ENABLED"`
TracingEnabled bool `env:"TRACING_ENABLED"`
TrustProxy bool `env:"TRUST_PROXY"`
AnalyticsDisabled bool `env:"ANALYTICS_DISABLED"`
AllowDowngrade bool `env:"ALLOW_DOWNGRADE"`
InternalAppURL string `env:"INTERNAL_APP_URL"`
MaxMindLicenseKey string `env:"MAXMIND_LICENSE_KEY" options:"file"`
GeoLiteDBPath string `env:"GEOLITE_DB_PATH"`
GeoLiteDBUrl string `env:"GEOLITE_DB_URL"`
FileBackend string `env:"FILE_BACKEND" options:"toLower"`
UploadPath string `env:"UPLOAD_PATH"`
FileBackend string `env:"FILE_BACKEND" options:"toLower"`
UploadPath string `env:"UPLOAD_PATH"`
S3Bucket string `env:"S3_BUCKET"`
S3Region string `env:"S3_REGION"`
S3Endpoint string `env:"S3_ENDPOINT"`
@@ -63,21 +72,6 @@ type EnvConfigSchema struct {
S3SecretAccessKey string `env:"S3_SECRET_ACCESS_KEY"`
S3ForcePathStyle bool `env:"S3_FORCE_PATH_STYLE"`
S3DisableDefaultIntegrityChecks bool `env:"S3_DISABLE_DEFAULT_INTEGRITY_CHECKS"`
Port string `env:"PORT"`
Host string `env:"HOST" options:"toLower"`
UnixSocket string `env:"UNIX_SOCKET"`
UnixSocketMode string `env:"UNIX_SOCKET_MODE"`
LocalIPv6Ranges string `env:"LOCAL_IPV6_RANGES"`
MaxMindLicenseKey string `env:"MAXMIND_LICENSE_KEY" options:"file"`
GeoLiteDBPath string `env:"GEOLITE_DB_PATH"`
GeoLiteDBUrl string `env:"GEOLITE_DB_URL"`
LogLevel string `env:"LOG_LEVEL" options:"toLower"`
MetricsEnabled bool `env:"METRICS_ENABLED"`
TracingEnabled bool `env:"TRACING_ENABLED"`
LogJSON bool `env:"LOG_JSON"`
}
var EnvConfig = defaultConfig()
@@ -92,22 +86,21 @@ func init() {
func defaultConfig() EnvConfigSchema {
return EnvConfigSchema{
AppEnv: AppEnvProduction,
LogLevel: "info",
DbProvider: "sqlite",
FileBackend: "filesystem",
AuditLogRetentionDays: 90,
AppURL: AppUrl,
Port: "1411",
Host: "0.0.0.0",
GeoLiteDBPath: "data/GeoLite2-City.mmdb",
GeoLiteDBUrl: MaxMindGeoLiteCityUrl,
AppEnv: AppEnvProduction,
LogLevel: "info",
DbProvider: "sqlite",
FileBackend: "filesystem",
AppURL: AppUrl,
Port: "1411",
Host: "0.0.0.0",
GeoLiteDBPath: "data/GeoLite2-City.mmdb",
GeoLiteDBUrl: MaxMindGeoLiteCityUrl,
}
}
func parseEnvConfig() error {
parsers := map[reflect.Type]env.ParserFunc{
reflect.TypeFor[[]byte](): func(value string) (any, error) {
reflect.TypeOf([]byte{}): func(value string) (interface{}, error) {
return []byte(value), nil
},
}
@@ -130,10 +123,6 @@ func parseEnvConfig() error {
// ValidateEnvConfig checks the EnvConfig for required fields and valid values
func ValidateEnvConfig(config *EnvConfigSchema) error {
if shouldSkipEnvValidation(os.Args) {
return nil
}
if _, err := sloggin.ParseLevel(config.LogLevel); err != nil {
return errors.New("invalid LOG_LEVEL value. Must be 'debug', 'info', 'warn' or 'error'")
}
@@ -175,7 +164,6 @@ func ValidateEnvConfig(config *EnvConfigSchema) error {
switch config.FileBackend {
case "s3", "database":
// All good, these are valid values
case "", "filesystem":
if config.UploadPath == "" {
config.UploadPath = defaultFsUploadPath
@@ -185,8 +173,8 @@ func ValidateEnvConfig(config *EnvConfigSchema) error {
}
// Validate LOCAL_IPV6_RANGES
ranges := strings.SplitSeq(config.LocalIPv6Ranges, ",")
for rangeStr := range ranges {
ranges := strings.Split(config.LocalIPv6Ranges, ",")
for _, rangeStr := range ranges {
rangeStr = strings.TrimSpace(rangeStr)
if rangeStr == "" {
continue
@@ -203,29 +191,10 @@ func ValidateEnvConfig(config *EnvConfigSchema) error {
}
if config.AuditLogRetentionDays <= 0 {
return errors.New("AUDIT_LOG_RETENTION_DAYS must be greater than 0")
}
if config.StaticApiKey != "" && len(config.StaticApiKey) < 16 {
return errors.New("STATIC_API_KEY must be at least 16 characters long")
}
return nil
}
func shouldSkipEnvValidation(args []string) bool {
for _, arg := range args[1:] {
switch arg {
case "-h", "--help", "help", "version":
return true
}
}
return false
}
// prepareEnvConfig processes special options for EnvConfig fields
func prepareEnvConfig(config *EnvConfigSchema) error {
val := reflect.ValueOf(config).Elem()
@@ -236,9 +205,9 @@ func prepareEnvConfig(config *EnvConfigSchema) error {
fieldType := typ.Field(i)
optionsTag := fieldType.Tag.Get("options")
options := strings.SplitSeq(optionsTag, ",")
options := strings.Split(optionsTag, ",")
for option := range options {
for _, option := range options {
switch option {
case "toLower":
if field.Kind() == reflect.String {

View File

@@ -129,41 +129,6 @@ func TestParseEnvConfig(t *testing.T) {
assert.False(t, EnvConfig.AnalyticsDisabled)
})
t.Run("should default audit log retention days to 90", func(t *testing.T) {
EnvConfig = defaultConfig()
t.Setenv("DB_PROVIDER", "sqlite")
t.Setenv("DB_CONNECTION_STRING", "file:test.db")
t.Setenv("APP_URL", "http://localhost:3000")
err := parseEnvConfig()
require.NoError(t, err)
assert.Equal(t, 90, EnvConfig.AuditLogRetentionDays)
})
t.Run("should parse audit log retention days override", func(t *testing.T) {
EnvConfig = defaultConfig()
t.Setenv("DB_PROVIDER", "sqlite")
t.Setenv("DB_CONNECTION_STRING", "file:test.db")
t.Setenv("APP_URL", "http://localhost:3000")
t.Setenv("AUDIT_LOG_RETENTION_DAYS", "365")
err := parseEnvConfig()
require.NoError(t, err)
assert.Equal(t, 365, EnvConfig.AuditLogRetentionDays)
})
t.Run("should fail when AUDIT_LOG_RETENTION_DAYS is non-positive", func(t *testing.T) {
EnvConfig = defaultConfig()
t.Setenv("DB_PROVIDER", "sqlite")
t.Setenv("DB_CONNECTION_STRING", "file:test.db")
t.Setenv("APP_URL", "http://localhost:3000")
t.Setenv("AUDIT_LOG_RETENTION_DAYS", "0")
err := parseAndValidateEnvConfig(t)
require.Error(t, err)
assert.ErrorContains(t, err, "AUDIT_LOG_RETENTION_DAYS must be greater than 0")
})
t.Run("should parse string environment variables correctly", func(t *testing.T) {
EnvConfig = defaultConfig()
t.Setenv("DB_CONNECTION_STRING", "postgres://test")

View File

@@ -20,7 +20,7 @@ type AlreadyInUseError struct {
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 400 }
func (e *AlreadyInUseError) Is(target error) bool {
// Ignore the field property when checking if an error is of the type AlreadyInUseError
@@ -31,26 +31,19 @@ func (e *AlreadyInUseError) Is(target error) bool {
type SetupAlreadyCompletedError struct{}
func (e *SetupAlreadyCompletedError) Error() string { return "setup already completed" }
func (e *SetupAlreadyCompletedError) HttpStatusCode() int { return http.StatusConflict }
func (e *SetupAlreadyCompletedError) HttpStatusCode() int { return 400 }
type TokenInvalidOrExpiredError struct{}
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 {
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 *TokenInvalidOrExpiredError) HttpStatusCode() int { return 400 }
type TokenInvalidError struct{}
func (e *TokenInvalidError) Error() string {
return "Token is invalid"
}
func (e *TokenInvalidError) HttpStatusCode() int { return http.StatusUnauthorized }
func (e *TokenInvalidError) HttpStatusCode() int { return 400 }
type OidcMissingAuthorizationError struct{}
@@ -60,51 +53,46 @@ func (e *OidcMissingAuthorizationError) HttpStatusCode() int { return http.Statu
type OidcGrantTypeNotSupportedError struct{}
func (e *OidcGrantTypeNotSupportedError) Error() string { return "grant type not supported" }
func (e *OidcGrantTypeNotSupportedError) HttpStatusCode() int { return http.StatusBadRequest }
func (e *OidcGrantTypeNotSupportedError) HttpStatusCode() int { return 400 }
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) HttpStatusCode() int { return 400 }
type OidcClientSecretInvalidError struct{}
func (e *OidcClientSecretInvalidError) Error() string { return "invalid client secret" }
func (e *OidcClientSecretInvalidError) HttpStatusCode() int { return http.StatusUnauthorized }
func (e *OidcClientSecretInvalidError) HttpStatusCode() int { return 400 }
type OidcClientAssertionInvalidError struct{}
func (e *OidcClientAssertionInvalidError) Error() string { return "invalid client assertion" }
func (e *OidcClientAssertionInvalidError) HttpStatusCode() int { return http.StatusUnauthorized }
func (e *OidcClientAssertionInvalidError) HttpStatusCode() int { return 400 }
type OidcInvalidAuthorizationCodeError struct{}
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 *OidcInvalidAuthorizationCodeError) HttpStatusCode() int { return 400 }
type OidcMissingCallbackURLError struct{}
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 400 }
type OidcInvalidCallbackURLError struct{}
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 400 }
type FileTypeNotSupportedError struct{}
func (e *FileTypeNotSupportedError) Error() string { return "file type not supported" }
func (e *FileTypeNotSupportedError) HttpStatusCode() int { return http.StatusBadRequest }
func (e *FileTypeNotSupportedError) HttpStatusCode() int { return 400 }
type FileTooLargeError struct {
MaxSize string
@@ -139,20 +127,6 @@ func (e *TooManyRequestsError) Error() string {
}
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 }
type UserNotFoundError struct{}
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 {
@@ -285,13 +259,6 @@ func (e *APIKeyNotFoundError) Error() string {
}
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 {
@@ -299,13 +266,6 @@ func (e *APIKeyExpirationDateError) Error() string {
}
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 {
@@ -438,13 +398,3 @@ func (e *ImageNotFoundError) Error() string {
func (e *ImageNotFoundError) HttpStatusCode() int {
return http.StatusNotFound
}
type InvalidEmailVerificationTokenError struct{}
func (e *InvalidEmailVerificationTokenError) Error() string {
return "Invalid email verification token"
}
func (e *InvalidEmailVerificationTokenError) HttpStatusCode() int {
return http.StatusBadRequest
}

View File

@@ -26,11 +26,11 @@ func NewApiKeyController(group *gin.RouterGroup, authMiddleware *middleware.Auth
uc := &ApiKeyController{apiKeyService: apiKeyService}
apiKeyGroup := group.Group("/api-keys")
apiKeyGroup.Use(authMiddleware.WithAdminNotRequired().Add())
{
apiKeyGroup.GET("", authMiddleware.WithAdminNotRequired().Add(), uc.listApiKeysHandler)
apiKeyGroup.POST("", authMiddleware.WithAdminNotRequired().WithApiKeyAuthDisabled().Add(), uc.createApiKeyHandler)
apiKeyGroup.POST("/:id/renew", authMiddleware.WithAdminNotRequired().WithApiKeyAuthDisabled().Add(), uc.renewApiKeyHandler)
apiKeyGroup.DELETE("/:id", authMiddleware.WithAdminNotRequired().Add(), uc.revokeApiKeyHandler)
apiKeyGroup.GET("", uc.listApiKeysHandler)
apiKeyGroup.POST("", uc.createApiKeyHandler)
apiKeyGroup.DELETE("/:id", uc.revokeApiKeyHandler)
}
}
@@ -101,41 +101,6 @@ func (c *ApiKeyController) createApiKeyHandler(ctx *gin.Context) {
})
}
// renewApiKeyHandler godoc
// @Summary Renew API key
// @Description Renew an existing API key by ID
// @Tags API Keys
// @Param id path string true "API Key ID"
// @Success 200 {object} dto.ApiKeyResponseDto "Renewed API key with new token"
// @Router /api/api-keys/{id}/renew [post]
func (c *ApiKeyController) renewApiKeyHandler(ctx *gin.Context) {
userID := ctx.GetString("userID")
apiKeyID := ctx.Param("id")
var input dto.ApiKeyRenewDto
if err := dto.ShouldBindWithNormalizedJSON(ctx, &input); err != nil {
_ = ctx.Error(err)
return
}
apiKey, token, err := c.apiKeyService.RenewApiKey(ctx.Request.Context(), userID, apiKeyID, input.ExpiresAt.ToTime())
if err != nil {
_ = ctx.Error(err)
return
}
var apiKeyDto dto.ApiKeyDto
if err := dto.MapStruct(apiKey, &apiKeyDto); err != nil {
_ = ctx.Error(err)
return
}
ctx.JSON(http.StatusOK, dto.ApiKeyResponseDto{
ApiKey: apiKeyDto,
Token: token,
})
}
// revokeApiKeyHandler godoc
// @Summary Revoke API key
// @Description Revoke (delete) an existing API key by ID

View File

@@ -2,9 +2,7 @@ package controller
import (
"net/http"
"slices"
"strconv"
"strings"
"time"
"github.com/gin-gonic/gin"
@@ -25,18 +23,15 @@ func NewAppImagesController(
}
group.GET("/application-images/logo", controller.getLogoHandler)
group.GET("/application-images/email", controller.getEmailLogoHandler)
group.GET("/application-images/background", controller.getBackgroundImageHandler)
group.GET("/application-images/favicon", controller.getFaviconHandler)
group.GET("/application-images/default-profile-picture", authMiddleware.Add(), controller.getDefaultProfilePicture)
group.PUT("/application-images/logo", authMiddleware.Add(), controller.updateLogoHandler)
group.PUT("/application-images/email", authMiddleware.Add(), controller.updateEmailLogoHandler)
group.PUT("/application-images/background", authMiddleware.Add(), controller.updateBackgroundImageHandler)
group.PUT("/application-images/favicon", authMiddleware.Add(), controller.updateFaviconHandler)
group.PUT("/application-images/default-profile-picture", authMiddleware.Add(), controller.updateDefaultProfilePicture)
group.DELETE("/application-images/background", authMiddleware.Add(), controller.deleteBackgroundImageHandler)
group.DELETE("/application-images/default-profile-picture", authMiddleware.Add(), controller.deleteDefaultProfilePicture)
}
@@ -64,18 +59,6 @@ func (c *AppImagesController) getLogoHandler(ctx *gin.Context) {
c.getImage(ctx, imageName)
}
// getEmailLogoHandler godoc
// @Summary Get email logo image
// @Description Get the email logo image for use in emails
// @Tags Application Images
// @Produce image/png
// @Produce image/jpeg
// @Success 200 {file} binary "Email logo image"
// @Router /api/application-images/email [get]
func (c *AppImagesController) getEmailLogoHandler(ctx *gin.Context) {
c.getImage(ctx, "logoEmail")
}
// getBackgroundImageHandler godoc
// @Summary Get background image
// @Description Get the background image for the application
@@ -141,37 +124,6 @@ func (c *AppImagesController) updateLogoHandler(ctx *gin.Context) {
ctx.Status(http.StatusNoContent)
}
// updateEmailLogoHandler godoc
// @Summary Update email logo
// @Description Update the email logo for use in emails
// @Tags Application Images
// @Accept multipart/form-data
// @Param file formData file true "Email logo image file"
// @Success 204 "No Content"
// @Router /api/application-images/email [put]
func (c *AppImagesController) updateEmailLogoHandler(ctx *gin.Context) {
file, err := ctx.FormFile("file")
if err != nil {
_ = ctx.Error(err)
return
}
fileType := utils.GetFileExtension(file.Filename)
mimeType := utils.GetImageMimeType(fileType)
if mimeType != "image/png" && mimeType != "image/jpeg" {
_ = ctx.Error(&common.WrongFileTypeError{ExpectedFileType: ".png or .jpg/jpeg"})
return
}
if err := c.appImagesService.UpdateImage(ctx.Request.Context(), file, "logoEmail"); err != nil {
_ = ctx.Error(err)
return
}
ctx.Status(http.StatusNoContent)
}
// updateBackgroundImageHandler godoc
// @Summary Update background image
// @Description Update the application background image
@@ -195,27 +147,12 @@ func (c *AppImagesController) updateBackgroundImageHandler(ctx *gin.Context) {
ctx.Status(http.StatusNoContent)
}
// deleteBackgroundImageHandler godoc
// @Summary Delete background image
// @Description Delete the application background image
// @Tags Application Images
// @Success 204 "No Content"
// @Router /api/application-images/background [delete]
func (c *AppImagesController) deleteBackgroundImageHandler(ctx *gin.Context) {
if err := c.appImagesService.DeleteImage(ctx.Request.Context(), "background"); err != nil {
_ = ctx.Error(err)
return
}
ctx.Status(http.StatusNoContent)
}
// updateFaviconHandler godoc
// @Summary Update favicon
// @Description Update the application favicon
// @Tags Application Images
// @Accept multipart/form-data
// @Param file formData file true "Favicon file (.svg/.png/.ico)"
// @Param file formData file true "Favicon file (.ico)"
// @Success 204 "No Content"
// @Router /api/application-images/favicon [put]
func (c *AppImagesController) updateFaviconHandler(ctx *gin.Context) {
@@ -226,9 +163,8 @@ func (c *AppImagesController) updateFaviconHandler(ctx *gin.Context) {
}
fileType := utils.GetFileExtension(file.Filename)
mimeType := utils.GetImageMimeType(strings.ToLower(fileType))
if !slices.Contains([]string{"image/svg+xml", "image/png", "image/x-icon"}, mimeType) {
_ = ctx.Error(&common.WrongFileTypeError{ExpectedFileType: ".svg or .png or .ico"})
if fileType != "ico" {
_ = ctx.Error(&common.WrongFileTypeError{ExpectedFileType: ".ico"})
return
}

View File

@@ -1,7 +1,6 @@
package controller
import (
"context"
"errors"
"log/slog"
"net/http"
@@ -25,11 +24,7 @@ import (
// @Description Initializes all OIDC-related API endpoints for authentication and client management
// @Tags OIDC
func NewOidcController(group *gin.RouterGroup, authMiddleware *middleware.AuthMiddleware, fileSizeLimitMiddleware *middleware.FileSizeLimitMiddleware, oidcService *service.OidcService, jwtService *service.JwtService) {
oc := &OidcController{
oidcService: oidcService,
jwtService: jwtService,
createTokens: oidcService.CreateTokens,
}
oc := &OidcController{oidcService: oidcService, jwtService: jwtService}
group.POST("/oidc/authorize", authMiddleware.WithAdminNotRequired().Add(), oc.authorizeHandler)
group.POST("/oidc/authorization-required", authMiddleware.WithAdminNotRequired().Add(), oc.authorizationConfirmationRequiredHandler)
@@ -52,7 +47,7 @@ func NewOidcController(group *gin.RouterGroup, authMiddleware *middleware.AuthMi
group.POST("/oidc/clients/:id/secret", authMiddleware.Add(), oc.createClientSecretHandler)
group.GET("/oidc/clients/:id/logo", oc.getClientLogoHandler)
group.DELETE("/oidc/clients/:id/logo", authMiddleware.Add(), oc.deleteClientLogoHandler)
group.DELETE("/oidc/clients/:id/logo", oc.deleteClientLogoHandler)
group.POST("/oidc/clients/:id/logo", authMiddleware.Add(), fileSizeLimitMiddleware.Add(2<<20), oc.updateClientLogoHandler)
group.GET("/oidc/clients/:id/preview/:userId", authMiddleware.Add(), oc.getClientPreviewHandler)
@@ -68,14 +63,17 @@ func NewOidcController(group *gin.RouterGroup, authMiddleware *middleware.AuthMi
group.GET("/oidc/users/me/clients", authMiddleware.WithAdminNotRequired().Add(), oc.listOwnAccessibleClientsHandler)
group.GET("/oidc/clients/:id/scim-service-provider", authMiddleware.Add(), oc.getClientScimServiceProviderHandler)
// OIDC API (Resource Server) routes
group.POST("/oidc/apis", authMiddleware.Add(), oc.createAPIHandler)
group.GET("/oidc/apis", authMiddleware.Add(), oc.listAPIsHandler)
group.GET("/oidc/apis/:id", authMiddleware.Add(), oc.getAPIHandler)
group.POST("/oidc/apis/:id", authMiddleware.Add(), oc.updateAPIHandler)
group.DELETE("/oidc/apis/:id", authMiddleware.Add(), oc.deleteAPIHandler)
}
type OidcController struct {
oidcService *service.OidcService
jwtService *service.JwtService
createTokens func(context.Context, dto.OidcCreateTokensDto) (service.CreatedTokens, error)
oidcService *service.OidcService
jwtService *service.JwtService
}
// authorizeHandler godoc
@@ -150,13 +148,8 @@ func (oc *OidcController) authorizationConfirmationRequiredHandler(c *gin.Contex
// @Success 200 {object} dto.OidcTokenResponseDto "Token response with access_token and optional id_token and refresh_token"
// @Router /api/oidc/token [post]
func (oc *OidcController) createTokensHandler(c *gin.Context) {
// Per RFC-6749, parameters passed to the /token endpoint MUST be passed in the body of the request
// Gin's "ShouldBind" by default reads from the query string too, so we need to reset all query string args before invoking ShouldBind
c.Request.URL.RawQuery = ""
var input dto.OidcCreateTokensDto
err := c.ShouldBind(&input)
if err != nil {
if err := c.ShouldBind(&input); err != nil {
_ = c.Error(err)
return
}
@@ -175,10 +168,10 @@ func (oc *OidcController) createTokensHandler(c *gin.Context) {
// 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)
input.ClientID, input.ClientSecret, _ = c.Request.BasicAuth()
}
tokens, err := oc.createTokens(c.Request.Context(), input)
tokens, err := oc.oidcService.CreateTokens(c.Request.Context(), input)
switch {
case errors.Is(err, &common.OidcAuthorizationPendingError{}):
@@ -333,15 +326,13 @@ func (oc *OidcController) introspectTokenHandler(c *gin.Context) {
creds service.ClientAuthCredentials
ok bool
)
creds.ClientID, creds.ClientSecret, ok = utils.OAuthClientBasicAuth(c.Request)
creds.ClientID, creds.ClientSecret, ok = c.Request.BasicAuth()
if !ok {
// If there's no basic auth, check if we have a bearer token (used as client assertion)
// If there's no basic auth, check if we have a bearer token
bearer, ok := utils.BearerAuth(c.Request)
if ok {
creds.ClientAssertionType = service.ClientAssertionTypeJWTBearer
creds.ClientAssertion = bearer
// When using client assertions, client_id can be passed as a form field
creds.ClientID = input.ClientID
}
}
@@ -664,20 +655,15 @@ func (oc *OidcController) updateAllowedUserGroupsHandler(c *gin.Context) {
}
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
c.Request.URL.RawQuery = ""
var input dto.OidcDeviceAuthorizationRequestDto
err := c.ShouldBind(&input)
if err != nil {
if err := c.ShouldBind(&input); err != nil {
_ = c.Error(err)
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)
input.ClientID, input.ClientSecret, _ = c.Request.BasicAuth()
}
response, err := oc.oidcService.CreateDeviceAuthorization(c.Request.Context(), input)
@@ -866,28 +852,150 @@ func (oc *OidcController) getClientPreviewHandler(c *gin.Context) {
c.JSON(http.StatusOK, preview)
}
// getClientScimServiceProviderHandler godoc
// @Summary Get SCIM service provider
// @Description Get the SCIM service provider configuration for an OIDC client
// createAPIHandler godoc
// @Summary Create OIDC API
// @Description Create a new OIDC API (resource server)
// @Tags OIDC
// @Accept json
// @Produce json
// @Param id path string true "Client ID"
// @Success 200 {object} dto.ScimServiceProviderDTO "SCIM service provider configuration"
// @Router /api/oidc/clients/{id}/scim-service-provider [get]
func (oc *OidcController) getClientScimServiceProviderHandler(c *gin.Context) {
clientID := c.Param("id")
provider, err := oc.oidcService.GetClientScimServiceProvider(c.Request.Context(), clientID)
// @Param api body dto.OidcAPICreateDto true "API information"
// @Success 201 {object} dto.OidcAPIDto "Created API"
// @Router /api/oidc/apis [post]
func (oc *OidcController) createAPIHandler(c *gin.Context) {
var input dto.OidcAPICreateDto
err := c.ShouldBindJSON(&input)
if err != nil {
_ = c.Error(err)
return
}
var providerDto dto.ScimServiceProviderDTO
if err := dto.MapStruct(provider, &providerDto); err != nil {
api, err := oc.oidcService.CreateAPI(c.Request.Context(), input)
if err != nil {
_ = c.Error(err)
return
}
c.JSON(http.StatusOK, providerDto)
var apiDto dto.OidcAPIDto
err = dto.MapStruct(api, &apiDto)
if err != nil {
_ = c.Error(err)
return
}
c.JSON(http.StatusCreated, apiDto)
}
// listAPIsHandler godoc
// @Summary List OIDC APIs
// @Description Get a paginated list of OIDC APIs (resource servers)
// @Tags OIDC
// @Param search query string false "Search term to filter APIs by name"
// @Param pagination[page] query int false "Page number for pagination" default(1)
// @Param pagination[limit] query int false "Number of items per page" default(20)
// @Param sort[column] query string false "Column to sort by"
// @Param sort[direction] query string false "Sort direction (asc or desc)" default("asc")
// @Success 200 {object} dto.Paginated[dto.OidcAPIDto]
// @Router /api/oidc/apis [get]
func (oc *OidcController) listAPIsHandler(c *gin.Context) {
searchTerm := c.Query("search")
listRequestOptions := utils.ParseListRequestOptions(c)
apis, pagination, err := oc.oidcService.ListAPIs(c.Request.Context(), searchTerm, listRequestOptions)
if err != nil {
_ = c.Error(err)
return
}
apisDto := make([]dto.OidcAPIDto, len(apis))
for i, api := range apis {
var apiDto dto.OidcAPIDto
err = dto.MapStruct(api, &apiDto)
if err != nil {
_ = c.Error(err)
return
}
apisDto[i] = apiDto
}
c.JSON(http.StatusOK, dto.Paginated[dto.OidcAPIDto]{
Data: apisDto,
Pagination: pagination,
})
}
// getAPIHandler godoc
// @Summary Get OIDC API
// @Description Get detailed information about an OIDC API (resource server)
// @Tags OIDC
// @Produce json
// @Param id path string true "API ID"
// @Success 200 {object} dto.OidcAPIDto "API information"
// @Router /api/oidc/apis/{id} [get]
func (oc *OidcController) getAPIHandler(c *gin.Context) {
apiID := c.Param("id")
api, err := oc.oidcService.GetAPI(c.Request.Context(), apiID)
if err != nil {
_ = c.Error(err)
return
}
var apiDto dto.OidcAPIDto
err = dto.MapStruct(api, &apiDto)
if err != nil {
_ = c.Error(err)
return
}
c.JSON(http.StatusOK, apiDto)
}
// updateAPIHandler godoc
// @Summary Update OIDC API
// @Description Update an existing OIDC API (resource server)
// @Tags OIDC
// @Accept json
// @Produce json
// @Param id path string true "API ID"
// @Param api body dto.OidcAPIUpdateDto true "API information"
// @Success 200 {object} dto.OidcAPIDto "Updated API"
// @Router /api/oidc/apis/{id} [post]
func (oc *OidcController) updateAPIHandler(c *gin.Context) {
var input dto.OidcAPIUpdateDto
err := c.ShouldBindJSON(&input)
if err != nil {
_ = c.Error(err)
return
}
api, err := oc.oidcService.UpdateAPI(c.Request.Context(), c.Param("id"), input)
if err != nil {
_ = c.Error(err)
return
}
var apiDto dto.OidcAPIDto
err = dto.MapStruct(api, &apiDto)
if err != nil {
_ = c.Error(err)
return
}
c.JSON(http.StatusOK, apiDto)
}
// deleteAPIHandler godoc
// @Summary Delete OIDC API
// @Description Delete an OIDC API (resource server) by ID
// @Tags OIDC
// @Param id path string true "API ID"
// @Success 204 "No Content"
// @Router /api/oidc/apis/{id} [delete]
func (oc *OidcController) deleteAPIHandler(c *gin.Context) {
err := oc.oidcService.DeleteAPI(c.Request.Context(), c.Param("id"))
if err != nil {
_ = c.Error(err)
return
}
c.Status(http.StatusNoContent)
}

View File

@@ -1,227 +0,0 @@
package controller
import (
"context"
"encoding/base64"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"time"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/pocket-id/pocket-id/backend/internal/common"
"github.com/pocket-id/pocket-id/backend/internal/dto"
"github.com/pocket-id/pocket-id/backend/internal/service"
)
func TestCreateTokensHandler(t *testing.T) {
createTestContext := func(t *testing.T, rawURL string, form url.Values, authHeader string, noCT bool) (*gin.Context, *httptest.ResponseRecorder) {
t.Helper()
mode := gin.Mode()
gin.SetMode(gin.TestMode)
t.Cleanup(func() { gin.SetMode(mode) })
recorder := httptest.NewRecorder()
c, _ := gin.CreateTestContext(recorder)
req, err := http.NewRequestWithContext(t.Context(), http.MethodPost, rawURL, strings.NewReader(form.Encode()))
require.NoError(t, err)
if !noCT {
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
}
if authHeader != "" {
req.Header.Set("Authorization", authHeader)
}
c.Request = req
return c, recorder
}
t.Run("Ignores Query String Parameters For Binding", func(t *testing.T) {
oc := &OidcController{}
c, _ := createTestContext(
t,
"http://example.com/oidc/token?grant_type=refresh_token&refresh_token=query-value",
url.Values{},
"",
false,
)
oc.createTokensHandler(c)
require.Len(t, c.Errors, 1)
assert.Contains(t, c.Errors[0].Err.Error(), "GrantType")
})
t.Run("Missing Authorization Code", func(t *testing.T) {
oc := &OidcController{}
c, _ := createTestContext(
t,
"http://example.com/oidc/token",
url.Values{
"grant_type": {service.GrantTypeAuthorizationCode},
},
"",
false,
)
oc.createTokensHandler(c)
require.Len(t, c.Errors, 1)
var missingCodeErr *common.OidcMissingAuthorizationCodeError
require.ErrorAs(t, c.Errors[0].Err, &missingCodeErr)
})
t.Run("Missing Refresh Token", func(t *testing.T) {
oc := &OidcController{}
c, _ := createTestContext(
t,
"http://example.com/oidc/token",
url.Values{
"grant_type": {service.GrantTypeRefreshToken},
},
"",
false,
)
oc.createTokensHandler(c)
require.Len(t, c.Errors, 1)
var missingRefreshErr *common.OidcMissingRefreshTokenError
require.ErrorAs(t, c.Errors[0].Err, &missingRefreshErr)
})
t.Run("Uses Basic Auth Credentials When Body Credentials Missing", func(t *testing.T) {
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"},
},
basicAuth,
false,
)
oc.createTokensHandler(c)
require.Empty(t, c.Errors)
assert.Equal(t, "client-id", capturedInput.ClientID)
assert.Equal(t, "client-secret", capturedInput.ClientSecret)
assert.Equal(t, "input-refresh-token", capturedInput.RefreshToken)
require.Equal(t, http.StatusOK, recorder.Code)
var response dto.OidcTokenResponseDto
require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &response))
assert.Equal(t, "access-token", response.AccessToken)
assert.Equal(t, "Bearer", response.TokenType)
assert.Equal(t, "id-token", response.IdToken)
assert.Equal(t, "refresh-token", response.RefreshToken)
assert.Equal(t, 120, response.ExpiresIn)
})
t.Run("Maps Authorization Pending Error", func(t *testing.T) {
oc := &OidcController{
createTokens: func(context.Context, dto.OidcCreateTokensDto) (service.CreatedTokens, error) {
return service.CreatedTokens{}, &common.OidcAuthorizationPendingError{}
},
}
c, recorder := createTestContext(
t,
"http://example.com/oidc/token",
url.Values{
"grant_type": {service.GrantTypeRefreshToken},
"refresh_token": {"input-refresh-token"},
},
"",
false,
)
oc.createTokensHandler(c)
require.Empty(t, c.Errors)
require.Equal(t, http.StatusBadRequest, recorder.Code)
var response map[string]string
require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &response))
assert.Equal(t, "authorization_pending", response["error"])
})
t.Run("Maps Slow Down Error", func(t *testing.T) {
oc := &OidcController{
createTokens: func(context.Context, dto.OidcCreateTokensDto) (service.CreatedTokens, error) {
return service.CreatedTokens{}, &common.OidcSlowDownError{}
},
}
c, recorder := createTestContext(
t,
"http://example.com/oidc/token",
url.Values{
"grant_type": {service.GrantTypeRefreshToken},
"refresh_token": {"input-refresh-token"},
},
"",
false,
)
oc.createTokensHandler(c)
require.Empty(t, c.Errors)
require.Equal(t, http.StatusBadRequest, recorder.Code)
var response map[string]string
require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &response))
assert.Equal(t, "slow_down", response["error"])
})
t.Run("Returns Generic Service Error In Context", func(t *testing.T) {
expectedErr := errors.New("boom")
oc := &OidcController{
createTokens: func(context.Context, dto.OidcCreateTokensDto) (service.CreatedTokens, error) {
return service.CreatedTokens{}, expectedErr
},
}
c, _ := createTestContext(
t,
"http://example.com/oidc/token",
url.Values{
"grant_type": {service.GrantTypeRefreshToken},
"refresh_token": {"input-refresh-token"},
},
"",
false,
)
oc.createTokensHandler(c)
require.Len(t, c.Errors, 1)
assert.ErrorIs(t, c.Errors[0].Err, expectedErr)
})
}

View File

@@ -1,122 +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"
)
func NewScimController(group *gin.RouterGroup, authMiddleware *middleware.AuthMiddleware, scimService *service.ScimService) {
ugc := ScimController{
scimService: scimService,
}
group.POST("/scim/service-provider", authMiddleware.Add(), ugc.createServiceProviderHandler)
group.POST("/scim/service-provider/:id/sync", authMiddleware.Add(), ugc.syncServiceProviderHandler)
group.PUT("/scim/service-provider/:id", authMiddleware.Add(), ugc.updateServiceProviderHandler)
group.DELETE("/scim/service-provider/:id", authMiddleware.Add(), ugc.deleteServiceProviderHandler)
}
type ScimController struct {
scimService *service.ScimService
}
// syncServiceProviderHandler godoc
// @Summary Sync SCIM service provider
// @Description Trigger synchronization for a SCIM service provider
// @Tags SCIM
// @Param id path string true "Service Provider ID"
// @Success 200 "OK"
// @Router /api/scim/service-provider/{id}/sync [post]
func (c *ScimController) syncServiceProviderHandler(ctx *gin.Context) {
err := c.scimService.SyncServiceProvider(ctx.Request.Context(), ctx.Param("id"))
if err != nil {
_ = ctx.Error(err)
return
}
ctx.Status(http.StatusOK)
}
// createServiceProviderHandler godoc
// @Summary Create SCIM service provider
// @Description Create a new SCIM service provider
// @Tags SCIM
// @Accept json
// @Produce json
// @Param serviceProvider body dto.ScimServiceProviderCreateDTO true "SCIM service provider information"
// @Success 201 {object} dto.ScimServiceProviderDTO "Created SCIM service provider"
// @Router /api/scim/service-provider [post]
func (c *ScimController) createServiceProviderHandler(ctx *gin.Context) {
var input dto.ScimServiceProviderCreateDTO
if err := ctx.ShouldBindJSON(&input); err != nil {
_ = ctx.Error(err)
return
}
provider, err := c.scimService.CreateServiceProvider(ctx.Request.Context(), &input)
if err != nil {
_ = ctx.Error(err)
return
}
var providerDTO dto.ScimServiceProviderDTO
if err := dto.MapStruct(provider, &providerDTO); err != nil {
_ = ctx.Error(err)
return
}
ctx.JSON(http.StatusCreated, providerDTO)
}
// updateServiceProviderHandler godoc
// @Summary Update SCIM service provider
// @Description Update an existing SCIM service provider
// @Tags SCIM
// @Accept json
// @Produce json
// @Param id path string true "Service Provider ID"
// @Param serviceProvider body dto.ScimServiceProviderCreateDTO true "SCIM service provider information"
// @Success 200 {object} dto.ScimServiceProviderDTO "Updated SCIM service provider"
// @Router /api/scim/service-provider/{id} [put]
func (c *ScimController) updateServiceProviderHandler(ctx *gin.Context) {
var input dto.ScimServiceProviderCreateDTO
if err := ctx.ShouldBindJSON(&input); err != nil {
_ = ctx.Error(err)
return
}
provider, err := c.scimService.UpdateServiceProvider(ctx.Request.Context(), ctx.Param("id"), &input)
if err != nil {
_ = ctx.Error(err)
return
}
var providerDTO dto.ScimServiceProviderDTO
if err := dto.MapStruct(provider, &providerDTO); err != nil {
_ = ctx.Error(err)
return
}
ctx.JSON(http.StatusOK, providerDTO)
}
// deleteServiceProviderHandler godoc
// @Summary Delete SCIM service provider
// @Description Delete a SCIM service provider by ID
// @Tags SCIM
// @Param id path string true "Service Provider ID"
// @Success 204 "No Content"
// @Router /api/scim/service-provider/{id} [delete]
func (c *ScimController) deleteServiceProviderHandler(ctx *gin.Context) {
err := c.scimService.DeleteServiceProvider(ctx.Request.Context(), ctx.Param("id"))
if err != nil {
_ = ctx.Error(err)
return
}
ctx.Status(http.StatusNoContent)
}

View File

@@ -4,7 +4,6 @@ import (
"net/http"
"time"
"github.com/pocket-id/pocket-id/backend/internal/common"
"github.com/pocket-id/pocket-id/backend/internal/utils/cookie"
"github.com/gin-gonic/gin"
@@ -15,17 +14,19 @@ import (
"golang.org/x/time/rate"
)
const defaultOneTimeAccessTokenDuration = 15 * time.Minute
const (
defaultOneTimeAccessTokenDuration = 15 * time.Minute
defaultSignupTokenDuration = time.Hour
)
// NewUserController creates a new controller for user management endpoints
// @Summary User management controller
// @Description Initializes all user-related API endpoints
// @Tags Users
func NewUserController(group *gin.RouterGroup, authMiddleware *middleware.AuthMiddleware, rateLimitMiddleware *middleware.RateLimitMiddleware, userService *service.UserService, oneTimeAccessService *service.OneTimeAccessService, appConfigService *service.AppConfigService) {
func NewUserController(group *gin.RouterGroup, authMiddleware *middleware.AuthMiddleware, rateLimitMiddleware *middleware.RateLimitMiddleware, userService *service.UserService, appConfigService *service.AppConfigService) {
uc := UserController{
userService: userService,
oneTimeAccessService: oneTimeAccessService,
appConfigService: appConfigService,
userService: userService,
appConfigService: appConfigService,
}
group.GET("/users", authMiddleware.Add(), uc.listUsersHandler)
@@ -53,14 +54,17 @@ func NewUserController(group *gin.RouterGroup, authMiddleware *middleware.AuthMi
group.DELETE("/users/:id/profile-picture", authMiddleware.Add(), uc.resetUserProfilePictureHandler)
group.DELETE("/users/me/profile-picture", authMiddleware.WithAdminNotRequired().Add(), uc.resetCurrentUserProfilePictureHandler)
group.POST("/users/me/send-email-verification", rateLimitMiddleware.Add(rate.Every(10*time.Minute), 3), authMiddleware.WithAdminNotRequired().Add(), uc.sendEmailVerificationHandler)
group.POST("/users/me/verify-email", rateLimitMiddleware.Add(rate.Every(10*time.Second), 5), authMiddleware.WithAdminNotRequired().Add(), uc.verifyEmailHandler)
group.POST("/signup-tokens", authMiddleware.Add(), uc.createSignupTokenHandler)
group.GET("/signup-tokens", authMiddleware.Add(), uc.listSignupTokensHandler)
group.DELETE("/signup-tokens/:id", authMiddleware.Add(), uc.deleteSignupTokenHandler)
group.POST("/signup", rateLimitMiddleware.Add(rate.Every(1*time.Minute), 10), uc.signupHandler)
group.POST("/signup/setup", uc.signUpInitialAdmin)
}
type UserController struct {
userService *service.UserService
oneTimeAccessService *service.OneTimeAccessService
appConfigService *service.AppConfigService
userService *service.UserService
appConfigService *service.AppConfigService
}
// getUserGroupsHandler godoc
@@ -68,7 +72,7 @@ type UserController struct {
// @Description Retrieve all groups a specific user belongs to
// @Tags Users,User Groups
// @Param id path string true "User ID"
// @Success 200 {array} dto.UserGroupDto
// @Success 200 {array} dto.UserGroupDtoWithUsers
// @Router /api/users/{id}/groups [get]
func (uc *UserController) getUserGroupsHandler(c *gin.Context) {
userID := c.Param("id")
@@ -78,7 +82,7 @@ func (uc *UserController) getUserGroupsHandler(c *gin.Context) {
return
}
var groupsDto []dto.UserGroupDto
var groupsDto []dto.UserGroupDtoWithUsers
if err := dto.MapStructList(groups, &groupsDto); err != nil {
_ = c.Error(err)
return
@@ -323,34 +327,22 @@ func (uc *UserController) updateCurrentUserProfilePictureHandler(c *gin.Context)
func (uc *UserController) createOneTimeAccessTokenHandler(c *gin.Context, own bool) {
var input dto.OneTimeAccessTokenCreateDto
err := c.ShouldBindJSON(&input)
if err != nil {
if err := c.ShouldBindJSON(&input); err != nil {
_ = c.Error(err)
return
}
var (
userID string
ttl time.Duration
)
var ttl time.Duration
if own {
// Get user ID from context and force the default TTL
userID = c.GetString("userID")
input.UserID = c.GetString("userID")
ttl = defaultOneTimeAccessTokenDuration
} else {
// Get user ID from URL parameter, and optional TTL from body
userID = c.Param("id")
ttl = input.TTL.Duration
if ttl <= 0 {
ttl = defaultOneTimeAccessTokenDuration
}
}
if userID == "" {
_ = c.Error(&common.UserIdNotProvidedError{})
return
}
token, err := uc.oneTimeAccessService.CreateOneTimeAccessToken(c.Request.Context(), userID, ttl)
token, err := uc.userService.CreateOneTimeAccessToken(c.Request.Context(), input.UserID, ttl)
if err != nil {
_ = c.Error(err)
return
@@ -399,13 +391,12 @@ func (uc *UserController) RequestOneTimeAccessEmailAsUnauthenticatedUserHandler(
return
}
deviceToken, err := uc.oneTimeAccessService.RequestOneTimeAccessEmailAsUnauthenticatedUser(c.Request.Context(), input.Email, input.RedirectPath)
err := uc.userService.RequestOneTimeAccessEmailAsUnauthenticatedUser(c.Request.Context(), input.Email, input.RedirectPath)
if err != nil {
_ = c.Error(err)
return
}
cookie.AddDeviceTokenCookie(c, deviceToken)
c.Status(http.StatusNoContent)
}
@@ -432,7 +423,7 @@ func (uc *UserController) RequestOneTimeAccessEmailAsAdminHandler(c *gin.Context
if ttl <= 0 {
ttl = defaultOneTimeAccessTokenDuration
}
err := uc.oneTimeAccessService.RequestOneTimeAccessEmailAsAdmin(c.Request.Context(), userID, ttl)
err := uc.userService.RequestOneTimeAccessEmailAsAdmin(c.Request.Context(), userID, ttl)
if err != nil {
_ = c.Error(err)
return
@@ -449,8 +440,41 @@ func (uc *UserController) RequestOneTimeAccessEmailAsAdminHandler(c *gin.Context
// @Success 200 {object} dto.UserDto
// @Router /api/one-time-access-token/{token} [post]
func (uc *UserController) exchangeOneTimeAccessTokenHandler(c *gin.Context) {
deviceToken, _ := c.Cookie(cookie.DeviceTokenCookieName)
user, token, err := uc.oneTimeAccessService.ExchangeOneTimeAccessToken(c.Request.Context(), c.Param("token"), deviceToken, c.ClientIP(), c.Request.UserAgent())
user, token, err := uc.userService.ExchangeOneTimeAccessToken(c.Request.Context(), c.Param("token"), c.ClientIP(), c.Request.UserAgent())
if err != nil {
_ = c.Error(err)
return
}
var userDto dto.UserDto
if err := dto.MapStruct(user, &userDto); err != nil {
_ = c.Error(err)
return
}
maxAge := int(uc.appConfigService.GetDbConfig().SessionDuration.AsDurationMinutes().Seconds())
cookie.AddAccessTokenCookie(c, maxAge, token)
c.JSON(http.StatusOK, userDto)
}
// signUpInitialAdmin godoc
// @Summary Sign up initial admin user
// @Description Sign up and generate setup access token for initial admin user
// @Tags Users
// @Accept json
// @Produce json
// @Param body body dto.SignUpDto true "User information"
// @Success 200 {object} dto.UserDto
// @Router /api/signup/setup [post]
func (uc *UserController) signUpInitialAdmin(c *gin.Context) {
var input dto.SignUpDto
if err := dto.ShouldBindWithNormalizedJSON(c, &input); err != nil {
_ = c.Error(err)
return
}
user, token, err := uc.userService.SignUpInitialAdmin(c.Request.Context(), input)
if err != nil {
_ = c.Error(err)
return
@@ -498,6 +522,130 @@ func (uc *UserController) updateUserGroups(c *gin.Context) {
c.JSON(http.StatusOK, userDto)
}
// createSignupTokenHandler godoc
// @Summary Create signup token
// @Description Create a new signup token that allows user registration
// @Tags Users
// @Accept json
// @Produce json
// @Param token body dto.SignupTokenCreateDto true "Signup token information"
// @Success 201 {object} dto.SignupTokenDto
// @Router /api/signup-tokens [post]
func (uc *UserController) createSignupTokenHandler(c *gin.Context) {
var input dto.SignupTokenCreateDto
if err := c.ShouldBindJSON(&input); err != nil {
_ = c.Error(err)
return
}
ttl := input.TTL.Duration
if ttl <= 0 {
ttl = defaultSignupTokenDuration
}
signupToken, err := uc.userService.CreateSignupToken(c.Request.Context(), ttl, input.UsageLimit)
if err != nil {
_ = c.Error(err)
return
}
var tokenDto dto.SignupTokenDto
err = dto.MapStruct(signupToken, &tokenDto)
if err != nil {
_ = c.Error(err)
return
}
c.JSON(http.StatusCreated, tokenDto)
}
// listSignupTokensHandler godoc
// @Summary List signup tokens
// @Description Get a paginated list of signup tokens
// @Tags Users
// @Param pagination[page] query int false "Page number for pagination" default(1)
// @Param pagination[limit] query int false "Number of items per page" default(20)
// @Param sort[column] query string false "Column to sort by"
// @Param sort[direction] query string false "Sort direction (asc or desc)" default("asc")
// @Success 200 {object} dto.Paginated[dto.SignupTokenDto]
// @Router /api/signup-tokens [get]
func (uc *UserController) listSignupTokensHandler(c *gin.Context) {
listRequestOptions := utils.ParseListRequestOptions(c)
tokens, pagination, err := uc.userService.ListSignupTokens(c.Request.Context(), listRequestOptions)
if err != nil {
_ = c.Error(err)
return
}
var tokensDto []dto.SignupTokenDto
if err := dto.MapStructList(tokens, &tokensDto); err != nil {
_ = c.Error(err)
return
}
c.JSON(http.StatusOK, dto.Paginated[dto.SignupTokenDto]{
Data: tokensDto,
Pagination: pagination,
})
}
// deleteSignupTokenHandler godoc
// @Summary Delete signup token
// @Description Delete a signup token by ID
// @Tags Users
// @Param id path string true "Token ID"
// @Success 204 "No Content"
// @Router /api/signup-tokens/{id} [delete]
func (uc *UserController) deleteSignupTokenHandler(c *gin.Context) {
tokenID := c.Param("id")
err := uc.userService.DeleteSignupToken(c.Request.Context(), tokenID)
if err != nil {
_ = c.Error(err)
return
}
c.Status(http.StatusNoContent)
}
// signupWithTokenHandler godoc
// @Summary Sign up
// @Description Create a new user account
// @Tags Users
// @Accept json
// @Produce json
// @Param user body dto.SignUpDto true "User information"
// @Success 201 {object} dto.SignUpDto
// @Router /api/signup [post]
func (uc *UserController) signupHandler(c *gin.Context) {
var input dto.SignUpDto
if err := dto.ShouldBindWithNormalizedJSON(c, &input); err != nil {
_ = c.Error(err)
return
}
ipAddress := c.ClientIP()
userAgent := c.GetHeader("User-Agent")
user, accessToken, err := uc.userService.SignUp(c.Request.Context(), input, ipAddress, userAgent)
if err != nil {
_ = c.Error(err)
return
}
maxAge := int(uc.appConfigService.GetDbConfig().SessionDuration.AsDurationMinutes().Seconds())
cookie.AddAccessTokenCookie(c, maxAge, accessToken)
var userDto dto.UserDto
if err := dto.MapStruct(user, &userDto); err != nil {
_ = c.Error(err)
return
}
c.JSON(http.StatusCreated, userDto)
}
// updateUser is an internal helper method, not exposed as an API endpoint
func (uc *UserController) updateUser(c *gin.Context, updateOwnUser bool) {
var input dto.UserCreateDto
@@ -564,44 +712,3 @@ func (uc *UserController) resetCurrentUserProfilePictureHandler(c *gin.Context)
c.Status(http.StatusNoContent)
}
// sendEmailVerificationHandler godoc
// @Summary Send email verification
// @Description Send an email verification to the currently authenticated user
// @Tags Users
// @Produce json
// @Success 204 "No Content"
// @Router /api/users/me/send-email-verification [post]
func (uc *UserController) sendEmailVerificationHandler(c *gin.Context) {
userID := c.GetString("userID")
if err := uc.userService.SendEmailVerification(c.Request.Context(), userID); err != nil {
_ = c.Error(err)
return
}
c.Status(http.StatusNoContent)
}
// verifyEmailHandler godoc
// @Summary Verify email
// @Description Verify the currently authenticated user's email using a verification token
// @Tags Users
// @Param body body dto.EmailVerificationDto true "Email verification token"
// @Success 204 "No Content"
// @Router /api/users/me/verify-email [post]
func (uc *UserController) verifyEmailHandler(c *gin.Context) {
var input dto.EmailVerificationDto
if err := dto.ShouldBindWithNormalizedJSON(c, &input); err != nil {
_ = c.Error(err)
return
}
userID := c.GetString("userID")
if err := uc.userService.VerifyEmail(c.Request.Context(), userID, input.Token); err != nil {
_ = c.Error(err)
return
}
c.Status(http.StatusNoContent)
}

View File

@@ -28,7 +28,6 @@ func NewUserGroupController(group *gin.RouterGroup, authMiddleware *middleware.A
userGroupsGroup.PUT("/:id", ugc.update)
userGroupsGroup.DELETE("/:id", ugc.delete)
userGroupsGroup.PUT("/:id/users", ugc.updateUsers)
userGroupsGroup.PUT("/:id/allowed-oidc-clients", ugc.updateAllowedOidcClients)
}
}
@@ -45,7 +44,7 @@ type UserGroupController struct {
// @Param pagination[limit] query int false "Number of items per page" default(20)
// @Param sort[column] query string false "Column to sort by"
// @Param sort[direction] query string false "Sort direction (asc or desc)" default("asc")
// @Success 200 {object} dto.Paginated[dto.UserGroupMinimalDto]
// @Success 200 {object} dto.Paginated[dto.UserGroupDtoWithUserCount]
// @Router /api/user-groups [get]
func (ugc *UserGroupController) list(c *gin.Context) {
searchTerm := c.Query("search")
@@ -58,9 +57,9 @@ func (ugc *UserGroupController) list(c *gin.Context) {
}
// Map the user groups to DTOs
var groupsDto = make([]dto.UserGroupMinimalDto, len(groups))
var groupsDto = make([]dto.UserGroupDtoWithUserCount, len(groups))
for i, group := range groups {
var groupDto dto.UserGroupMinimalDto
var groupDto dto.UserGroupDtoWithUserCount
if err := dto.MapStruct(group, &groupDto); err != nil {
_ = c.Error(err)
return
@@ -73,7 +72,7 @@ func (ugc *UserGroupController) list(c *gin.Context) {
groupsDto[i] = groupDto
}
c.JSON(http.StatusOK, dto.Paginated[dto.UserGroupMinimalDto]{
c.JSON(http.StatusOK, dto.Paginated[dto.UserGroupDtoWithUserCount]{
Data: groupsDto,
Pagination: pagination,
})
@@ -86,7 +85,7 @@ func (ugc *UserGroupController) list(c *gin.Context) {
// @Accept json
// @Produce json
// @Param id path string true "User Group ID"
// @Success 200 {object} dto.UserGroupDto
// @Success 200 {object} dto.UserGroupDtoWithUsers
// @Router /api/user-groups/{id} [get]
func (ugc *UserGroupController) get(c *gin.Context) {
group, err := ugc.UserGroupService.Get(c.Request.Context(), c.Param("id"))
@@ -95,7 +94,7 @@ func (ugc *UserGroupController) get(c *gin.Context) {
return
}
var groupDto dto.UserGroupDto
var groupDto dto.UserGroupDtoWithUsers
if err := dto.MapStruct(group, &groupDto); err != nil {
_ = c.Error(err)
return
@@ -111,7 +110,7 @@ func (ugc *UserGroupController) get(c *gin.Context) {
// @Accept json
// @Produce json
// @Param userGroup body dto.UserGroupCreateDto true "User group information"
// @Success 201 {object} dto.UserGroupDto "Created user group"
// @Success 201 {object} dto.UserGroupDtoWithUsers "Created user group"
// @Router /api/user-groups [post]
func (ugc *UserGroupController) create(c *gin.Context) {
var input dto.UserGroupCreateDto
@@ -126,7 +125,7 @@ func (ugc *UserGroupController) create(c *gin.Context) {
return
}
var groupDto dto.UserGroupDto
var groupDto dto.UserGroupDtoWithUsers
if err := dto.MapStruct(group, &groupDto); err != nil {
_ = c.Error(err)
return
@@ -143,7 +142,7 @@ func (ugc *UserGroupController) create(c *gin.Context) {
// @Produce json
// @Param id path string true "User Group ID"
// @Param userGroup body dto.UserGroupCreateDto true "User group information"
// @Success 200 {object} dto.UserGroupDto "Updated user group"
// @Success 200 {object} dto.UserGroupDtoWithUsers "Updated user group"
// @Router /api/user-groups/{id} [put]
func (ugc *UserGroupController) update(c *gin.Context) {
var input dto.UserGroupCreateDto
@@ -158,7 +157,7 @@ func (ugc *UserGroupController) update(c *gin.Context) {
return
}
var groupDto dto.UserGroupDto
var groupDto dto.UserGroupDtoWithUsers
if err := dto.MapStruct(group, &groupDto); err != nil {
_ = c.Error(err)
return
@@ -193,7 +192,7 @@ func (ugc *UserGroupController) delete(c *gin.Context) {
// @Produce json
// @Param id path string true "User Group ID"
// @Param users body dto.UserGroupUpdateUsersDto true "List of user IDs to assign to this group"
// @Success 200 {object} dto.UserGroupDto
// @Success 200 {object} dto.UserGroupDtoWithUsers
// @Router /api/user-groups/{id}/users [put]
func (ugc *UserGroupController) updateUsers(c *gin.Context) {
var input dto.UserGroupUpdateUsersDto
@@ -208,7 +207,7 @@ func (ugc *UserGroupController) updateUsers(c *gin.Context) {
return
}
var groupDto dto.UserGroupDto
var groupDto dto.UserGroupDtoWithUsers
if err := dto.MapStruct(group, &groupDto); err != nil {
_ = c.Error(err)
return
@@ -216,35 +215,3 @@ func (ugc *UserGroupController) updateUsers(c *gin.Context) {
c.JSON(http.StatusOK, groupDto)
}
// updateAllowedOidcClients godoc
// @Summary Update allowed OIDC clients
// @Description Update the OIDC clients allowed for a specific user group
// @Tags OIDC
// @Accept json
// @Produce json
// @Param id path string true "User Group ID"
// @Param groups body dto.UserGroupUpdateAllowedOidcClientsDto true "OIDC client IDs to allow"
// @Success 200 {object} dto.UserGroupDto "Updated user group"
// @Router /api/user-groups/{id}/allowed-oidc-clients [put]
func (ugc *UserGroupController) updateAllowedOidcClients(c *gin.Context) {
var input dto.UserGroupUpdateAllowedOidcClientsDto
if err := c.ShouldBindJSON(&input); err != nil {
_ = c.Error(err)
return
}
userGroup, err := ugc.UserGroupService.UpdateAllowedOidcClient(c.Request.Context(), c.Param("id"), input)
if err != nil {
_ = c.Error(err)
return
}
var userGroupDto dto.UserGroupDto
if err := dto.MapStruct(userGroup, &userGroupDto); err != nil {
_ = c.Error(err)
return
}
c.JSON(http.StatusOK, userGroupDto)
}

View File

@@ -1,198 +0,0 @@
package controller
import (
"net/http"
"time"
"github.com/pocket-id/pocket-id/backend/internal/utils/cookie"
"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"
"github.com/pocket-id/pocket-id/backend/internal/utils"
"golang.org/x/time/rate"
)
const defaultSignupTokenDuration = time.Hour
// NewUserSignupController creates a new controller for user signup and signup token management
// @Summary User signup and signup token management controller
// @Description Initializes all user signup-related API endpoints
// @Tags Users
func NewUserSignupController(group *gin.RouterGroup, authMiddleware *middleware.AuthMiddleware, rateLimitMiddleware *middleware.RateLimitMiddleware, userSignUpService *service.UserSignUpService, appConfigService *service.AppConfigService) {
usc := UserSignupController{
userSignUpService: userSignUpService,
appConfigService: appConfigService,
}
group.POST("/signup-tokens", authMiddleware.Add(), usc.createSignupTokenHandler)
group.GET("/signup-tokens", authMiddleware.Add(), usc.listSignupTokensHandler)
group.DELETE("/signup-tokens/:id", authMiddleware.Add(), usc.deleteSignupTokenHandler)
group.POST("/signup", rateLimitMiddleware.Add(rate.Every(1*time.Minute), 10), usc.signupHandler)
group.POST("/signup/setup", usc.signUpInitialAdmin)
}
type UserSignupController struct {
userSignUpService *service.UserSignUpService
appConfigService *service.AppConfigService
}
// signUpInitialAdmin godoc
// @Summary Sign up initial admin user
// @Description Sign up and generate setup access token for initial admin user
// @Tags Users
// @Accept json
// @Produce json
// @Param body body dto.SignUpDto true "User information"
// @Success 200 {object} dto.UserDto
// @Router /api/signup/setup [post]
func (usc *UserSignupController) signUpInitialAdmin(c *gin.Context) {
var input dto.SignUpDto
if err := dto.ShouldBindWithNormalizedJSON(c, &input); err != nil {
_ = c.Error(err)
return
}
user, token, err := usc.userSignUpService.SignUpInitialAdmin(c.Request.Context(), input)
if err != nil {
_ = c.Error(err)
return
}
var userDto dto.UserDto
if err := dto.MapStruct(user, &userDto); err != nil {
_ = c.Error(err)
return
}
maxAge := int(usc.appConfigService.GetDbConfig().SessionDuration.AsDurationMinutes().Seconds())
cookie.AddAccessTokenCookie(c, maxAge, token)
c.JSON(http.StatusOK, userDto)
}
// createSignupTokenHandler godoc
// @Summary Create signup token
// @Description Create a new signup token that allows user registration
// @Tags Users
// @Accept json
// @Produce json
// @Param token body dto.SignupTokenCreateDto true "Signup token information"
// @Success 201 {object} dto.SignupTokenDto
// @Router /api/signup-tokens [post]
func (usc *UserSignupController) createSignupTokenHandler(c *gin.Context) {
var input dto.SignupTokenCreateDto
if err := c.ShouldBindJSON(&input); err != nil {
_ = c.Error(err)
return
}
ttl := input.TTL.Duration
if ttl <= 0 {
ttl = defaultSignupTokenDuration
}
signupToken, err := usc.userSignUpService.CreateSignupToken(c.Request.Context(), ttl, input.UsageLimit, input.UserGroupIDs)
if err != nil {
_ = c.Error(err)
return
}
var tokenDto dto.SignupTokenDto
err = dto.MapStruct(signupToken, &tokenDto)
if err != nil {
_ = c.Error(err)
return
}
c.JSON(http.StatusCreated, tokenDto)
}
// listSignupTokensHandler godoc
// @Summary List signup tokens
// @Description Get a paginated list of signup tokens
// @Tags Users
// @Param pagination[page] query int false "Page number for pagination" default(1)
// @Param pagination[limit] query int false "Number of items per page" default(20)
// @Param sort[column] query string false "Column to sort by"
// @Param sort[direction] query string false "Sort direction (asc or desc)" default("asc")
// @Success 200 {object} dto.Paginated[dto.SignupTokenDto]
// @Router /api/signup-tokens [get]
func (usc *UserSignupController) listSignupTokensHandler(c *gin.Context) {
listRequestOptions := utils.ParseListRequestOptions(c)
tokens, pagination, err := usc.userSignUpService.ListSignupTokens(c.Request.Context(), listRequestOptions)
if err != nil {
_ = c.Error(err)
return
}
var tokensDto []dto.SignupTokenDto
if err := dto.MapStructList(tokens, &tokensDto); err != nil {
_ = c.Error(err)
return
}
c.JSON(http.StatusOK, dto.Paginated[dto.SignupTokenDto]{
Data: tokensDto,
Pagination: pagination,
})
}
// deleteSignupTokenHandler godoc
// @Summary Delete signup token
// @Description Delete a signup token by ID
// @Tags Users
// @Param id path string true "Token ID"
// @Success 204 "No Content"
// @Router /api/signup-tokens/{id} [delete]
func (usc *UserSignupController) deleteSignupTokenHandler(c *gin.Context) {
tokenID := c.Param("id")
err := usc.userSignUpService.DeleteSignupToken(c.Request.Context(), tokenID)
if err != nil {
_ = c.Error(err)
return
}
c.Status(http.StatusNoContent)
}
// signupWithTokenHandler godoc
// @Summary Sign up
// @Description Create a new user account
// @Tags Users
// @Accept json
// @Produce json
// @Param user body dto.SignUpDto true "User information"
// @Success 201 {object} dto.SignUpDto
// @Router /api/signup [post]
func (usc *UserSignupController) signupHandler(c *gin.Context) {
var input dto.SignUpDto
if err := dto.ShouldBindWithNormalizedJSON(c, &input); err != nil {
_ = c.Error(err)
return
}
ipAddress := c.ClientIP()
userAgent := c.GetHeader("User-Agent")
user, accessToken, err := usc.userSignUpService.SignUp(c.Request.Context(), input, ipAddress, userAgent)
if err != nil {
_ = c.Error(err)
return
}
maxAge := int(usc.appConfigService.GetDbConfig().SessionDuration.AsDurationMinutes().Seconds())
cookie.AddAccessTokenCookie(c, maxAge, accessToken)
var userDto dto.UserDto
if err := dto.MapStruct(user, &userDto); err != nil {
_ = c.Error(err)
return
}
c.JSON(http.StatusCreated, userDto)
}

View File

@@ -5,17 +5,14 @@ import (
"time"
"github.com/gin-gonic/gin"
"github.com/pocket-id/pocket-id/backend/internal/common"
"github.com/pocket-id/pocket-id/backend/internal/middleware"
"github.com/pocket-id/pocket-id/backend/internal/service"
"github.com/pocket-id/pocket-id/backend/internal/utils"
)
// NewVersionController registers version-related routes.
func NewVersionController(group *gin.RouterGroup, authMiddleware *middleware.AuthMiddleware, versionService *service.VersionService) {
func NewVersionController(group *gin.RouterGroup, versionService *service.VersionService) {
vc := &VersionController{versionService: versionService}
group.GET("/version/latest", vc.getLatestVersionHandler)
group.GET("/version/current", authMiddleware.WithAdminNotRequired().Add(), vc.getCurrentVersionHandler)
}
type VersionController struct {
@@ -41,16 +38,3 @@ func (vc *VersionController) getLatestVersionHandler(c *gin.Context) {
"latestVersion": tag,
})
}
// getCurrentVersionHandler godoc
// @Summary Get current deployed version of Pocket ID
// @Tags Version
// @Produce json
// @Success 200 {object} map[string]string "Current version information"
// @Router /api/version/current [get]
func (vc *VersionController) getCurrentVersionHandler(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"currentVersion": common.Version,
})
}

View File

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

View File

@@ -10,10 +10,6 @@ type ApiKeyCreateDto struct {
ExpiresAt datatype.DateTime `json:"expiresAt" binding:"required"`
}
type ApiKeyRenewDto struct {
ExpiresAt datatype.DateTime `json:"expiresAt" binding:"required"`
}
type ApiKeyDto struct {
ID string `json:"id"`
Name string `json:"name"`

View File

@@ -14,7 +14,6 @@ type AppConfigVariableDto struct {
type AppConfigUpdateDto struct {
AppName string `json:"appName" binding:"required,min=1,max=30" unorm:"nfc"`
SessionDuration string `json:"sessionDuration" binding:"required"`
HomePageURL string `json:"homePageUrl" binding:"required"`
EmailsVerified string `json:"emailsVerified" binding:"required"`
DisableAnimations string `json:"disableAnimations" binding:"required"`
AllowOwnAccountEdit string `json:"allowOwnAccountEdit" binding:"required"`
@@ -54,5 +53,4 @@ type AppConfigUpdateDto struct {
EmailOneTimeAccessAsUnauthenticatedEnabled string `json:"emailOneTimeAccessAsUnauthenticatedEnabled" binding:"required"`
EmailLoginNotificationEnabled string `json:"emailLoginNotificationEnabled" binding:"required"`
EmailApiKeyExpirationEnabled string `json:"emailApiKeyExpirationEnabled" binding:"required"`
EmailVerificationEnabled string `json:"emailVerificationEnabled" binding:"required"`
}

View File

@@ -9,6 +9,7 @@ import (
"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/utils"
)
type sourceStruct struct {
@@ -59,11 +60,11 @@ type embeddedStruct struct {
func TestMapStruct(t *testing.T) {
src := sourceStruct{
AString: "abcd",
AStringPtr: new("xyz"),
AStringPtr: utils.Ptr("xyz"),
ABool: true,
ABoolPtr: new(false),
ABoolPtr: utils.Ptr(false),
ACustomDateTime: datatype.DateTime(time.Date(2025, 1, 2, 3, 4, 5, 0, time.UTC)),
ACustomDateTimePtr: new(datatype.DateTime(time.Date(2024, 1, 2, 3, 4, 5, 0, time.UTC))),
ACustomDateTimePtr: utils.Ptr(datatype.DateTime(time.Date(2024, 1, 2, 3, 4, 5, 0, time.UTC))),
ANilStringPtr: nil,
ASlice: []string{"a", "b", "c"},
AMap: map[string]int{
@@ -79,8 +80,8 @@ func TestMapStruct(t *testing.T) {
Bar: 111,
},
StringPtrToString: new("foobar"),
EmptyStringPtrToString: new(""),
StringPtrToString: utils.Ptr("foobar"),
EmptyStringPtrToString: utils.Ptr(""),
NilStringPtrToString: nil,
IntToInt64: 99,
AuditLogEventToString: model.AuditLogEventAccountCreated,
@@ -117,11 +118,11 @@ func TestMapStructList(t *testing.T) {
sources := []sourceStruct{
{
AString: "first",
AStringPtr: new("one"),
AStringPtr: utils.Ptr("one"),
ABool: true,
ABoolPtr: new(false),
ABoolPtr: utils.Ptr(false),
ACustomDateTime: datatype.DateTime(time.Date(2025, 1, 2, 3, 4, 5, 0, time.UTC)),
ACustomDateTimePtr: new(datatype.DateTime(time.Date(2024, 1, 2, 3, 4, 5, 0, time.UTC))),
ACustomDateTimePtr: utils.Ptr(datatype.DateTime(time.Date(2024, 1, 2, 3, 4, 5, 0, time.UTC))),
ASlice: []string{"a", "b"},
AMap: map[string]int{
"a": 1,
@@ -135,11 +136,11 @@ func TestMapStructList(t *testing.T) {
},
{
AString: "second",
AStringPtr: new("two"),
AStringPtr: utils.Ptr("two"),
ABool: false,
ABoolPtr: new(true),
ABoolPtr: utils.Ptr(true),
ACustomDateTime: datatype.DateTime(time.Date(2026, 6, 7, 8, 9, 10, 0, time.UTC)),
ACustomDateTimePtr: new(datatype.DateTime(time.Date(2023, 6, 7, 8, 9, 10, 0, time.UTC))),
ACustomDateTimePtr: utils.Ptr(datatype.DateTime(time.Date(2023, 6, 7, 8, 9, 10, 0, time.UTC))),
ASlice: []string{"c", "d", "e"},
AMap: map[string]int{
"c": 3,

View File

@@ -12,7 +12,7 @@ import (
// Normalize iterates through an object and performs Unicode normalization on all string fields with the `unorm` tag.
func Normalize(obj any) {
v := reflect.ValueOf(obj)
if v.Kind() != reflect.Pointer || v.IsNil() {
if v.Kind() != reflect.Ptr || v.IsNil() {
return
}
v = v.Elem()
@@ -21,7 +21,7 @@ func Normalize(obj any) {
if v.Kind() == reflect.Slice {
for i := 0; i < v.Len(); i++ {
elem := v.Index(i)
if elem.Kind() == reflect.Pointer && !elem.IsNil() && elem.Elem().Kind() == reflect.Struct {
if elem.Kind() == reflect.Ptr && !elem.IsNil() && elem.Elem().Kind() == reflect.Struct {
Normalize(elem.Interface())
} else if elem.Kind() == reflect.Struct && elem.CanAddr() {
Normalize(elem.Addr().Interface())

View File

@@ -18,12 +18,11 @@ type OidcClientDto struct {
IsPublic bool `json:"isPublic"`
PkceEnabled bool `json:"pkceEnabled"`
Credentials OidcClientCredentialsDto `json:"credentials"`
IsGroupRestricted bool `json:"isGroupRestricted"`
}
type OidcClientWithAllowedUserGroupsDto struct {
OidcClientDto
AllowedUserGroups []UserGroupMinimalDto `json:"allowedUserGroups"`
AllowedUserGroups []UserGroupDtoWithUserCount `json:"allowedUserGroups"`
}
type OidcClientWithAllowedGroupsCountDto struct {
@@ -44,7 +43,6 @@ type OidcClientUpdateDto struct {
HasDarkLogo bool `json:"hasDarkLogo"`
LogoURL *string `json:"logoUrl"`
DarkLogoURL *string `json:"darkLogoUrl"`
IsGroupRestricted bool `json:"isGroupRestricted"`
}
type OidcClientCreateDto struct {
@@ -98,8 +96,7 @@ type OidcCreateTokensDto struct {
}
type OidcIntrospectDto struct {
Token string `form:"token" binding:"required"`
ClientID string `form:"client_id"`
Token string `form:"token" binding:"required"`
}
type OidcUpdateAllowedUserGroupsDto struct {
@@ -140,7 +137,6 @@ type OidcDeviceAuthorizationRequestDto struct {
ClientSecret string `form:"client_secret"`
ClientAssertion string `form:"client_assertion"`
ClientAssertionType string `form:"client_assertion_type"`
Nonce string `form:"nonce"`
}
type OidcDeviceAuthorizationResponseDto struct {
@@ -182,3 +178,26 @@ type AccessibleOidcClientDto struct {
OidcClientMetaDataDto
LastUsedAt *datatype.DateTime `json:"lastUsedAt"`
}
type OidcAPIDto struct {
ID string `json:"id"`
Name string `json:"name"`
Identifier string `json:"identifier"`
Permissions []OidcAPIPermissionDto `json:"permissions"`
CreatedAt datatype.DateTime `json:"createdAt"`
}
type OidcAPIPermissionDto struct {
Name string `json:"name"`
Description string `json:"description"`
}
type OidcAPICreateDto struct {
Name string `json:"name" binding:"required,max=100" unorm:"nfc"`
}
type OidcAPIUpdateDto struct {
Name string `json:"name" binding:"required,max=100" unorm:"nfc"`
Identifier string `json:"identifier" binding:"omitempty,url,max=255"`
Permissions []OidcAPIPermissionDto `json:"permissions" binding:"omitempty,dive"`
}

View File

@@ -1,16 +0,0 @@
package dto
import "github.com/pocket-id/pocket-id/backend/internal/utils"
type OneTimeAccessTokenCreateDto struct {
TTL utils.JSONDuration `json:"ttl" binding:"ttl"`
}
type OneTimeAccessEmailAsUnauthenticatedUserDto struct {
Email string `json:"email" binding:"required,email" unorm:"nfc"`
RedirectPath string `json:"redirectPath"`
}
type OneTimeAccessEmailAsAdminDto struct {
TTL utils.JSONDuration `json:"ttl" binding:"ttl"`
}

View File

@@ -1,96 +0,0 @@
package dto
import (
"time"
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
)
type ScimServiceProviderDTO struct {
ID string `json:"id"`
Endpoint string `json:"endpoint"`
Token string `json:"token"`
LastSyncedAt *datatype.DateTime `json:"lastSyncedAt"`
OidcClient OidcClientMetaDataDto `json:"oidcClient"`
CreatedAt datatype.DateTime `json:"createdAt"`
}
type ScimServiceProviderCreateDTO struct {
Endpoint string `json:"endpoint" binding:"required,url"`
Token string `json:"token"`
OidcClientID string `json:"oidcClientId" binding:"required"`
}
type ScimUser struct {
ScimResourceData
UserName string `json:"userName"`
Name *ScimName `json:"name,omitempty"`
Display string `json:"displayName,omitempty"`
Active bool `json:"active"`
Emails []ScimEmail `json:"emails,omitempty"`
}
type ScimName struct {
GivenName string `json:"givenName,omitempty"`
FamilyName string `json:"familyName,omitempty"`
}
type ScimEmail struct {
Value string `json:"value"`
Primary bool `json:"primary,omitempty"`
}
type ScimGroup struct {
ScimResourceData
Display string `json:"displayName"`
Members []ScimGroupMember `json:"members,omitempty"`
}
type ScimGroupMember struct {
Value string `json:"value"`
}
type ScimListResponse[T any] struct {
Resources []T `json:"Resources"`
TotalResults int `json:"totalResults"`
StartIndex int `json:"startIndex"`
ItemsPerPage int `json:"itemsPerPage"`
}
type ScimResourceData struct {
ID string `json:"id,omitempty"`
ExternalID string `json:"externalId,omitempty"`
Schemas []string `json:"schemas"`
Meta ScimResourceMeta `json:"meta,omitempty"`
}
type ScimResourceMeta struct {
Location string `json:"location,omitempty"`
ResourceType string `json:"resourceType,omitempty"`
Created time.Time `json:"created"`
LastModified time.Time `json:"lastModified,omitempty"`
Version string `json:"version,omitempty"`
}
func (r ScimResourceData) GetID() string {
return r.ID
}
func (r ScimResourceData) GetExternalID() string {
return r.ExternalID
}
func (r ScimResourceData) GetSchemas() []string {
return r.Schemas
}
func (r ScimResourceData) GetMeta() ScimResourceMeta {
return r.Meta
}
type ScimResource interface {
GetID() string
GetExternalID() string
GetSchemas() []string
GetMeta() ScimResourceMeta
}

View File

@@ -1,9 +0,0 @@
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"`
}

View File

@@ -6,17 +6,15 @@ import (
)
type SignupTokenCreateDto struct {
TTL utils.JSONDuration `json:"ttl" binding:"required,ttl"`
UsageLimit int `json:"usageLimit" binding:"required,min=1,max=100"`
UserGroupIDs []string `json:"userGroupIds"`
TTL utils.JSONDuration `json:"ttl" binding:"required,ttl"`
UsageLimit int `json:"usageLimit" binding:"required,min=1,max=100"`
}
type SignupTokenDto struct {
ID string `json:"id"`
Token string `json:"token"`
ExpiresAt datatype.DateTime `json:"expiresAt"`
UsageLimit int `json:"usageLimit"`
UsageCount int `json:"usageCount"`
UserGroups []UserGroupMinimalDto `json:"userGroups"`
CreatedAt datatype.DateTime `json:"createdAt"`
ID string `json:"id"`
Token string `json:"token"`
ExpiresAt datatype.DateTime `json:"expiresAt"`
UsageLimit int `json:"usageLimit"`
UsageCount int `json:"usageCount"`
CreatedAt datatype.DateTime `json:"createdAt"`
}

View File

@@ -4,36 +4,34 @@ import (
"errors"
"github.com/gin-gonic/gin/binding"
"github.com/pocket-id/pocket-id/backend/internal/utils"
)
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" `
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 []UserGroupDto `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=2,max=50" unorm:"nfc"`
Email *string `json:"email" binding:"omitempty,email" unorm:"nfc"`
FirstName string `json:"firstName" binding:"required,min=1,max=50" unorm:"nfc"`
LastName string `json:"lastName" binding:"max=50" unorm:"nfc"`
DisplayName string `json:"displayName" binding:"required,min=1,max=100" unorm:"nfc"`
IsAdmin bool `json:"isAdmin"`
Locale *string `json:"locale"`
Disabled bool `json:"disabled"`
LdapID string `json:"-"`
}
func (u UserCreateDto) Validate() error {
@@ -47,10 +45,28 @@ func (u UserCreateDto) Validate() error {
return e.Struct(u)
}
type EmailVerificationDto struct {
Token string `json:"token" binding:"required"`
type OneTimeAccessTokenCreateDto struct {
UserID string `json:"userId"`
TTL utils.JSONDuration `json:"ttl" binding:"ttl"`
}
type OneTimeAccessEmailAsUnauthenticatedUserDto struct {
Email string `json:"email" binding:"required,email" unorm:"nfc"`
RedirectPath string `json:"redirectPath"`
}
type OneTimeAccessEmailAsAdminDto struct {
TTL utils.JSONDuration `json:"ttl" binding:"ttl"`
}
type UserUpdateUserGroupDto struct {
UserGroupIds []string `json:"userGroupIds" binding:"required"`
}
type SignUpDto struct {
Username string `json:"username" binding:"required,username,min=2,max=50" unorm:"nfc"`
Email *string `json:"email" binding:"omitempty,email" unorm:"nfc"`
FirstName string `json:"firstName" binding:"required,min=1,max=50" unorm:"nfc"`
LastName string `json:"lastName" binding:"max=50" unorm:"nfc"`
Token string `json:"token"`
}

View File

@@ -3,6 +3,7 @@ package dto
import (
"testing"
"github.com/pocket-id/pocket-id/backend/internal/utils"
"github.com/stretchr/testify/require"
)
@@ -16,7 +17,7 @@ func TestUserCreateDto_Validate(t *testing.T) {
name: "valid input",
input: UserCreateDto{
Username: "testuser",
Email: new("test@example.com"),
Email: utils.Ptr("test@example.com"),
FirstName: "John",
LastName: "Doe",
DisplayName: "John Doe",
@@ -26,37 +27,27 @@ func TestUserCreateDto_Validate(t *testing.T) {
{
name: "missing username",
input: UserCreateDto{
Email: new("test@example.com"),
Email: utils.Ptr("test@example.com"),
FirstName: "John",
LastName: "Doe",
DisplayName: "John Doe",
},
wantErr: "Field validation for 'Username' failed on the 'required' tag",
},
{
name: "missing first name",
input: UserCreateDto{
Username: "testuser",
Email: new("test@example.com"),
LastName: "Doe",
},
wantErr: "",
},
{
name: "missing display name",
input: UserCreateDto{
Username: "testuser",
Email: new("test@example.com"),
Email: utils.Ptr("test@example.com"),
FirstName: "John",
LastName: "Doe",
},
wantErr: "",
wantErr: "Field validation for 'DisplayName' failed on the 'required' tag",
},
{
name: "username contains invalid characters",
input: UserCreateDto{
Username: "test/ser",
Email: new("test@example.com"),
Email: utils.Ptr("test@example.com"),
FirstName: "John",
LastName: "Doe",
DisplayName: "John Doe",
@@ -67,7 +58,7 @@ func TestUserCreateDto_Validate(t *testing.T) {
name: "invalid email",
input: UserCreateDto{
Username: "testuser",
Email: new("not-an-email"),
Email: utils.Ptr("not-an-email"),
FirstName: "John",
LastName: "Doe",
DisplayName: "John Doe",
@@ -78,18 +69,18 @@ func TestUserCreateDto_Validate(t *testing.T) {
name: "first name too short",
input: UserCreateDto{
Username: "testuser",
Email: new("test@example.com"),
Email: utils.Ptr("test@example.com"),
FirstName: "",
LastName: "Doe",
DisplayName: "John Doe",
},
wantErr: "",
wantErr: "Field validation for 'FirstName' failed on the 'required' tag",
},
{
name: "last name too long",
input: UserCreateDto{
Username: "testuser",
Email: new("test@example.com"),
Email: utils.Ptr("test@example.com"),
FirstName: "John",
LastName: "abcdfghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyz",
DisplayName: "John Doe",

View File

@@ -8,17 +8,25 @@ import (
)
type UserGroupDto struct {
ID string `json:"id"`
FriendlyName string `json:"friendlyName"`
Name string `json:"name"`
CustomClaims []CustomClaimDto `json:"customClaims"`
LdapID *string `json:"ldapId"`
CreatedAt datatype.DateTime `json:"createdAt"`
Users []UserDto `json:"users"`
AllowedOidcClients []OidcClientMetaDataDto `json:"allowedOidcClients"`
ID string `json:"id"`
FriendlyName string `json:"friendlyName"`
Name string `json:"name"`
CustomClaims []CustomClaimDto `json:"customClaims"`
LdapID *string `json:"ldapId"`
CreatedAt datatype.DateTime `json:"createdAt"`
}
type UserGroupMinimalDto struct {
type UserGroupDtoWithUsers struct {
ID string `json:"id"`
FriendlyName string `json:"friendlyName"`
Name string `json:"name"`
CustomClaims []CustomClaimDto `json:"customClaims"`
Users []UserDto `json:"users"`
LdapID *string `json:"ldapId"`
CreatedAt datatype.DateTime `json:"createdAt"`
}
type UserGroupDtoWithUserCount struct {
ID string `json:"id"`
FriendlyName string `json:"friendlyName"`
Name string `json:"name"`
@@ -28,10 +36,6 @@ type UserGroupMinimalDto struct {
CreatedAt datatype.DateTime `json:"createdAt"`
}
type UserGroupUpdateAllowedOidcClientsDto struct {
OidcClientIDs []string `json:"oidcClientIds" binding:"required"`
}
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"`

View File

@@ -1,7 +1,9 @@
package dto
import (
"net/url"
"regexp"
"strings"
"time"
"github.com/pocket-id/pocket-id/backend/internal/utils"
@@ -13,8 +15,7 @@ import (
// [a-zA-Z0-9] : The username must start with an alphanumeric character
// [a-zA-Z0-9_.@-]* : The rest of the username can contain alphanumeric characters, dots, underscores, hyphens, and "@" symbols
// [a-zA-Z0-9]$ : The username must end with an alphanumeric character
// (...)? : This allows single-character usernames (just one alphanumeric character)
var validateUsernameRegex = regexp.MustCompile("^[a-zA-Z0-9]([a-zA-Z0-9_.@-]*[a-zA-Z0-9])?$")
var validateUsernameRegex = regexp.MustCompile("^[a-zA-Z0-9][a-zA-Z0-9_.@-]*[a-zA-Z0-9]$")
var validateClientIDRegex = regexp.MustCompile("^[a-zA-Z0-9._-]+$")
@@ -66,6 +67,19 @@ func ValidateClientID(clientID string) bool {
// ValidateCallbackURL validates callback URLs with support for wildcards
func ValidateCallbackURL(raw string) bool {
err := utils.ValidateCallbackURLPattern(raw)
return err == nil
// Don't validate if it contains a wildcard
if strings.Contains(raw, "*") {
return true
}
u, err := url.Parse(raw)
if err != nil {
return false
}
if !u.IsAbs() {
return false
}
return true
}

View File

@@ -20,7 +20,6 @@ func TestValidateUsername(t *testing.T) {
{"starts with symbol", ".username", false},
{"ends with non-alphanumeric", "username-", false},
{"contains space", "user name", false},
{"valid single char", "a", true},
{"empty", "", false},
{"only special chars", "-._@", false},
{"valid long", "a1234567890_b.c-d@e", true},

View File

@@ -28,7 +28,7 @@ func (s *Scheduler) RegisterAnalyticsJob(ctx context.Context, appConfig *service
appConfig: appConfig,
httpClient: httpClient,
}
return s.RegisterJob(ctx, "SendHeartbeat", gocron.DurationJob(24*time.Hour), jobs.sendHeartbeat, service.RegisterJobOpts{RunImmediately: true})
return s.registerJob(ctx, "SendHeartbeat", gocron.DurationJob(24*time.Hour), jobs.sendHeartbeat, true)
}
type AnalyticsJob struct {

View File

@@ -22,7 +22,7 @@ func (s *Scheduler) RegisterApiKeyExpiryJob(ctx context.Context, apiKeyService *
}
// Send every day at midnight
return s.RegisterJob(ctx, "ExpiredApiKeyEmailJob", gocron.CronJob("0 0 * * *", false), jobs.checkAndNotifyExpiringApiKeys, service.RegisterJobOpts{})
return s.registerJob(ctx, "ExpiredApiKeyEmailJob", gocron.CronJob("0 0 * * *", false), jobs.checkAndNotifyExpiringApiKeys, false)
}
func (j *ApiKeyEmailJobs) checkAndNotifyExpiringApiKeys(ctx context.Context) error {
@@ -42,11 +42,7 @@ func (j *ApiKeyEmailJobs) checkAndNotifyExpiringApiKeys(ctx context.Context) err
}
err = j.apiKeyService.SendApiKeyExpiringSoonEmail(ctx, key)
if err != nil {
slog.ErrorContext(ctx, "Failed to send expiring API key notification email",
slog.String("key", key.ID),
slog.String("user", key.User.ID),
slog.Any("error", err),
)
slog.ErrorContext(ctx, "Failed to send expiring API key notification email", slog.String("key", key.ID), slog.Any("error", err))
}
}
return nil

View File

@@ -7,37 +7,26 @@ import (
"log/slog"
"time"
backoff "github.com/cenkalti/backoff/v5"
"github.com/go-co-op/gocron/v2"
"gorm.io/gorm"
"github.com/pocket-id/pocket-id/backend/internal/common"
"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/service"
)
func (s *Scheduler) RegisterDbCleanupJobs(ctx context.Context, db *gorm.DB) error {
jobs := &DbCleanupJobs{db: db}
newBackOff := func() *backoff.ExponentialBackOff {
bo := backoff.NewExponentialBackOff()
bo.Multiplier = 4
bo.RandomizationFactor = 0.1
bo.InitialInterval = time.Second
bo.MaxInterval = 45 * time.Second
return bo
}
// Use exponential backoff for each DB cleanup job so transient query failures are retried automatically rather than causing an immediate job failure
// Run every 24 hours (but with some jitter so they don't run at the exact same time), and now
def := gocron.DurationRandomJob(24*time.Hour-2*time.Minute, 24*time.Hour+2*time.Minute)
return errors.Join(
s.RegisterJob(ctx, "ClearWebauthnSessions", jobDefWithJitter(24*time.Hour), jobs.clearWebauthnSessions, service.RegisterJobOpts{RunImmediately: true, BackOff: newBackOff()}),
s.RegisterJob(ctx, "ClearOneTimeAccessTokens", jobDefWithJitter(24*time.Hour), jobs.clearOneTimeAccessTokens, service.RegisterJobOpts{RunImmediately: true, BackOff: newBackOff()}),
s.RegisterJob(ctx, "ClearSignupTokens", jobDefWithJitter(24*time.Hour), jobs.clearSignupTokens, service.RegisterJobOpts{RunImmediately: true, BackOff: newBackOff()}),
s.RegisterJob(ctx, "ClearEmailVerificationTokens", jobDefWithJitter(24*time.Hour), jobs.clearEmailVerificationTokens, service.RegisterJobOpts{RunImmediately: true, BackOff: newBackOff()}),
s.RegisterJob(ctx, "ClearOidcAuthorizationCodes", jobDefWithJitter(24*time.Hour), jobs.clearOidcAuthorizationCodes, service.RegisterJobOpts{RunImmediately: true, BackOff: newBackOff()}),
s.RegisterJob(ctx, "ClearOidcRefreshTokens", jobDefWithJitter(24*time.Hour), jobs.clearOidcRefreshTokens, service.RegisterJobOpts{RunImmediately: true, BackOff: newBackOff()}),
s.RegisterJob(ctx, "ClearReauthenticationTokens", jobDefWithJitter(24*time.Hour), jobs.clearReauthenticationTokens, service.RegisterJobOpts{RunImmediately: true, BackOff: newBackOff()}),
s.RegisterJob(ctx, "ClearAuditLogs", jobDefWithJitter(24*time.Hour), jobs.clearAuditLogs, service.RegisterJobOpts{RunImmediately: true, BackOff: newBackOff()}),
s.registerJob(ctx, "ClearWebauthnSessions", def, jobs.clearWebauthnSessions, true),
s.registerJob(ctx, "ClearOneTimeAccessTokens", def, jobs.clearOneTimeAccessTokens, true),
s.registerJob(ctx, "ClearSignupTokens", def, jobs.clearSignupTokens, true),
s.registerJob(ctx, "ClearOidcAuthorizationCodes", def, jobs.clearOidcAuthorizationCodes, true),
s.registerJob(ctx, "ClearOidcRefreshTokens", def, jobs.clearOidcRefreshTokens, true),
s.registerJob(ctx, "ClearReauthenticationTokens", def, jobs.clearReauthenticationTokens, true),
s.registerJob(ctx, "ClearAuditLogs", def, jobs.clearAuditLogs, true),
)
}
@@ -130,13 +119,11 @@ func (j *DbCleanupJobs) clearReauthenticationTokens(ctx context.Context) error {
return nil
}
// ClearAuditLogs deletes audit logs older than the configured retention window
// ClearAuditLogs deletes audit logs older than 90 days
func (j *DbCleanupJobs) clearAuditLogs(ctx context.Context) error {
cutoff := time.Now().AddDate(0, 0, -common.EnvConfig.AuditLogRetentionDays)
st := j.db.
WithContext(ctx).
Delete(&model.AuditLog{}, "created_at < ?", datatype.DateTime(cutoff))
Delete(&model.AuditLog{}, "created_at < ?", datatype.DateTime(time.Now().AddDate(0, 0, -90)))
if st.Error != nil {
return fmt.Errorf("failed to delete old audit logs: %w", st.Error)
}
@@ -145,16 +132,3 @@ func (j *DbCleanupJobs) clearAuditLogs(ctx context.Context) error {
return nil
}
// ClearEmailVerificationTokens deletes email verification tokens that have expired
func (j *DbCleanupJobs) clearEmailVerificationTokens(ctx context.Context) error {
st := j.db.
WithContext(ctx).
Delete(&model.EmailVerificationToken{}, "expires_at < ?", datatype.DateTime(time.Now()))
if st.Error != nil {
return fmt.Errorf("failed to clean expired email verification tokens: %w", st.Error)
}
slog.InfoContext(ctx, "Cleaned expired email verification tokens", slog.Int64("count", st.RowsAffected))
return nil
}

View File

@@ -13,26 +13,20 @@ import (
"gorm.io/gorm"
"github.com/pocket-id/pocket-id/backend/internal/model"
"github.com/pocket-id/pocket-id/backend/internal/service"
"github.com/pocket-id/pocket-id/backend/internal/storage"
)
func (s *Scheduler) RegisterFileCleanupJobs(ctx context.Context, db *gorm.DB, fileStorage storage.FileStorage) error {
jobs := &FileCleanupJobs{db: db, fileStorage: fileStorage}
var errs []error
errs = append(errs,
s.RegisterJob(ctx, "ClearUnusedDefaultProfilePictures", gocron.DurationJob(24*time.Hour), jobs.clearUnusedDefaultProfilePictures, service.RegisterJobOpts{}),
)
err := s.registerJob(ctx, "ClearUnusedDefaultProfilePictures", gocron.DurationJob(24*time.Hour), jobs.clearUnusedDefaultProfilePictures, false)
// Only necessary for file system storage
if fileStorage.Type() == storage.TypeFileSystem {
errs = append(errs,
s.RegisterJob(ctx, "ClearOrphanedTempFiles", gocron.DurationJob(12*time.Hour), jobs.clearOrphanedTempFiles, service.RegisterJobOpts{RunImmediately: true}),
)
err = errors.Join(err, s.registerJob(ctx, "ClearOrphanedTempFiles", gocron.DurationJob(12*time.Hour), jobs.clearOrphanedTempFiles, true))
}
return errors.Join(errs...)
return err
}
type FileCleanupJobs struct {
@@ -74,8 +68,7 @@ func (j *FileCleanupJobs) clearUnusedDefaultProfilePictures(ctx context.Context)
// If these initials aren't used by any user, delete the file
if _, ok := initialsInUse[initials]; !ok {
filePath := path.Join(defaultPicturesDir, filename)
err = j.fileStorage.Delete(ctx, filePath)
if err != nil {
if err := j.fileStorage.Delete(ctx, filePath); err != nil {
slog.ErrorContext(ctx, "Failed to delete unused default profile picture", slog.String("path", filePath), slog.Any("error", err))
} else {
filesDeleted++
@@ -102,9 +95,8 @@ func (j *FileCleanupJobs) clearOrphanedTempFiles(ctx context.Context) error {
return nil
}
rErr := j.fileStorage.Delete(ctx, p.Path)
if rErr != nil {
slog.ErrorContext(ctx, "Failed to delete temp file", slog.String("path", p.Path), slog.Any("error", rErr))
if err := j.fileStorage.Delete(ctx, p.Path); err != nil {
slog.ErrorContext(ctx, "Failed to delete temp file", slog.String("path", p.Path), slog.Any("error", err))
return nil
}
deleted++

View File

@@ -23,7 +23,7 @@ func (s *Scheduler) RegisterGeoLiteUpdateJobs(ctx context.Context, geoLiteServic
jobs := &GeoLiteUpdateJobs{geoLiteService: geoLiteService}
// Run every 24 hours (and right away)
return s.RegisterJob(ctx, "UpdateGeoLiteDB", gocron.DurationJob(24*time.Hour), jobs.updateGoeLiteDB, service.RegisterJobOpts{RunImmediately: true})
return s.registerJob(ctx, "UpdateGeoLiteDB", gocron.DurationJob(24*time.Hour), jobs.updateGoeLiteDB, true)
}
func (j *GeoLiteUpdateJobs) updateGoeLiteDB(ctx context.Context) error {

View File

@@ -4,6 +4,8 @@ import (
"context"
"time"
"github.com/go-co-op/gocron/v2"
"github.com/pocket-id/pocket-id/backend/internal/service"
)
@@ -15,8 +17,8 @@ type LdapJobs struct {
func (s *Scheduler) RegisterLdapJobs(ctx context.Context, ldapService *service.LdapService, appConfigService *service.AppConfigService) error {
jobs := &LdapJobs{ldapService: ldapService, appConfigService: appConfigService}
// Register the job to run every hour (with some jitter)
return s.RegisterJob(ctx, "SyncLdap", jobDefWithJitter(time.Hour), jobs.syncLdap, service.RegisterJobOpts{RunImmediately: true})
// Register the job to run every hour
return s.registerJob(ctx, "SyncLdap", gocron.DurationJob(time.Hour), jobs.syncLdap, true)
}
func (j *LdapJobs) syncLdap(ctx context.Context) error {

View File

@@ -2,16 +2,11 @@ package job
import (
"context"
"errors"
"fmt"
"log/slog"
"time"
backoff "github.com/cenkalti/backoff/v5"
"github.com/go-co-op/gocron/v2"
"github.com/google/uuid"
"github.com/pocket-id/pocket-id/backend/internal/service"
)
type Scheduler struct {
@@ -29,22 +24,6 @@ func NewScheduler() (*Scheduler, error) {
}, nil
}
func (s *Scheduler) RemoveJob(name string) error {
jobs := s.scheduler.Jobs()
var errs []error
for _, job := range jobs {
if job.Name() == name {
err := s.scheduler.RemoveJob(job.ID())
if err != nil {
errs = append(errs, fmt.Errorf("failed to dequeue job %q with ID %q: %w", name, job.ID().String(), err))
}
}
}
return errors.Join(errs...)
}
// Run the scheduler.
// This function blocks until the context is canceled.
func (s *Scheduler) Run(ctx context.Context) error {
@@ -64,32 +43,9 @@ func (s *Scheduler) Run(ctx context.Context) error {
return nil
}
func (s *Scheduler) RegisterJob(ctx context.Context, name string, def gocron.JobDefinition, jobFn func(ctx context.Context) error, opts service.RegisterJobOpts) error {
// If a BackOff strategy is provided, wrap the job with retry logic
if opts.BackOff != nil {
origJob := jobFn
jobFn = func(ctx context.Context) error {
_, err := backoff.Retry(
ctx,
func() (struct{}, error) {
return struct{}{}, origJob(ctx)
},
backoff.WithBackOff(opts.BackOff),
backoff.WithNotify(func(err error, d time.Duration) {
slog.WarnContext(ctx, "Job failed, retrying",
slog.String("name", name),
slog.Any("error", err),
slog.Duration("retryIn", d),
)
}),
)
return err
}
}
func (s *Scheduler) registerJob(ctx context.Context, name string, def gocron.JobDefinition, job func(ctx context.Context) error, runImmediately bool) error {
jobOptions := []gocron.JobOption{
gocron.WithContext(ctx),
gocron.WithName(name),
gocron.WithEventListeners(
gocron.BeforeJobRuns(func(jobID uuid.UUID, jobName string) {
slog.Info("Starting job",
@@ -113,13 +69,11 @@ func (s *Scheduler) RegisterJob(ctx context.Context, name string, def gocron.Job
),
}
if opts.RunImmediately {
if runImmediately {
jobOptions = append(jobOptions, gocron.JobOption(gocron.WithStartImmediately()))
}
jobOptions = append(jobOptions, opts.ExtraOptions...)
_, err := s.scheduler.NewJob(def, gocron.NewTask(jobFn), jobOptions...)
_, err := s.scheduler.NewJob(def, gocron.NewTask(job), jobOptions...)
if err != nil {
return fmt.Errorf("failed to register job %q: %w", name, err)
@@ -127,9 +81,3 @@ func (s *Scheduler) RegisterJob(ctx context.Context, name string, def gocron.Job
return nil
}
func jobDefWithJitter(interval time.Duration) gocron.JobDefinition {
const jitter = 5 * time.Minute
return gocron.DurationRandomJob(interval-jitter, interval+jitter)
}

View File

@@ -1,25 +0,0 @@
package job
import (
"context"
"time"
"github.com/go-co-op/gocron/v2"
"github.com/pocket-id/pocket-id/backend/internal/service"
)
type ScimJobs struct {
scimService *service.ScimService
}
func (s *Scheduler) RegisterScimJobs(ctx context.Context, scimService *service.ScimService) error {
jobs := &ScimJobs{scimService: scimService}
// Register the job to run every hour (with some jitter)
return s.RegisterJob(ctx, "SyncScim", gocron.DurationJob(time.Hour), jobs.SyncScim, service.RegisterJobOpts{RunImmediately: true})
}
func (j *ScimJobs) SyncScim(ctx context.Context) error {
return j.scimService.SyncAll(ctx)
}

View File

@@ -34,7 +34,7 @@ func (m *ApiKeyAuthMiddleware) Add(adminRequired bool) gin.HandlerFunc {
}
func (m *ApiKeyAuthMiddleware) Verify(c *gin.Context, adminRequired bool) (userID string, isAdmin bool, err error) {
apiKey := c.GetHeader("X-API-Key")
apiKey := c.GetHeader("X-API-KEY")
user, err := m.apiKeyService.ValidateApiKey(c.Request.Context(), apiKey)
if err != nil {

View File

@@ -18,7 +18,6 @@ type AuthMiddleware struct {
type AuthOptions struct {
AdminRequired bool
SuccessOptional bool
AllowApiKeyAuth bool
}
func NewAuthMiddleware(
@@ -32,7 +31,6 @@ func NewAuthMiddleware(
options: AuthOptions{
AdminRequired: true,
SuccessOptional: false,
AllowApiKeyAuth: true,
},
}
}
@@ -61,17 +59,6 @@ func (m *AuthMiddleware) WithSuccessOptional() *AuthMiddleware {
return clone
}
// WithApiKeyAuthDisabled disables API key authentication fallback and requires JWT auth.
func (m *AuthMiddleware) WithApiKeyAuthDisabled() *AuthMiddleware {
clone := &AuthMiddleware{
apiKeyMiddleware: m.apiKeyMiddleware,
jwtMiddleware: m.jwtMiddleware,
options: m.options,
}
clone.options.AllowApiKeyAuth = false
return clone
}
func (m *AuthMiddleware) Add() gin.HandlerFunc {
return func(c *gin.Context) {
userID, isAdmin, err := m.jwtMiddleware.Verify(c, m.options.AdminRequired)
@@ -92,21 +79,6 @@ func (m *AuthMiddleware) Add() gin.HandlerFunc {
return
}
if !m.options.AllowApiKeyAuth {
if m.options.SuccessOptional {
c.Next()
return
}
c.Abort()
if c.GetHeader("X-API-Key") != "" {
_ = c.Error(&common.APIKeyAuthNotAllowedError{})
return
}
_ = c.Error(err)
return
}
// JWT auth failed, try API key auth
userID, isAdmin, err = m.apiKeyMiddleware.Verify(c, m.options.AdminRequired)
if err == nil {

View File

@@ -1,104 +0,0 @@
package middleware
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
"github.com/pocket-id/pocket-id/backend/internal/common"
"github.com/pocket-id/pocket-id/backend/internal/dto"
"github.com/pocket-id/pocket-id/backend/internal/model"
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
"github.com/pocket-id/pocket-id/backend/internal/service"
testutils "github.com/pocket-id/pocket-id/backend/internal/utils/testing"
)
func TestWithApiKeyAuthDisabled(t *testing.T) {
gin.SetMode(gin.TestMode)
originalEnvConfig := common.EnvConfig
defer func() {
common.EnvConfig = originalEnvConfig
}()
common.EnvConfig.AppURL = "https://test.example.com"
common.EnvConfig.EncryptionKey = []byte("0123456789abcdef0123456789abcdef")
db := testutils.NewDatabaseForTest(t)
appConfigService, err := service.NewAppConfigService(t.Context(), db)
require.NoError(t, err)
jwtService, err := service.NewJwtService(t.Context(), db, appConfigService)
require.NoError(t, err)
userService := service.NewUserService(db, jwtService, nil, nil, appConfigService, nil, nil, nil, nil)
apiKeyService, err := service.NewApiKeyService(t.Context(), db, nil)
require.NoError(t, err)
authMiddleware := NewAuthMiddleware(apiKeyService, userService, jwtService)
user := createUserForAuthMiddlewareTest(t, db)
jwtToken, err := jwtService.GenerateAccessToken(user)
require.NoError(t, err)
_, apiKeyToken, err := apiKeyService.CreateApiKey(t.Context(), user.ID, dto.ApiKeyCreateDto{
Name: "Middleware API Key",
ExpiresAt: datatype.DateTime(time.Now().Add(24 * time.Hour)),
})
require.NoError(t, err)
router := gin.New()
router.Use(NewErrorHandlerMiddleware().Add())
router.GET("/api/protected", authMiddleware.WithAdminNotRequired().WithApiKeyAuthDisabled().Add(), func(c *gin.Context) {
c.Status(http.StatusNoContent)
})
t.Run("rejects API key auth when API key auth is disabled", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/api/protected", nil)
req.Header.Set("X-API-Key", apiKeyToken)
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, req)
require.Equal(t, http.StatusForbidden, recorder.Code)
var body map[string]string
err := json.Unmarshal(recorder.Body.Bytes(), &body)
require.NoError(t, err)
require.Equal(t, "API key authentication is not allowed for this endpoint", body["error"])
})
t.Run("allows JWT auth when API key auth is disabled", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/api/protected", nil)
req.Header.Set("Authorization", "Bearer "+jwtToken)
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, req)
require.Equal(t, http.StatusNoContent, recorder.Code)
})
}
func createUserForAuthMiddlewareTest(t *testing.T, db *gorm.DB) model.User {
t.Helper()
email := "auth@example.com"
user := model.User{
Username: "auth-user",
Email: &email,
FirstName: "Auth",
LastName: "User",
DisplayName: "Auth User",
}
err := db.Create(&user).Error
require.NoError(t, err)
return user
}

View File

@@ -17,12 +17,6 @@ func NewRateLimitMiddleware() *RateLimitMiddleware {
}
func (m *RateLimitMiddleware) Add(limit rate.Limit, burst int) gin.HandlerFunc {
if common.EnvConfig.DisableRateLimiting {
return func(c *gin.Context) {
c.Next()
}
}
// Map to store the rate limiters per IP
var clients = make(map[string]*client)
var mu sync.Mutex

View File

@@ -36,7 +36,6 @@ type AppConfig struct {
// General
AppName AppConfigVariable `key:"appName,public"` // Public
SessionDuration AppConfigVariable `key:"sessionDuration"`
HomePageURL AppConfigVariable `key:"homePageUrl,public"` // Public
EmailsVerified AppConfigVariable `key:"emailsVerified"`
AccentColor AppConfigVariable `key:"accentColor,public"` // Public
DisableAnimations AppConfigVariable `key:"disableAnimations,public"` // Public
@@ -59,7 +58,6 @@ type AppConfig struct {
EmailOneTimeAccessAsUnauthenticatedEnabled AppConfigVariable `key:"emailOneTimeAccessAsUnauthenticatedEnabled,public"` // Public
EmailOneTimeAccessAsAdminEnabled AppConfigVariable `key:"emailOneTimeAccessAsAdminEnabled,public"` // Public
EmailApiKeyExpirationEnabled AppConfigVariable `key:"emailApiKeyExpirationEnabled"`
EmailVerificationEnabled AppConfigVariable `key:"emailVerificationEnabled,public"` // Public
// LDAP
LdapEnabled AppConfigVariable `key:"ldapEnabled,public"` // Public
LdapUrl AppConfigVariable `key:"ldapUrl"`

View File

@@ -70,12 +70,13 @@ func TestAppConfigVariable_AsMinutesDuration(t *testing.T) {
// - dto.AppConfigDto should not include "internal" fields from model.AppConfig
// This test is primarily meant to catch discrepancies between the two structs as fields are added or removed over time
func TestAppConfigStructMatchesUpdateDto(t *testing.T) {
appConfigType := reflect.TypeFor[model.AppConfig]()
updateDtoType := reflect.TypeFor[dto.AppConfigUpdateDto]()
appConfigType := reflect.TypeOf(model.AppConfig{})
updateDtoType := reflect.TypeOf(dto.AppConfigUpdateDto{})
// Process AppConfig fields
appConfigFields := make(map[string]string)
for field := range appConfigType.Fields() {
for i := 0; i < appConfigType.NumField(); i++ {
field := appConfigType.Field(i)
if field.Tag.Get("key") == "" {
// Skip internal fields
continue
@@ -90,7 +91,9 @@ func TestAppConfigStructMatchesUpdateDto(t *testing.T) {
// Process AppConfigUpdateDto fields
dtoFields := make(map[string]string)
for field := range updateDtoType.Fields() {
for i := 0; i < updateDtoType.NumField(); i++ {
field := updateDtoType.Field(i)
// Extract the json name from the tag (takes the part before any binding constraints)
jsonTag := field.Tag.Get("json")
jsonName, _, _ := strings.Cut(jsonTag, ",")

View File

@@ -1,13 +0,0 @@
package model
import datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
type EmailVerificationToken struct {
Base
Token string
ExpiresAt datatype.DateTime
UserID string
User User
}

View File

@@ -58,7 +58,6 @@ type OidcClient struct {
RequiresReauthentication bool `sortable:"true" filterable:"true"`
Credentials OidcClientCredentials
LaunchURL *string
IsGroupRestricted bool `sortable:"true" filterable:"true"`
AllowedUserGroups []UserGroup `gorm:"many2many:oidc_clients_allowed_user_groups;"`
CreatedByID *string
@@ -144,7 +143,6 @@ type OidcDeviceCode struct {
DeviceCode string
UserCode string
Scope string
Nonce string
ExpiresAt datatype.DateTime
IsAuthorized bool
@@ -153,3 +151,33 @@ type OidcDeviceCode struct {
ClientID string
Client OidcClient
}
type OidcAPI struct {
Base
Name string `sortable:"true"`
Identifier string `sortable:"true"`
Data OidcAPIData `gorm:"type:text"`
}
func (a OidcAPI) DefaultIdentifier() string {
return "api://" + a.Identifier
}
//nolint:recvcheck
type OidcAPIData struct {
Permissions []OidcAPIPermission `json:"permissions"`
}
func (p *OidcAPIData) Scan(value any) error {
return utils.UnmarshalJSONFromDatabase(p, value)
}
func (p OidcAPIData) Value() (driver.Value, error) {
return json.Marshal(p)
}
type OidcAPIPermission struct {
Name string `json:"name"`
Description string `json:"description"`
}

View File

@@ -1,13 +0,0 @@
package model
import datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
type OneTimeAccessToken struct {
Base
Token string
DeviceToken *string
ExpiresAt datatype.DateTime
UserID string
User User
}

View File

@@ -1,14 +0,0 @@
package model
import datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
type ScimServiceProvider struct {
Base
Endpoint string `sortable:"true"`
Token datatype.EncryptedString
LastSyncedAt *datatype.DateTime `sortable:"true"`
OidcClientID string
OidcClient OidcClient `gorm:"foreignKey:OidcClientID;references:ID;"`
}

View File

@@ -13,7 +13,6 @@ type SignupToken struct {
ExpiresAt datatype.DateTime `json:"expiresAt" sortable:"true"`
UsageLimit int `json:"usageLimit" sortable:"true"`
UsageCount int `json:"usageCount" sortable:"true"`
UserGroups []UserGroup `gorm:"many2many:signup_tokens_user_groups;"`
}
func (st *SignupToken) IsExpired() bool {

View File

@@ -1,112 +0,0 @@
package datatype
import (
"crypto/sha256"
"database/sql/driver"
"encoding/base64"
"fmt"
"io"
"github.com/pocket-id/pocket-id/backend/internal/common"
cryptoutils "github.com/pocket-id/pocket-id/backend/internal/utils/crypto"
"golang.org/x/crypto/hkdf"
)
const encryptedStringAAD = "encrypted_string"
var encStringKey []byte
// EncryptedString stores plaintext in memory and persists encrypted data in the database.
type EncryptedString string //nolint:recvcheck
func (e *EncryptedString) Scan(value any) error {
if value == nil {
*e = ""
return nil
}
var raw string
switch v := value.(type) {
case string:
raw = v
case []byte:
raw = string(v)
default:
return fmt.Errorf("unexpected type for EncryptedString: %T", value)
}
if raw == "" {
*e = ""
return nil
}
decBytes, err := DecryptEncryptedStringWithKey(encStringKey, raw)
if err != nil {
return err
}
*e = EncryptedString(decBytes)
return nil
}
func (e EncryptedString) Value() (driver.Value, error) {
if e == "" {
return "", nil
}
encValue, err := EncryptEncryptedStringWithKey(encStringKey, []byte(e))
if err != nil {
return nil, err
}
return encValue, nil
}
func (e EncryptedString) String() string {
return string(e)
}
// DeriveEncryptedStringKey derives a key for encrypting EncryptedString values from the master key.
func DeriveEncryptedStringKey(master []byte) ([]byte, error) {
const info = "pocketid/encrypted_string"
r := hkdf.New(sha256.New, master, nil, []byte(info))
key := make([]byte, 32)
if _, err := io.ReadFull(r, key); err != nil {
return nil, err
}
return key, nil
}
// DecryptEncryptedStringWithKey decrypts an EncryptedString value using the derived key.
func DecryptEncryptedStringWithKey(key []byte, encoded string) ([]byte, error) {
encBytes, err := base64.StdEncoding.DecodeString(encoded)
if err != nil {
return nil, fmt.Errorf("failed to decode encrypted string: %w", err)
}
decBytes, err := cryptoutils.Decrypt(key, encBytes, []byte(encryptedStringAAD))
if err != nil {
return nil, fmt.Errorf("failed to decrypt encrypted string: %w", err)
}
return decBytes, nil
}
// EncryptEncryptedStringWithKey encrypts an EncryptedString value using the derived key.
func EncryptEncryptedStringWithKey(key []byte, plaintext []byte) (string, error) {
encBytes, err := cryptoutils.Encrypt(key, plaintext, []byte(encryptedStringAAD))
if err != nil {
return "", fmt.Errorf("failed to encrypt string: %w", err)
}
return base64.StdEncoding.EncodeToString(encBytes), nil
}
func init() {
key, err := DeriveEncryptedStringKey(common.EnvConfig.EncryptionKey)
if err != nil {
panic(fmt.Sprintf("failed to derive encrypted string key: %v", err))
}
encStringKey = key
}

View File

@@ -2,7 +2,6 @@ package model
import (
"strings"
"time"
"github.com/go-webauthn/webauthn/protocol"
"github.com/go-webauthn/webauthn/webauthn"
@@ -14,17 +13,15 @@ import (
type User struct {
Base
Username string `sortable:"true"`
Email *string `sortable:"true"`
EmailVerified bool `sortable:"true" filterable:"true"`
FirstName string `sortable:"true"`
LastName string `sortable:"true"`
DisplayName string `sortable:"true"`
IsAdmin bool `sortable:"true" filterable:"true"`
Locale *string
LdapID *string
Disabled bool `sortable:"true" filterable:"true"`
UpdatedAt *datatype.DateTime
Username string `sortable:"true"`
Email *string `sortable:"true"`
FirstName string `sortable:"true"`
LastName string `sortable:"true"`
DisplayName string `sortable:"true"`
IsAdmin bool `sortable:"true" filterable:"true"`
Locale *string
LdapID *string
Disabled bool `sortable:"true" filterable:"true"`
CustomClaims []CustomClaim
UserGroups []UserGroup `gorm:"many2many:user_groups_users;"`
@@ -39,7 +36,7 @@ func (u User) WebAuthnDisplayName() string {
if u.DisplayName != "" {
return u.DisplayName
}
return u.FullName()
return u.FirstName + " " + u.LastName
}
func (u User) WebAuthnIcon() string { return "" }
@@ -76,16 +73,7 @@ func (u User) WebAuthnCredentialDescriptors() (descriptors []protocol.Credential
}
func (u User) FullName() string {
fullname := strings.TrimSpace(u.FirstName + " " + u.LastName)
if fullname != "" {
return fullname
}
if u.DisplayName != "" {
return u.DisplayName
}
return u.Username
return u.FirstName + " " + u.LastName
}
func (u User) Initials() string {
@@ -97,9 +85,11 @@ func (u User) Initials() string {
return strings.ToUpper(first + last)
}
func (u User) LastModified() time.Time {
if u.UpdatedAt != nil {
return u.UpdatedAt.ToTime()
}
return u.CreatedAt.ToTime()
type OneTimeAccessToken struct {
Base
Token string
ExpiresAt datatype.DateTime
UserID string
User User
}

View File

@@ -1,25 +1,10 @@
package model
import (
"time"
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
)
type UserGroup struct {
Base
FriendlyName string `sortable:"true"`
Name string `sortable:"true"`
LdapID *string
UpdatedAt *datatype.DateTime
Users []User `gorm:"many2many:user_groups_users;"`
CustomClaims []CustomClaim
AllowedOidcClients []OidcClient `gorm:"many2many:oidc_clients_allowed_user_groups;"`
}
func (ug UserGroup) LastModified() time.Time {
if ug.UpdatedAt != nil {
return ug.UpdatedAt.ToTime()
}
return ug.CreatedAt.ToTime()
FriendlyName string `sortable:"true"`
Name string `sortable:"true"`
LdapID *string
Users []User `gorm:"many2many:user_groups_users;"`
CustomClaims []CustomClaim
}

View File

@@ -58,7 +58,7 @@ type ReauthenticationToken struct {
type AuthenticatorTransportList []protocol.AuthenticatorTransport //nolint:recvcheck
// Scan and Value methods for GORM to handle the custom type
func (atl *AuthenticatorTransportList) Scan(value any) error {
func (atl *AuthenticatorTransportList) Scan(value interface{}) error {
return utils.UnmarshalJSONFromDatabase(atl, value)
}
@@ -69,7 +69,7 @@ func (atl AuthenticatorTransportList) Value() (driver.Value, error) {
type CredentialParameters []protocol.CredentialParameter //nolint:recvcheck
// Scan and Value methods for GORM to handle the custom type
func (cp *CredentialParameters) Scan(value any) error {
func (cp *CredentialParameters) Scan(value interface{}) error {
return utils.UnmarshalJSONFromDatabase(cp, value)
}

View File

@@ -3,7 +3,6 @@ package service
import (
"context"
"errors"
"fmt"
"time"
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
@@ -17,25 +16,13 @@ import (
"gorm.io/gorm/clause"
)
const staticApiKeyUserID = "00000000-0000-0000-0000-000000000000"
type ApiKeyService struct {
db *gorm.DB
emailService *EmailService
}
func NewApiKeyService(ctx context.Context, db *gorm.DB, emailService *EmailService) (*ApiKeyService, error) {
s := &ApiKeyService{db: db, emailService: emailService}
if common.EnvConfig.StaticApiKey == "" {
err := s.deleteStaticApiKeyUser(ctx)
if err != nil {
return nil, err
}
}
return s, nil
func NewApiKeyService(db *gorm.DB, emailService *EmailService) *ApiKeyService {
return &ApiKeyService{db: db, emailService: emailService}
}
func (s *ApiKeyService) ListApiKeys(ctx context.Context, userID string, listRequestOptions utils.ListRequestOptions) ([]model.ApiKey, utils.PaginationResponse, error) {
@@ -78,9 +65,6 @@ func (s *ApiKeyService) CreateApiKey(ctx context.Context, userID string, input d
Create(&apiKey).
Error
if err != nil {
if errors.Is(err, gorm.ErrDuplicatedKey) {
return model.ApiKey{}, "", &common.AlreadyInUseError{Property: "API key name"}
}
return model.ApiKey{}, "", err
}
@@ -88,56 +72,6 @@ func (s *ApiKeyService) CreateApiKey(ctx context.Context, userID string, input d
return apiKey, token, nil
}
func (s *ApiKeyService) RenewApiKey(ctx context.Context, userID, apiKeyID string, expiration time.Time) (model.ApiKey, string, error) {
// Check if expiration is in the future
if !expiration.After(time.Now()) {
return model.ApiKey{}, "", &common.APIKeyExpirationDateError{}
}
tx := s.db.Begin()
defer tx.Rollback()
var apiKey model.ApiKey
err := tx.
WithContext(ctx).
Model(&model.ApiKey{}).
Where("id = ? AND user_id = ?", apiKeyID, userID).
First(&apiKey).
Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return model.ApiKey{}, "", &common.APIKeyNotFoundError{}
}
return model.ApiKey{}, "", err
}
// Only allow renewal if the key has already expired
if apiKey.ExpiresAt.ToTime().After(time.Now()) {
return model.ApiKey{}, "", &common.APIKeyNotExpiredError{}
}
// Generate a secure random API key
token, err := utils.GenerateRandomAlphanumericString(32)
if err != nil {
return model.ApiKey{}, "", err
}
apiKey.Key = utils.CreateSha256Hash(token)
apiKey.ExpiresAt = datatype.DateTime(expiration)
err = tx.WithContext(ctx).Save(&apiKey).Error
if err != nil {
return model.ApiKey{}, "", err
}
if err := tx.Commit().Error; err != nil {
return model.ApiKey{}, "", err
}
return apiKey, token, nil
}
func (s *ApiKeyService) RevokeApiKey(ctx context.Context, userID, apiKeyID string) error {
var apiKey model.ApiKey
err := s.db.
@@ -160,10 +94,6 @@ func (s *ApiKeyService) ValidateApiKey(ctx context.Context, apiKey string) (mode
return model.User{}, &common.NoAPIKeyProvidedError{}
}
if common.EnvConfig.StaticApiKey != "" && apiKey == common.EnvConfig.StaticApiKey {
return s.initStaticApiKeyUser(ctx)
}
now := time.Now()
hashedKey := utils.CreateSha256Hash(apiKey)
@@ -174,7 +104,7 @@ func (s *ApiKeyService) ValidateApiKey(ctx context.Context, apiKey string) (mode
Clauses(clause.Returning{}).
Where("key = ? AND expires_at > ?", hashedKey, datatype.DateTime(now)).
Updates(&model.ApiKey{
LastUsedAt: new(datatype.DateTime(now)),
LastUsedAt: utils.Ptr(datatype.DateTime(now)),
}).
Preload("User").
First(&key).
@@ -206,75 +136,34 @@ func (s *ApiKeyService) ListExpiringApiKeys(ctx context.Context, daysAhead int)
}
func (s *ApiKeyService) SendApiKeyExpiringSoonEmail(ctx context.Context, apiKey model.ApiKey) error {
if apiKey.User.Email == nil {
user := apiKey.User
if user.ID == "" {
if err := s.db.WithContext(ctx).First(&user, "id = ?", apiKey.UserID).Error; err != nil {
return err
}
}
if user.Email == nil {
return &common.UserEmailNotSetError{}
}
err := SendEmail(ctx, s.emailService, email.Address{
Name: apiKey.User.FullName(),
Email: *apiKey.User.Email,
Name: user.FullName(),
Email: *user.Email,
}, ApiKeyExpiringSoonTemplate, &ApiKeyExpiringSoonTemplateData{
ApiKeyName: apiKey.Name,
ExpiresAt: apiKey.ExpiresAt.ToTime(),
Name: apiKey.User.FirstName,
Name: user.FirstName,
})
if err != nil {
return fmt.Errorf("error sending notification email: %w", err)
return err
}
// Mark the API key as having had an expiration email sent
err = s.db.WithContext(ctx).
return s.db.WithContext(ctx).
Model(&model.ApiKey{}).
Where("id = ?", apiKey.ID).
Update("expiration_email_sent", true).
Error
if err != nil {
return fmt.Errorf("error recording expiration sent email in database: %w", err)
}
return nil
}
func (s *ApiKeyService) initStaticApiKeyUser(ctx context.Context) (user model.User, err error) {
err = s.db.
WithContext(ctx).
First(&user, "id = ?", staticApiKeyUserID).
Error
if err == nil {
return user, nil
}
if !errors.Is(err, gorm.ErrRecordNotFound) {
return model.User{}, err
}
usernameSuffix, err := utils.GenerateRandomAlphanumericString(6)
if err != nil {
return model.User{}, err
}
user = model.User{
Base: model.Base{
ID: staticApiKeyUserID,
},
FirstName: "Static API User",
Username: "static-api-user-" + usernameSuffix,
DisplayName: "Static API User",
IsAdmin: true,
}
err = s.db.
WithContext(ctx).
Create(&user).
Error
return user, err
}
func (s *ApiKeyService) deleteStaticApiKeyUser(ctx context.Context) error {
return s.db.
WithContext(ctx).
Delete(&model.User{}, "id = ?", staticApiKeyUserID).
Error
}

View File

@@ -61,7 +61,6 @@ func (s *AppConfigService) getDefaultDbConfig() *model.AppConfig {
// General
AppName: model.AppConfigVariable{Value: "Pocket ID"},
SessionDuration: model.AppConfigVariable{Value: "60"},
HomePageURL: model.AppConfigVariable{Value: "/settings/account"},
EmailsVerified: model.AppConfigVariable{Value: "false"},
DisableAnimations: model.AppConfigVariable{Value: "false"},
AllowOwnAccountEdit: model.AppConfigVariable{Value: "true"},
@@ -84,7 +83,6 @@ func (s *AppConfigService) getDefaultDbConfig() *model.AppConfig {
EmailOneTimeAccessAsUnauthenticatedEnabled: model.AppConfigVariable{Value: "false"},
EmailOneTimeAccessAsAdminEnabled: model.AppConfigVariable{Value: "false"},
EmailApiKeyExpirationEnabled: model.AppConfigVariable{Value: "false"},
EmailVerificationEnabled: model.AppConfigVariable{Value: "false"},
// LDAP
LdapEnabled: model.AppConfigVariable{Value: "false"},
LdapUrl: model.AppConfigVariable{},
@@ -186,7 +184,8 @@ func (s *AppConfigService) UpdateAppConfig(ctx context.Context, input dto.AppCon
rt := reflect.ValueOf(input).Type()
rv := reflect.ValueOf(input)
dbUpdate := make([]model.AppConfigVariable, 0, rt.NumField())
for field := range rt.Fields() {
for i := range rt.NumField() {
field := rt.Field(i)
value := rv.FieldByName(field.Name).String()
// Get the value of the json tag, taking only what's before the comma

View File

@@ -73,10 +73,7 @@ func (lv *lockValue) Unmarshal(raw string) error {
// Acquire obtains the lock. When force is true, the lock is stolen from any existing owner.
// If the lock is forcefully acquired, it blocks until the previous lock has expired.
func (s *AppLockService) Acquire(ctx context.Context, force bool) (waitUntil time.Time, err error) {
tx := s.db.WithContext(ctx).Begin()
if tx.Error != nil {
return time.Time{}, fmt.Errorf("begin lock transaction: %w", tx.Error)
}
tx := s.db.Begin()
defer func() {
tx.Rollback()
}()
@@ -96,8 +93,7 @@ func (s *AppLockService) Acquire(ctx context.Context, force bool) (waitUntil tim
var prevLock lockValue
if prevLockRaw != "" {
err = prevLock.Unmarshal(prevLockRaw)
if err != nil {
if err := prevLock.Unmarshal(prevLockRaw); err != nil {
return time.Time{}, fmt.Errorf("decode existing lock value: %w", err)
}
}
@@ -143,8 +139,7 @@ func (s *AppLockService) Acquire(ctx context.Context, force bool) (waitUntil tim
return time.Time{}, fmt.Errorf("lock acquisition failed: %w", res.Error)
}
err = tx.Commit().Error
if err != nil {
if err := tx.Commit().Error; err != nil {
return time.Time{}, fmt.Errorf("commit lock acquisition: %w", err)
}
@@ -179,8 +174,7 @@ func (s *AppLockService) RunRenewal(ctx context.Context) error {
case <-ctx.Done():
return nil
case <-ticker.C:
err := s.renew(ctx)
if err != nil {
if err := s.renew(ctx); err != nil {
return fmt.Errorf("renew lock: %w", err)
}
}
@@ -189,43 +183,33 @@ func (s *AppLockService) RunRenewal(ctx context.Context) error {
// Release releases the lock if it is held by this process.
func (s *AppLockService) Release(ctx context.Context) error {
db, err := s.db.DB()
if err != nil {
return fmt.Errorf("failed to get DB connection: %w", err)
}
opCtx, cancel := context.WithTimeout(ctx, 3*time.Second)
defer cancel()
var query string
switch s.db.Name() {
case "sqlite":
query = `
DELETE FROM kv
WHERE key = ?
AND json_extract(value, '$.lock_id') = ?
`
DELETE FROM kv
WHERE key = ?
AND json_extract(value, '$.lock_id') = ?
`
case "postgres":
query = `
DELETE FROM kv
WHERE key = $1
AND value::json->>'lock_id' = $2
`
DELETE FROM kv
WHERE key = $1
AND value::json->>'lock_id' = $2
`
default:
return fmt.Errorf("unsupported database dialect: %s", s.db.Name())
}
opCtx, cancel := context.WithTimeout(ctx, 3*time.Second)
defer cancel()
res, err := db.ExecContext(opCtx, query, lockKey, s.lockID)
if err != nil {
return fmt.Errorf("release lock failed: %w", err)
res := s.db.WithContext(opCtx).Exec(query, lockKey, s.lockID)
if res.Error != nil {
return fmt.Errorf("release lock failed: %w", res.Error)
}
count, err := res.RowsAffected()
if err != nil {
return fmt.Errorf("failed to count affected rows: %w", err)
}
if count == 0 {
if res.RowsAffected == 0 {
slog.Warn("Application lock not held by this process, cannot release",
slog.Int64("process_id", s.processID),
slog.String("host_id", s.hostID),
@@ -241,11 +225,6 @@ WHERE key = $1
// renew tries to renew the lock, retrying up to renewRetries times (sleeping 1s between attempts).
func (s *AppLockService) renew(ctx context.Context) error {
db, err := s.db.DB()
if err != nil {
return fmt.Errorf("failed to get DB connection: %w", err)
}
var lastErr error
for attempt := 1; attempt <= renewRetries; attempt++ {
now := time.Now()
@@ -267,56 +246,42 @@ func (s *AppLockService) renew(ctx context.Context) error {
switch s.db.Name() {
case "sqlite":
query = `
UPDATE kv
SET value = ?
WHERE key = ?
AND json_extract(value, '$.lock_id') = ?
AND json_extract(value, '$.expires_at') > ?
`
UPDATE kv
SET value = ?
WHERE key = ?
AND json_extract(value, '$.lock_id') = ?
AND json_extract(value, '$.expires_at') > ?
`
case "postgres":
query = `
UPDATE kv
SET value = $1
WHERE key = $2
AND value::json->>'lock_id' = $3
AND ((value::json->>'expires_at')::bigint > $4)
`
UPDATE kv
SET value = $1
WHERE key = $2
AND value::json->>'lock_id' = $3
AND ((value::json->>'expires_at')::bigint > $4)
`
default:
return fmt.Errorf("unsupported database dialect: %s", s.db.Name())
}
opCtx, cancel := context.WithTimeout(ctx, 3*time.Second)
res, err := db.ExecContext(opCtx, query, raw, lockKey, s.lockID, nowUnix)
res := s.db.WithContext(opCtx).Exec(query, raw, lockKey, s.lockID, nowUnix)
cancel()
// Query succeeded, but may have updated 0 rows
if err == nil {
count, err := res.RowsAffected()
if err != nil {
return fmt.Errorf("failed to count affected rows: %w", err)
}
// If no rows were updated, we lost the lock
if count == 0 {
return ErrLockLost
}
// All good
switch {
case res.Error != nil:
lastErr = fmt.Errorf("lock renewal failed: %w", res.Error)
case res.RowsAffected == 0:
// Must be after checking res.Error
return ErrLockLost
default:
slog.Debug("Renewed application lock",
slog.Int64("process_id", s.processID),
slog.String("host_id", s.hostID),
slog.Duration("duration", time.Since(now)),
)
return nil
}
// If we're here, we have an error that can be retried
slog.Debug("Application lock renewal attempt failed",
slog.Any("error", err),
slog.Duration("duration", time.Since(now)),
)
lastErr = fmt.Errorf("lock renewal failed: %w", err)
// Wait before next attempt or cancel if context is done
if attempt < renewRetries {
select {

View File

@@ -49,23 +49,6 @@ func readLockValue(t *testing.T, db *gorm.DB) lockValue {
return value
}
func lockDatabaseForWrite(t *testing.T, db *gorm.DB) *gorm.DB {
t.Helper()
tx := db.Begin()
require.NoError(t, tx.Error)
// Keep a write transaction open to block other queries.
err := tx.Exec(
`INSERT INTO kv (key, value) VALUES (?, ?) ON CONFLICT(key) DO NOTHING`,
lockKey,
`{"expires_at":0}`,
).Error
require.NoError(t, err)
return tx
}
func TestAppLockServiceAcquire(t *testing.T) {
t.Run("creates new lock when none exists", func(t *testing.T) {
db := testutils.NewDatabaseForTest(t)
@@ -116,66 +99,6 @@ func TestAppLockServiceAcquire(t *testing.T) {
require.Equal(t, service.hostID, stored.HostID)
require.Greater(t, stored.ExpiresAt, time.Now().Unix())
})
t.Run("force acquisition returns wait duration when stealing active lock", func(t *testing.T) {
db := testutils.NewDatabaseForTest(t)
service := newTestAppLockService(t, db)
existing := lockValue{
ProcessID: 99,
HostID: "other-host",
LockID: "other-lock-id",
ExpiresAt: time.Now().Add(ttl).Unix(),
}
insertLock(t, db, existing)
waitUntil, err := service.Acquire(context.Background(), true)
require.NoError(t, err)
require.WithinDuration(t, time.Unix(existing.ExpiresAt, 0), waitUntil, time.Second)
})
t.Run("force acquisition does not wait when lock id is unchanged", func(t *testing.T) {
db := testutils.NewDatabaseForTest(t)
service := newTestAppLockService(t, db)
insertLock(t, db, lockValue{
ProcessID: 99,
HostID: "other-host",
LockID: service.lockID,
ExpiresAt: time.Now().Add(ttl).Unix(),
})
waitUntil, err := service.Acquire(context.Background(), true)
require.NoError(t, err)
require.True(t, waitUntil.IsZero())
})
t.Run("returns error when existing lock value is invalid JSON", func(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
require.NoError(t, err)
_, err = service.Acquire(context.Background(), false)
require.ErrorContains(t, err, "decode existing lock value")
})
t.Run("returns context deadline exceeded when database is locked", func(t *testing.T) {
db := testutils.NewDatabaseForTest(t)
service := newTestAppLockService(t, db)
tx := lockDatabaseForWrite(t, db)
defer tx.Rollback()
ctx, cancel := context.WithTimeout(context.Background(), 150*time.Millisecond)
defer cancel()
_, err := service.Acquire(ctx, false)
require.ErrorIs(t, err, context.DeadlineExceeded)
require.ErrorContains(t, err, "begin lock transaction")
})
}
func TestAppLockServiceRelease(t *testing.T) {
@@ -211,24 +134,6 @@ func TestAppLockServiceRelease(t *testing.T) {
stored := readLockValue(t, db)
require.Equal(t, existing, stored)
})
t.Run("returns context deadline exceeded when database is locked", func(t *testing.T) {
db := testutils.NewDatabaseForTest(t)
service := newTestAppLockService(t, db)
_, err := service.Acquire(context.Background(), false)
require.NoError(t, err)
tx := lockDatabaseForWrite(t, db)
defer tx.Rollback()
ctx, cancel := context.WithTimeout(context.Background(), 150*time.Millisecond)
defer cancel()
err = service.Release(ctx)
require.ErrorIs(t, err, context.DeadlineExceeded)
require.ErrorContains(t, err, "release lock failed")
})
}
func TestAppLockServiceRenew(t *testing.T) {
@@ -281,21 +186,4 @@ func TestAppLockServiceRenew(t *testing.T) {
err = service.renew(context.Background())
require.ErrorIs(t, err, ErrLockLost)
})
t.Run("returns context deadline exceeded when database is locked", func(t *testing.T) {
db := testutils.NewDatabaseForTest(t)
service := newTestAppLockService(t, db)
_, err := service.Acquire(context.Background(), false)
require.NoError(t, err)
tx := lockDatabaseForWrite(t, db)
defer tx.Rollback()
ctx, cancel := context.WithTimeout(context.Background(), 150*time.Millisecond)
defer cancel()
err = service.renew(ctx)
require.ErrorIs(t, err, context.DeadlineExceeded)
})
}

View File

@@ -80,35 +80,22 @@ func (s *TestService) SeedDatabase(baseURL string) error {
Base: model.Base{
ID: "f4b89dc2-62fb-46bf-9f5f-c34f4eafe93e",
},
Username: "tim",
Email: new("tim.cook@test.com"),
EmailVerified: true,
FirstName: "Tim",
LastName: "Cook",
DisplayName: "Tim Cook",
IsAdmin: true,
Username: "tim",
Email: utils.Ptr("tim.cook@test.com"),
FirstName: "Tim",
LastName: "Cook",
DisplayName: "Tim Cook",
IsAdmin: true,
},
{
Base: model.Base{
ID: "1cd19686-f9a6-43f4-a41f-14a0bf5b4036",
},
Username: "craig",
Email: new("craig.federighi@test.com"),
EmailVerified: false,
FirstName: "Craig",
LastName: "Federighi",
DisplayName: "Craig Federighi",
IsAdmin: false,
},
{
Base: model.Base{
ID: "d9256384-98ad-49a7-bc58-99ad0b4dc23c",
},
Username: "eddy",
Email: new("eddy.cue@test.com"),
FirstName: "Eddy",
LastName: "Cue",
DisplayName: "Eddy Cue",
Username: "craig",
Email: utils.Ptr("craig.federighi@test.com"),
FirstName: "Craig",
LastName: "Federighi",
DisplayName: "Craig Federighi",
IsAdmin: false,
},
}
@@ -171,22 +158,21 @@ func (s *TestService) SeedDatabase(baseURL string) error {
ID: "3654a746-35d4-4321-ac61-0bdcff2b4055",
},
Name: "Nextcloud",
LaunchURL: new("https://nextcloud.local"),
LaunchURL: utils.Ptr("https://nextcloud.local"),
Secret: "$2a$10$9dypwot8nGuCjT6wQWWpJOckZfRprhe2EkwpKizxS/fpVHrOLEJHC", // w2mUeZISmEvIDMEDvpY0PnxQIpj1m3zY
CallbackURLs: model.UrlList{"http://nextcloud/auth/callback"},
LogoutCallbackURLs: model.UrlList{"http://nextcloud/auth/logout/callback"},
ImageType: new("png"),
CreatedByID: new(users[0].ID),
ImageType: utils.StringPointer("png"),
CreatedByID: utils.Ptr(users[0].ID),
},
{
Base: model.Base{
ID: "606c7782-f2b1-49e5-8ea9-26eb1b06d018",
},
Name: "Immich",
Secret: "$2a$10$Ak.FP8riD1ssy2AGGbG.gOpnp/rBpymd74j0nxNMtW0GG1Lb4gzxe", // PYjrE9u4v9GVqXKi52eur0eb2Ci4kc0x
CallbackURLs: model.UrlList{"http://immich/auth/callback"},
CreatedByID: new(users[1].ID),
IsGroupRestricted: true,
Name: "Immich",
Secret: "$2a$10$Ak.FP8riD1ssy2AGGbG.gOpnp/rBpymd74j0nxNMtW0GG1Lb4gzxe", // PYjrE9u4v9GVqXKi52eur0eb2Ci4kc0x
CallbackURLs: model.UrlList{"http://immich/auth/callback"},
CreatedByID: utils.Ptr(users[1].ID),
AllowedUserGroups: []model.UserGroup{
userGroups[1],
},
@@ -199,8 +185,7 @@ func (s *TestService) SeedDatabase(baseURL string) error {
Secret: "$2a$10$xcRReBsvkI1XI6FG8xu/pOgzeF00bH5Wy4d/NThwcdi3ZBpVq/B9a", // n4VfQeXlTzA6yKpWbR9uJcMdSx2qH0Lo
CallbackURLs: model.UrlList{"http://tailscale/auth/callback"},
LogoutCallbackURLs: model.UrlList{"http://tailscale/auth/logout/callback"},
IsGroupRestricted: true,
CreatedByID: new(users[0].ID),
CreatedByID: utils.Ptr(users[0].ID),
},
{
Base: model.Base{
@@ -209,7 +194,7 @@ func (s *TestService) SeedDatabase(baseURL string) error {
Name: "Federated",
Secret: "$2a$10$Ak.FP8riD1ssy2AGGbG.gOpnp/rBpymd74j0nxNMtW0GG1Lb4gzxe", // PYjrE9u4v9GVqXKi52eur0eb2Ci4kc0x
CallbackURLs: model.UrlList{"http://federated/auth/callback"},
CreatedByID: new(users[1].ID),
CreatedByID: utils.Ptr(users[1].ID),
AllowedUserGroups: []model.UserGroup{},
Credentials: model.OidcClientCredentials{
FederatedIdentities: []model.OidcClientFederatedIdentity{
@@ -222,20 +207,6 @@ func (s *TestService) SeedDatabase(baseURL string) error {
},
},
},
{
Base: model.Base{
ID: "c46d2090-37a0-4f2b-8748-6aa53b0c1afa",
},
Name: "SCIM Client",
Secret: "$2a$10$h4wfa8gI7zavDAxwzSq1sOwYU4e8DwK1XZ8ZweNnY5KzlJ3Iz.qdK", // nQbiuMRG7FpdK2EnDd5MBivWQeKFXohn
CallbackURLs: model.UrlList{"http://scimclient/auth/callback"},
CreatedByID: new(users[0].ID),
IsGroupRestricted: true,
AllowedUserGroups: []model.UserGroup{
userGroups[0],
userGroups[1],
},
},
}
for _, client := range oidcClients {
if err := tx.Create(&client).Error; err != nil {
@@ -258,7 +229,7 @@ func (s *TestService) SeedDatabase(baseURL string) error {
Nonce: "nonce",
ExpiresAt: datatype.DateTime(time.Now().Add(1 * time.Hour)),
UserID: users[1].ID,
ClientID: oidcClients[3].ID,
ClientID: oidcClients[2].ID,
},
}
for _, authCode := range authCodes {
@@ -356,30 +327,17 @@ func (s *TestService) SeedDatabase(baseURL string) error {
return err
}
apiKeys := []model.ApiKey{
{
Base: model.Base{
ID: "5f1fa856-c164-4295-961e-175a0d22d725",
},
Name: "Test API Key",
Key: "6c34966f57ef2bb7857649aff0e7ab3ad67af93c846342ced3f5a07be8706c20",
UserID: users[0].ID,
ExpiresAt: datatype.DateTime(time.Now().Add(30 * 24 * time.Hour)),
},
{
Base: model.Base{
ID: "98900330-7a7b-48fe-881b-2cc6ad049976",
},
Name: "Expired API Key",
Key: "141ff8ac9db640ba93630099de83d0ead8e7ac673e3a7d31b4fd7ff2252e6389",
UserID: users[0].ID,
ExpiresAt: datatype.DateTime(time.Now().Add(-20 * 24 * time.Hour)),
apiKey := model.ApiKey{
Base: model.Base{
ID: "5f1fa856-c164-4295-961e-175a0d22d725",
},
Name: "Test API Key",
Key: "6c34966f57ef2bb7857649aff0e7ab3ad67af93c846342ced3f5a07be8706c20",
UserID: users[0].ID,
ExpiresAt: datatype.DateTime(time.Now().Add(30 * 24 * time.Hour)),
}
for _, apiKey := range apiKeys {
if err := tx.Create(&apiKey).Error; err != nil {
return err
}
if err := tx.Create(&apiKey).Error; err != nil {
return err
}
signupTokens := []model.SignupToken{
@@ -391,9 +349,6 @@ func (s *TestService) SeedDatabase(baseURL string) error {
ExpiresAt: datatype.DateTime(time.Now().Add(24 * time.Hour)),
UsageLimit: 1,
UsageCount: 0,
UserGroups: []model.UserGroup{
userGroups[0],
},
},
{
Base: model.Base{
@@ -429,36 +384,11 @@ func (s *TestService) SeedDatabase(baseURL string) error {
}
}
emailVerificationTokens := []model.EmailVerificationToken{
{
Base: model.Base{
ID: "ef9ca469-b178-4857-bd39-26639dca45de",
},
Token: "2FZFSoupBdHyqIL65bWTsgCgHIhxlXup",
ExpiresAt: datatype.DateTime(time.Now().Add(2 * time.Hour)),
UserID: users[1].ID,
},
{
Base: model.Base{
ID: "a3dcb4d2-7f3c-4e8a-9f4d-5b6c7d8e9f00",
},
Token: "EXPIRED1234567890ABCDE",
ExpiresAt: datatype.DateTime(time.Now().Add(-1 * time.Hour)),
UserID: users[1].ID,
},
}
for _, token := range emailVerificationTokens {
if err := tx.Create(&token).Error; err != nil {
return err
}
}
keyValues := []model.KV{
{
Key: jwkutils.PrivateKeyDBKey,
// {"alg":"RS256","d":"mvMDWSdPPvcum0c0iEHE2gbqtV2NKMmLwrl9E6K7g8lTV95SePLnW_bwyMPV7EGp7PQk3l17I5XRhFjze7GqTnFIOgKzMianPs7jv2ELtBMGK0xOPATgu1iGb70xZ6vcvuEfRyY3dJ0zr4jpUdVuXwKmx9rK4IdZn2dFCKfvSuspqIpz11RhF1ALrqDLkxGVv7ZwNh0_VhJZU9hcjG5l6xc7rQEKpPRkZp0IdjkGS8Z0FskoVaiRIWAbZuiVFB9WCW8k1czC4HQTPLpII01bUQx2ludbm0UlXRgVU9ptUUbU7GAImQqTOW8LfPGklEvcgzlIlR_oqw4P9yBxLi-yMQ","dp":"pvNCSnnhbo8Igw9psPR-DicxFnkXlu_ix4gpy6efTrxA-z1VDFDioJ814vKQNioYDzpyAP1gfMPhRkvG_q0hRZsJah3Sb9dfA-WkhSWY7lURQP4yIBTMU0PF_rEATuS7lRciYk1SOx5fqXZd3m_LP0vpBC4Ujlq6NAq6CIjCnms","dq":"TtUVGCCkPNgfOLmkYXu7dxxUCV5kB01-xAEK2OY0n0pG8vfDophH4_D_ZC7nvJ8J9uDhs_3JStexq1lIvaWtG99RNTChIEDzpdn6GH9yaVcb_eB4uJjrNm64FhF8PGCCwxA-xMCZMaARKwhMB2_IOMkxUbWboL3gnhJ2rDO_QO0","e":"AQAB","kid":"8uHDw3M6rf8","kty":"RSA","n":"yaeEL0VKoPBXIAaWXsUgmu05lAvEIIdJn0FX9lHh4JE5UY9B83C5sCNdhs9iSWzpeP11EVjWp8i3Yv2CF7c7u50BXnVBGtxpZpFC-585UXacoJ0chUmarL9GRFJcM1nPHBTFu68aRrn1rIKNHUkNaaxFo0NFGl_4EDDTO8HwawTjwkPoQlRzeByhlvGPVvwgB3Fn93B8QJ_cZhXKxJvjjrC_8Pk76heC_ntEMru71Ix77BoC3j2TuyiN7m9RNBW8BU5q6lKoIdvIeZfTFLzi37iufyfvMrJTixp9zhNB1NxlLCeOZl2MXegtiGqd2H3cbAyqoOiv9ihUWTfXj7SxJw","p":"_Yylc9e07CKdqNRD2EosMC2mrhrEa9j5oY_l00Qyy4-jmCA59Q9viyqvveRo0U7cRvFA5BWgWN6GGLh1DG3X-QBqVr0dnk3uzbobb55RYUXyPLuBZI2q6w2oasbiDwPdY7KpkVv_H-bpITQlyDvO8hhucA6rUV7F6KTQVz8M3Ms","q":"y5p3hch-7jJ21TkAhp_Vk1fLCAuD4tbErwQs2of9ja8sB4iJOs5Wn6HD3P7Mc8Plye7qaLHvzc8I5g0tPKWvC0DPd_FLPXiWwMVAzee3NUX_oGeJNOQp11y1w_KqdO9qZqHSEPZ3NcFL_SZMFgggxhM1uzRiPzsVN0lnD_6prZU","qi":"2Grt6uXHm61ji3xSdkBWNtUnj19vS1-7rFJp5SoYztVQVThf_W52BAiXKBdYZDRVoItC_VS2NvAOjeJjhYO_xQ_q3hK7MdtuXfEPpLnyXKkmWo3lrJ26wbeF6l05LexCkI7ShsOuSt-dsyaTJTszuKDIA6YOfWvfo3aVZmlWRaI","use":"sig"}
Value: new("7d/5hl7diJ2rnFL14hEAQf9tzpu29aqXQ8jpJ2iqqKUNFZpdOkEpud0CmRv4H3r8yyk2u/Gqqj9klSy58DJkYXGF5PAYgLyoBIb7L3JXWRbxg4cQ3QJCug13l2OTmpAKoVc+rmX8c3j3h1sNqyJ+7Ql5sS0jSeyiYgIsFNCdnK5alBDyvtcpe/QDpklmP4JCeVpvmf2rLGplk3g5UO5ydJ8UiDXxfDmi+gF6NKJvrGnnah8Ar3G/x88z+tTJtp0DIQFwxXwUM2XZqzEVGm8K2r0w5o9/Keh6bBBaiuH2C78ZOaijGV3DovhR+e9J0cYUYGwT42MZMx9fSWQ/lvWGGnf+Uq3MXJfjWSREfhkp8KTQwR9F7+dnVJWswOEk7jPR8I7hCWTMxJyvaFX3wgAXIVmhrgXZQQbYOqTt56IoqUl0xOJku8dA8opg2UcLlmmuOh6+hfkXKsiiS/H/9c1BVIGj1fCOiT6IePh4wKKSTbwJnPD5EKmdJpgTsUpjcDnXQKY4ReO0UpdRdKxwRDDLeQuG6j+ljGxR9GPudCU9Nmci6rFVI6n5LWYkQxBA1O73RpmXRZPDzntDfpXMEonkmSvOoxaCK2Id7CRKMdqvR0kEouwnhk5WSFtsfi3sA0pkXzPFxwZeWM8vFtbffZOZzXaOhxCOfcj1NClZohlZhyc4jvkxmrpY7PSaAzih0AmHI7y0LYFi6fZu/K4EheVa1+KF55nWZ8ARikHMWKAKkyExkTak7xyN884TDmzURRaPlQg4jzQte5WMNjAG/hlHibdMBNvgwiYd49ZxteJ8ABdbiXVRl+2JGbdjl2ubpQZwOn7bJKlqO56bIwsZ+e4+pXsuOGdBahkHrUjtMEmH3DZbGc6CJLbcmdhdpApLQRRcLAazxJhzAwJ47FRYsHsj57LnYNvmcKdIxw8rxCdLUuzz95uw0T3ankEO5J9sjem+HMEuKdwXK1UcuOn2rjR8Sd/BuvQmeso27dFbPXqXYNS90Ml45YyTvcKSiopD181oZR703TFUSpR7dsiqROMr+p/2jN9h6a8WbQ8xpksyclaQByY/M77AssbXnG6wfhRsntNIINCZLbBnjXOyz6ZHIC5K4tSTdcnWaiYPeRPQmnw9UUvHAcNU2yMWsy0eU377yDS0WstTxOdQutTdkczl8kv5Lo26JiEK7mSIuRK19ffF9Zz8FG8+eKv5zdyIPjyQRDYBysUoDv5huKe2eoxJu/MWS2Pql/ZtUGeD6Ozm3mCvh0vQ9ceagBkY6Ocm3du0ziAKP29Ri0mjg4DizVorbLzsh+EQH/s2Pi9MnjUZDlEmuLl2Xfp7/w4j/8u0N0tVR70VDFuGdKpTjFY3vS8EJrPtyMTM51x1D9rb8gIql8aR/rJw4YF+huxg1mv5n6+tGVqg5msbPmF12eJijP4lkmaRwIpLW5pJTtaDkUj7uOeu1mm4k+Dt5nh0/0jPHzrv6bcTCcbV7UjMHDoTXXqEpFAAJ66rHR7zdAJu+YKsnTIZyLmOpcowq7LL8G9qTvV0OSpyQWUIavRSgbDHFqEqRs+JU94jAzkq8nCY5MTd9m5sIv9InfdT3k+pwpsE/FKge8nghFLtbUrafGkzTky8SE2druvVcIvbfXMfLIKRUYjJgnWc0gQzF5J6pzXM7D2r/RG6JDzASqjlbURq6v9bhNerlOVdMujWKEEVcKWIzlbt4RkihRjM8AUqIZQOyicGQ+4yfIjAHw5viuABONYs3OIWULnFqJxdvS9rNKhfxSjIq9cfqyzevq2xrRoMXEonobh6M3bD2Vang8OAeVeD1OXWPERi4pepCYFS9RJ/Xa/UWxptsqSNuGcb3fAzQSmLpXLGdWRoKXvSe7EYgc0bGcLOjSTu5RURKo+EF9i4KT9EJauf6VXw5dTf/CCIJRXE1bWzXhSCFYntohYhX2ldOCDYpi/jFBC6Vtkw0ud3/xq8Nmhd5gUk+SpngByCZH3Pm3H+jvlbMpiqkDkm1v74hDX13Xhrcw2eWyuqKBVoRCCniUvwpYNbGvBfjC6Hcizv0Aybciwj+4nybt5EPoEUm6S6Gs7fG7QpPdvrzpAxX70MlmdkF/gwyuhbEeJhLK+WL7qAsN5CvHPzVbsIf90x+nGTtMJPgpxVr0tJMj+vprXV4WxutfARBiOnqe58MhA857sd+MzKBgKnoLOBRTiC3qc/0/ULwbG2HCCD7nmwzz7M4nUuMvo8rgS7z0BF68OClT8X3JwSXbL5Wg=="),
Value: utils.Ptr("7d/5hl7diJ2rnFL14hEAQf9tzpu29aqXQ8jpJ2iqqKUNFZpdOkEpud0CmRv4H3r8yyk2u/Gqqj9klSy58DJkYXGF5PAYgLyoBIb7L3JXWRbxg4cQ3QJCug13l2OTmpAKoVc+rmX8c3j3h1sNqyJ+7Ql5sS0jSeyiYgIsFNCdnK5alBDyvtcpe/QDpklmP4JCeVpvmf2rLGplk3g5UO5ydJ8UiDXxfDmi+gF6NKJvrGnnah8Ar3G/x88z+tTJtp0DIQFwxXwUM2XZqzEVGm8K2r0w5o9/Keh6bBBaiuH2C78ZOaijGV3DovhR+e9J0cYUYGwT42MZMx9fSWQ/lvWGGnf+Uq3MXJfjWSREfhkp8KTQwR9F7+dnVJWswOEk7jPR8I7hCWTMxJyvaFX3wgAXIVmhrgXZQQbYOqTt56IoqUl0xOJku8dA8opg2UcLlmmuOh6+hfkXKsiiS/H/9c1BVIGj1fCOiT6IePh4wKKSTbwJnPD5EKmdJpgTsUpjcDnXQKY4ReO0UpdRdKxwRDDLeQuG6j+ljGxR9GPudCU9Nmci6rFVI6n5LWYkQxBA1O73RpmXRZPDzntDfpXMEonkmSvOoxaCK2Id7CRKMdqvR0kEouwnhk5WSFtsfi3sA0pkXzPFxwZeWM8vFtbffZOZzXaOhxCOfcj1NClZohlZhyc4jvkxmrpY7PSaAzih0AmHI7y0LYFi6fZu/K4EheVa1+KF55nWZ8ARikHMWKAKkyExkTak7xyN884TDmzURRaPlQg4jzQte5WMNjAG/hlHibdMBNvgwiYd49ZxteJ8ABdbiXVRl+2JGbdjl2ubpQZwOn7bJKlqO56bIwsZ+e4+pXsuOGdBahkHrUjtMEmH3DZbGc6CJLbcmdhdpApLQRRcLAazxJhzAwJ47FRYsHsj57LnYNvmcKdIxw8rxCdLUuzz95uw0T3ankEO5J9sjem+HMEuKdwXK1UcuOn2rjR8Sd/BuvQmeso27dFbPXqXYNS90Ml45YyTvcKSiopD181oZR703TFUSpR7dsiqROMr+p/2jN9h6a8WbQ8xpksyclaQByY/M77AssbXnG6wfhRsntNIINCZLbBnjXOyz6ZHIC5K4tSTdcnWaiYPeRPQmnw9UUvHAcNU2yMWsy0eU377yDS0WstTxOdQutTdkczl8kv5Lo26JiEK7mSIuRK19ffF9Zz8FG8+eKv5zdyIPjyQRDYBysUoDv5huKe2eoxJu/MWS2Pql/ZtUGeD6Ozm3mCvh0vQ9ceagBkY6Ocm3du0ziAKP29Ri0mjg4DizVorbLzsh+EQH/s2Pi9MnjUZDlEmuLl2Xfp7/w4j/8u0N0tVR70VDFuGdKpTjFY3vS8EJrPtyMTM51x1D9rb8gIql8aR/rJw4YF+huxg1mv5n6+tGVqg5msbPmF12eJijP4lkmaRwIpLW5pJTtaDkUj7uOeu1mm4k+Dt5nh0/0jPHzrv6bcTCcbV7UjMHDoTXXqEpFAAJ66rHR7zdAJu+YKsnTIZyLmOpcowq7LL8G9qTvV0OSpyQWUIavRSgbDHFqEqRs+JU94jAzkq8nCY5MTd9m5sIv9InfdT3k+pwpsE/FKge8nghFLtbUrafGkzTky8SE2druvVcIvbfXMfLIKRUYjJgnWc0gQzF5J6pzXM7D2r/RG6JDzASqjlbURq6v9bhNerlOVdMujWKEEVcKWIzlbt4RkihRjM8AUqIZQOyicGQ+4yfIjAHw5viuABONYs3OIWULnFqJxdvS9rNKhfxSjIq9cfqyzevq2xrRoMXEonobh6M3bD2Vang8OAeVeD1OXWPERi4pepCYFS9RJ/Xa/UWxptsqSNuGcb3fAzQSmLpXLGdWRoKXvSe7EYgc0bGcLOjSTu5RURKo+EF9i4KT9EJauf6VXw5dTf/CCIJRXE1bWzXhSCFYntohYhX2ldOCDYpi/jFBC6Vtkw0ud3/xq8Nmhd5gUk+SpngByCZH3Pm3H+jvlbMpiqkDkm1v74hDX13Xhrcw2eWyuqKBVoRCCniUvwpYNbGvBfjC6Hcizv0Aybciwj+4nybt5EPoEUm6S6Gs7fG7QpPdvrzpAxX70MlmdkF/gwyuhbEeJhLK+WL7qAsN5CvHPzVbsIf90x+nGTtMJPgpxVr0tJMj+vprXV4WxutfARBiOnqe58MhA857sd+MzKBgKnoLOBRTiC3qc/0/ULwbG2HCCD7nmwzz7M4nUuMvo8rgS7z0BF68OClT8X3JwSXbL5Wg=="),
},
}
@@ -566,7 +496,7 @@ func (s *TestService) ResetAppConfig(ctx context.Context) error {
}
// Reload the JWK
if err := s.jwtService.LoadOrGenerateKey(ctx); err != nil {
if err := s.jwtService.LoadOrGenerateKey(); err != nil {
return err
}

View File

@@ -78,7 +78,7 @@ func SendEmail[V any](ctx context.Context, srv *EmailService, toEmail email.Addr
data := &email.TemplateData[V]{
AppName: dbConfig.AppName.Value,
LogoURL: common.EnvConfig.AppURL + "/api/application-images/email",
LogoURL: common.EnvConfig.AppURL + "/api/application-images/logo",
Data: tData,
}
@@ -150,8 +150,7 @@ func SendEmail[V any](ctx context.Context, srv *EmailService, toEmail email.Addr
}
// Send the email
err = srv.sendEmailContent(client, toEmail, c)
if err != nil {
if err := srv.sendEmailContent(client, toEmail, c); err != nil {
return fmt.Errorf("send email content: %w", err)
}

View File

@@ -49,13 +49,6 @@ var ApiKeyExpiringSoonTemplate = email.Template[ApiKeyExpiringSoonTemplateData]{
},
}
var EmailVerificationTemplate = email.Template[EmailVerificationTemplateData]{
Path: "email-verification",
Title: func(data *email.TemplateData[EmailVerificationTemplateData]) string {
return "Verify your " + data.AppName + " email address"
},
}
type NewLoginTemplateData struct {
IPAddress string
Country string
@@ -77,10 +70,5 @@ type ApiKeyExpiringSoonTemplateData struct {
ExpiresAt time.Time
}
type EmailVerificationTemplateData struct {
UserFullName string
VerificationLink string
}
// this is list of all template paths used for preloading templates
var emailTemplatesPaths = []string{NewLoginTemplate.Path, OneTimeAccessTemplate.Path, TestTemplate.Path, ApiKeyExpiringSoonTemplate.Path, EmailVerificationTemplate.Path}
var emailTemplatesPaths = []string{NewLoginTemplate.Path, OneTimeAccessTemplate.Path, TestTemplate.Path, ApiKeyExpiringSoonTemplate.Path}

View File

@@ -56,7 +56,7 @@ func (s *ExportService) extractDatabase() (DatabaseExport, error) {
Tables: map[string][]map[string]any{},
// These tables need to be inserted in a specific order because of foreign key constraints
// Not all tables are listed here, because not all tables are order-dependent
TableOrder: []string{"users", "user_groups", "oidc_clients", "signup_tokens"},
TableOrder: []string{"users", "user_groups", "oidc_clients"},
}
for table := range schema {
@@ -129,39 +129,39 @@ func (s *ExportService) getScanValuesForTable(cols []string, types utils.DBSchem
case "boolean", "bool":
var x bool
if types[col].Nullable {
res[i] = new(new(x))
res[i] = utils.Ptr(utils.Ptr(x))
} else {
res[i] = new(x)
res[i] = utils.Ptr(x)
}
case "blob", "bytea", "jsonb":
// Treat jsonb columns as binary too
var x []byte
if types[col].Nullable {
res[i] = new(new(x))
res[i] = utils.Ptr(utils.Ptr(x))
} else {
res[i] = new(x)
res[i] = utils.Ptr(x)
}
case "timestamp", "timestamptz", "timestamp with time zone", "datetime":
var x datatype.DateTime
if types[col].Nullable {
res[i] = new(new(x))
res[i] = utils.Ptr(utils.Ptr(x))
} else {
res[i] = new(x)
res[i] = utils.Ptr(x)
}
case "integer", "int", "bigint":
var x int64
if types[col].Nullable {
res[i] = new(new(x))
res[i] = utils.Ptr(utils.Ptr(x))
} else {
res[i] = new(x)
res[i] = utils.Ptr(x)
}
default:
// Treat everything else as a string (including the "numeric" type)
var x string
if types[col].Nullable {
res[i] = new(new(x))
res[i] = utils.Ptr(utils.Ptr(x))
} else {
res[i] = new(x)
res[i] = utils.Ptr(x)
}
}
}

View File

@@ -2,7 +2,6 @@ package service
import (
"archive/tar"
"bytes"
"compress/gzip"
"context"
"errors"
@@ -14,7 +13,6 @@ import (
"net/netip"
"os"
"path/filepath"
"strings"
"sync"
"time"
@@ -24,8 +22,6 @@ import (
"github.com/pocket-id/pocket-id/backend/internal/common"
)
const maxTotalSize = 300 * 1024 * 1024 // 300 MB limit for total decompressed size
type GeoLiteService struct {
httpClient *http.Client
disableUpdater bool
@@ -113,11 +109,7 @@ func (s *GeoLiteService) UpdateDatabase(parentCtx context.Context) error {
}
slog.Info("Updating GeoLite2 City database")
downloadUrl := common.EnvConfig.GeoLiteDBUrl
if strings.Contains(downloadUrl, "%s") {
downloadUrl = fmt.Sprintf(downloadUrl, common.EnvConfig.MaxMindLicenseKey)
}
downloadUrl := fmt.Sprintf(common.EnvConfig.GeoLiteDBUrl, common.EnvConfig.MaxMindLicenseKey)
ctx, cancel := context.WithTimeout(parentCtx, 10*time.Minute)
defer cancel()
@@ -159,24 +151,7 @@ func (s *GeoLiteService) isDatabaseUpToDate() bool {
// extractDatabase extracts the database file from the tar.gz archive directly to the target location.
func (s *GeoLiteService) extractDatabase(reader io.Reader) error {
// Check for gzip magic number
buf := make([]byte, 2)
_, err := io.ReadFull(reader, buf)
if err != nil {
return fmt.Errorf("failed to read magic number: %w", err)
}
// Check if the file starts with the gzip magic number
// Gosec returns false positive for "G602: slice index out of range"
//nolint:gosec
isGzip := buf[0] == 0x1f && buf[1] == 0x8b
if !isGzip {
// If not gzip, assume it's a regular database file
return s.writeDatabaseFile(io.MultiReader(bytes.NewReader(buf), reader))
}
gzr, err := gzip.NewReader(io.MultiReader(bytes.NewReader(buf), reader))
gzr, err := gzip.NewReader(reader)
if err != nil {
return fmt.Errorf("failed to create gzip reader: %w", err)
}
@@ -185,6 +160,7 @@ func (s *GeoLiteService) extractDatabase(reader io.Reader) error {
tarReader := tar.NewReader(gzr)
var totalSize int64
const maxTotalSize = 300 * 1024 * 1024 // 300 MB limit for total decompressed size
// Iterate over the files in the tar archive
for {
@@ -246,47 +222,3 @@ func (s *GeoLiteService) extractDatabase(reader io.Reader) error {
return errors.New("GeoLite2-City.mmdb not found in archive")
}
func (s *GeoLiteService) writeDatabaseFile(reader io.Reader) error {
baseDir := filepath.Dir(common.EnvConfig.GeoLiteDBPath)
tmpFile, err := os.CreateTemp(baseDir, "geolite.*.mmdb.tmp")
if err != nil {
return fmt.Errorf("failed to create temporary database file: %w", err)
}
defer tmpFile.Close()
// Limit the amount we read to maxTotalSize.
// We read one extra byte to detect if the source is larger than the limit.
limitReader := io.LimitReader(reader, maxTotalSize+1)
// Write the file contents directly to the temporary file
written, err := io.Copy(tmpFile, limitReader)
if err != nil {
os.Remove(tmpFile.Name())
return fmt.Errorf("failed to write database file: %w", err)
}
if written > maxTotalSize {
os.Remove(tmpFile.Name())
return errors.New("total database size exceeds maximum allowed limit")
}
// Validate the downloaded database file
if db, err := maxminddb.Open(tmpFile.Name()); err == nil {
db.Close()
} else {
os.Remove(tmpFile.Name())
return fmt.Errorf("failed to open downloaded database file: %w", err)
}
// Ensure atomic replacement of the old database file
s.mutex.Lock()
err = os.Rename(tmpFile.Name(), common.EnvConfig.GeoLiteDBPath)
s.mutex.Unlock()
if err != nil {
os.Remove(tmpFile.Name())
return fmt.Errorf("failed to replace database file: %w", err)
}
return nil
}

View File

@@ -61,7 +61,7 @@ func (s *ImportService) ImportFromZip(ctx context.Context, r *zip.Reader) error
// ImportDatabase only imports the database data from the given DatabaseExport struct.
func (s *ImportService) ImportDatabase(dbData DatabaseExport) error {
err := s.resetSchema(dbData.Version)
err := s.resetSchema(dbData.Version, dbData.Provider)
if err != nil {
return err
}
@@ -144,7 +144,7 @@ func (s *ImportService) importUploads(ctx context.Context, files []*zip.File) er
}
// resetSchema drops the existing schema and migrates to the target version
func (s *ImportService) resetSchema(targetVersion uint) error {
func (s *ImportService) resetSchema(targetVersion uint, exportDbProvider string) error {
sqlDb, err := s.db.DB()
if err != nil {
return fmt.Errorf("failed to get sql.DB: %w", err)
@@ -155,19 +155,11 @@ func (s *ImportService) resetSchema(targetVersion uint) error {
return fmt.Errorf("failed to get migrate instance: %w", err)
}
if s.db.Name() == "sqlite" {
s.db.Exec("PRAGMA foreign_keys = OFF;")
}
err = m.Drop()
if err != nil {
return fmt.Errorf("failed to drop existing schema: %w", err)
}
if s.db.Name() == "sqlite" {
defer s.db.Exec("PRAGMA foreign_keys = ON;")
}
// Needs to be called again to re-create the schema_migrations table
m, err = utils.GetEmbeddedMigrateInstance(sqlDb)
if err != nil {

View File

@@ -7,7 +7,6 @@ import (
"fmt"
"time"
"github.com/google/uuid"
"github.com/lestrrat-go/jwx/v3/jwa"
"github.com/lestrrat-go/jwx/v3/jwk"
"github.com/lestrrat-go/jwx/v3/jwt"
@@ -57,10 +56,10 @@ type JwtService struct {
jwksEncoded []byte
}
func NewJwtService(ctx context.Context, db *gorm.DB, appConfigService *AppConfigService) (*JwtService, error) {
func NewJwtService(db *gorm.DB, appConfigService *AppConfigService) (*JwtService, error) {
service := &JwtService{}
err := service.init(ctx, db, appConfigService, &common.EnvConfig)
err := service.init(db, appConfigService, &common.EnvConfig)
if err != nil {
return nil, err
}
@@ -68,16 +67,16 @@ func NewJwtService(ctx context.Context, db *gorm.DB, appConfigService *AppConfig
return service, nil
}
func (s *JwtService) init(ctx context.Context, db *gorm.DB, appConfigService *AppConfigService, envConfig *common.EnvConfigSchema) (err error) {
func (s *JwtService) init(db *gorm.DB, appConfigService *AppConfigService, envConfig *common.EnvConfigSchema) (err error) {
s.appConfigService = appConfigService
s.envConfig = envConfig
s.db = db
// Ensure keys are generated or loaded
return s.LoadOrGenerateKey(ctx)
return s.LoadOrGenerateKey()
}
func (s *JwtService) LoadOrGenerateKey(ctx context.Context) error {
func (s *JwtService) LoadOrGenerateKey() error {
// Get the key provider
keyProvider, err := jwkutils.GetKeyProvider(s.db, s.envConfig, s.appConfigService.GetDbConfig().InstanceID.Value)
if err != nil {
@@ -85,7 +84,7 @@ func (s *JwtService) LoadOrGenerateKey(ctx context.Context) error {
}
// Try loading a key
key, err := keyProvider.LoadKey(ctx)
key, err := keyProvider.LoadKey()
if err != nil {
return fmt.Errorf("failed to load key: %w", err)
}
@@ -106,7 +105,7 @@ func (s *JwtService) LoadOrGenerateKey(ctx context.Context) error {
}
// Save the newly-generated key
err = keyProvider.SaveKey(ctx, s.privateKey)
err = keyProvider.SaveKey(s.privateKey)
if err != nil {
return fmt.Errorf("failed to save private key: %w", err)
}
@@ -194,7 +193,6 @@ func (s *JwtService) GenerateAccessToken(user model.User) (string, error) {
Expiration(now.Add(s.appConfigService.GetDbConfig().SessionDuration.AsDurationMinutes())).
IssuedAt(now).
Issuer(s.envConfig.AppURL).
JwtID(uuid.New().String()).
Build()
if err != nil {
return "", fmt.Errorf("failed to build token: %w", err)
@@ -249,7 +247,6 @@ func (s *JwtService) BuildIDToken(userClaims map[string]any, clientID string, no
Expiration(now.Add(1 * time.Hour)).
IssuedAt(now).
Issuer(s.envConfig.AppURL).
JwtID(uuid.New().String()).
Build()
if err != nil {
return nil, fmt.Errorf("failed to build token: %w", err)
@@ -339,7 +336,6 @@ func (s *JwtService) BuildOAuthAccessToken(user model.User, clientID string) (jw
Expiration(now.Add(1 * time.Hour)).
IssuedAt(now).
Issuer(s.envConfig.AppURL).
JwtID(uuid.New().String()).
Build()
if err != nil {
return nil, fmt.Errorf("failed to build token: %w", err)

View File

@@ -20,14 +20,13 @@ import (
"github.com/pocket-id/pocket-id/backend/internal/common"
"github.com/pocket-id/pocket-id/backend/internal/model"
"github.com/pocket-id/pocket-id/backend/internal/utils"
jwkutils "github.com/pocket-id/pocket-id/backend/internal/utils/jwk"
testutils "github.com/pocket-id/pocket-id/backend/internal/utils/testing"
)
const testEncryptionKey = "0123456789abcdef0123456789abcdef"
const uuidRegexPattern = "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$"
func newTestEnvConfig() *common.EnvConfigSchema {
return &common.EnvConfigSchema{
AppURL: "https://test.example.com",
@@ -39,7 +38,7 @@ func initJwtService(t *testing.T, db *gorm.DB, appConfig *AppConfigService, envC
t.Helper()
service := &JwtService{}
err := service.init(t.Context(), db, appConfig, envConfig)
err := service.init(db, appConfig, envConfig)
require.NoError(t, err, "Failed to initialize JWT service")
return service
@@ -66,7 +65,7 @@ func saveKeyToDatabase(t *testing.T, db *gorm.DB, envConfig *common.EnvConfigSch
keyProvider, err := jwkutils.GetKeyProvider(db, envConfig, appConfig.GetDbConfig().InstanceID.Value)
require.NoError(t, err, "Failed to init key provider")
err = keyProvider.SaveKey(t.Context(), key)
err = keyProvider.SaveKey(key)
require.NoError(t, err, "Failed to save key")
kid, ok := key.KeyID()
@@ -94,7 +93,7 @@ func TestJwtService_Init(t *testing.T) {
// Verify the key has been persisted in the database
keyProvider, err := jwkutils.GetKeyProvider(db, mockEnvConfig, mockConfig.GetDbConfig().InstanceID.Value)
require.NoError(t, err, "Failed to init key provider")
key, err := keyProvider.LoadKey(t.Context())
key, err := keyProvider.LoadKey()
require.NoError(t, err, "Failed to load key from provider")
require.NotNil(t, key, "Key should be present in the database")
@@ -304,7 +303,7 @@ func TestGenerateVerifyAccessToken(t *testing.T) {
user := model.User{
Base: model.Base{ID: "user123"},
Email: new("user@example.com"),
Email: utils.Ptr("user@example.com"),
IsAdmin: false,
}
@@ -324,9 +323,6 @@ func TestGenerateVerifyAccessToken(t *testing.T) {
audience, ok := claims.Audience()
_ = assert.True(t, ok, "Audience not found in token") &&
assert.Equal(t, []string{service.envConfig.AppURL}, audience, "Audience should contain the app URL")
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")
expectedExp := time.Now().Add(1 * time.Hour)
expiration, ok := claims.Expiration()
@@ -340,7 +336,7 @@ func TestGenerateVerifyAccessToken(t *testing.T) {
adminUser := model.User{
Base: model.Base{ID: "admin123"},
Email: new("admin@example.com"),
Email: utils.Ptr("admin@example.com"),
IsAdmin: true,
}
@@ -392,7 +388,7 @@ func TestGenerateVerifyAccessToken(t *testing.T) {
user := model.User{
Base: model.Base{ID: "eddsauser123"},
Email: new("eddsauser@example.com"),
Email: utils.Ptr("eddsauser@example.com"),
IsAdmin: true,
}
@@ -429,7 +425,7 @@ func TestGenerateVerifyAccessToken(t *testing.T) {
user := model.User{
Base: model.Base{ID: "ecdsauser123"},
Email: new("ecdsauser@example.com"),
Email: utils.Ptr("ecdsauser@example.com"),
IsAdmin: true,
}
@@ -466,7 +462,7 @@ func TestGenerateVerifyAccessToken(t *testing.T) {
user := model.User{
Base: model.Base{ID: "rsauser123"},
Email: new("rsauser@example.com"),
Email: utils.Ptr("rsauser@example.com"),
IsAdmin: true,
}
@@ -501,7 +497,7 @@ func TestGenerateVerifyIdToken(t *testing.T) {
t.Run("generates and verifies ID token with standard claims", func(t *testing.T) {
service, _, _ := setupJwtService(t, mockConfig)
userClaims := map[string]any{
userClaims := map[string]interface{}{
"sub": "user123",
"name": "Test User",
"email": "user@example.com",
@@ -524,9 +520,6 @@ func TestGenerateVerifyIdToken(t *testing.T) {
issuer, ok := claims.Issuer()
_ = assert.True(t, ok, "Issuer not found in token") &&
assert.Equal(t, service.envConfig.AppURL, issuer, "Issuer should match app URL")
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")
expectedExp := time.Now().Add(1 * time.Hour)
expiration, ok := claims.Expiration()
@@ -538,7 +531,7 @@ func TestGenerateVerifyIdToken(t *testing.T) {
t.Run("can accept expired tokens if told so", func(t *testing.T) {
service, _, _ := setupJwtService(t, mockConfig)
userClaims := map[string]any{
userClaims := map[string]interface{}{
"sub": "user123",
"name": "Test User",
"email": "user@example.com",
@@ -586,7 +579,7 @@ func TestGenerateVerifyIdToken(t *testing.T) {
t.Run("generates and verifies ID token with nonce", func(t *testing.T) {
service, _, _ := setupJwtService(t, mockConfig)
userClaims := map[string]any{
userClaims := map[string]interface{}{
"sub": "user456",
"name": "Another User",
}
@@ -611,7 +604,7 @@ func TestGenerateVerifyIdToken(t *testing.T) {
t.Run("fails verification with incorrect issuer", func(t *testing.T) {
service, _, _ := setupJwtService(t, mockConfig)
userClaims := map[string]any{
userClaims := map[string]interface{}{
"sub": "user789",
}
tokenString, err := service.GenerateIDToken(userClaims, "client-789", "")
@@ -633,7 +626,7 @@ func TestGenerateVerifyIdToken(t *testing.T) {
require.True(t, ok)
assert.Equal(t, origKeyID, loadedKeyID, "Loaded key should have the same ID as the original")
userClaims := map[string]any{
userClaims := map[string]interface{}{
"sub": "eddsauser456",
"name": "EdDSA User",
"email": "eddsauser@example.com",
@@ -671,7 +664,7 @@ func TestGenerateVerifyIdToken(t *testing.T) {
require.True(t, ok)
assert.Equal(t, origKeyID, loadedKeyID, "Loaded key should have the same ID as the original")
userClaims := map[string]any{
userClaims := map[string]interface{}{
"sub": "ecdsauser456",
"email": "ecdsauser@example.com",
}
@@ -708,7 +701,7 @@ func TestGenerateVerifyIdToken(t *testing.T) {
require.True(t, ok)
assert.Equal(t, origKeyID, loadedKeyID, "Loaded key should have the same ID as the original")
userClaims := map[string]any{
userClaims := map[string]interface{}{
"sub": "rsauser456",
"name": "RSA User",
"email": "rsauser@example.com",
@@ -741,7 +734,7 @@ func TestGenerateVerifyOAuthAccessToken(t *testing.T) {
user := model.User{
Base: model.Base{ID: "user123"},
Email: new("user@example.com"),
Email: utils.Ptr("user@example.com"),
}
const clientID = "test-client-123"
@@ -761,9 +754,6 @@ func TestGenerateVerifyOAuthAccessToken(t *testing.T) {
issuer, ok := claims.Issuer()
_ = assert.True(t, ok, "Issuer not found in token") &&
assert.Equal(t, service.envConfig.AppURL, issuer, "Issuer should match app URL")
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")
expectedExp := time.Now().Add(1 * time.Hour)
expiration, ok := claims.Expiration()
@@ -824,7 +814,7 @@ func TestGenerateVerifyOAuthAccessToken(t *testing.T) {
user := model.User{
Base: model.Base{ID: "eddsauser789"},
Email: new("eddsaoauth@example.com"),
Email: utils.Ptr("eddsaoauth@example.com"),
}
const clientID = "eddsa-oauth-client"
@@ -861,7 +851,7 @@ func TestGenerateVerifyOAuthAccessToken(t *testing.T) {
user := model.User{
Base: model.Base{ID: "ecdsauser789"},
Email: new("ecdsaoauth@example.com"),
Email: utils.Ptr("ecdsaoauth@example.com"),
}
const clientID = "ecdsa-oauth-client"
@@ -898,7 +888,7 @@ func TestGenerateVerifyOAuthAccessToken(t *testing.T) {
user := model.User{
Base: model.Base{ID: "rsauser789"},
Email: new("rsaoauth@example.com"),
Email: utils.Ptr("rsaoauth@example.com"),
}
const clientID = "rsa-oauth-client"

View File

@@ -35,7 +35,6 @@ type LdapService struct {
userService *UserService
groupService *UserGroupService
fileStorage storage.FileStorage
clientFactory func() (ldapClient, error)
}
type savePicture struct {
@@ -44,33 +43,8 @@ type savePicture struct {
picture string
}
type ldapDesiredUser struct {
ldapID string
input dto.UserCreateDto
picture string
}
type ldapDesiredGroup struct {
ldapID string
input dto.UserGroupCreateDto
memberUsernames []string
}
type ldapDesiredState struct {
users []ldapDesiredUser
userIDs map[string]struct{}
groups []ldapDesiredGroup
groupIDs map[string]struct{}
}
type ldapClient interface {
Search(searchRequest *ldap.SearchRequest) (*ldap.SearchResult, error)
Bind(username, password string) error
Close() error
}
func NewLdapService(db *gorm.DB, httpClient *http.Client, appConfigService *AppConfigService, userService *UserService, groupService *UserGroupService, fileStorage storage.FileStorage) *LdapService {
service := &LdapService{
return &LdapService{
db: db,
httpClient: httpClient,
appConfigService: appConfigService,
@@ -78,12 +52,9 @@ func NewLdapService(db *gorm.DB, httpClient *http.Client, appConfigService *AppC
groupService: groupService,
fileStorage: fileStorage,
}
service.clientFactory = service.createClient
return service
}
func (s *LdapService) createClient() (ldapClient, error) {
func (s *LdapService) createClient() (*ldap.Conn, error) {
dbConfig := s.appConfigService.GetDbConfig()
if !dbConfig.LdapEnabled.IsTrue() {
@@ -108,33 +79,24 @@ func (s *LdapService) createClient() (ldapClient, error) {
func (s *LdapService) SyncAll(ctx context.Context) error {
// Setup LDAP connection
client, err := s.clientFactory()
client, err := s.createClient()
if err != nil {
return fmt.Errorf("failed to create LDAP client: %w", err)
}
defer client.Close()
// First, we fetch all users and group from LDAP, which is our "desired state"
desiredState, err := s.fetchDesiredState(ctx, client)
if err != nil {
return fmt.Errorf("failed to fetch LDAP state: %w", err)
}
// Start a transaction
tx := s.db.WithContext(ctx).Begin()
if tx.Error != nil {
return fmt.Errorf("failed to begin database transaction: %w", tx.Error)
}
defer tx.Rollback()
tx := s.db.Begin()
defer func() {
tx.Rollback()
}()
// Reconcile users
savePictures, deleteFiles, err := s.reconcileUsers(ctx, tx, desiredState.users, desiredState.userIDs)
savePictures, deleteFiles, err := s.SyncUsers(ctx, tx, client)
if err != nil {
return fmt.Errorf("failed to sync users: %w", err)
}
// Reconcile groups
err = s.reconcileGroups(ctx, tx, desiredState.groups, desiredState.groupIDs)
err = s.SyncGroups(ctx, tx, client)
if err != nil {
return fmt.Errorf("failed to sync groups: %w", err)
}
@@ -167,59 +129,10 @@ func (s *LdapService) SyncAll(ctx context.Context) error {
return nil
}
func (s *LdapService) fetchDesiredState(ctx context.Context, client ldapClient) (ldapDesiredState, error) {
// Fetch users first so we can use their DNs when resolving group members
users, userIDs, usernamesByDN, err := s.fetchUsersFromLDAP(ctx, client)
if err != nil {
return ldapDesiredState{}, err
}
// Then fetch groups to complete the desired LDAP state snapshot
groups, groupIDs, err := s.fetchGroupsFromLDAP(ctx, client, usernamesByDN)
if err != nil {
return ldapDesiredState{}, err
}
// Apply user admin flags from the desired group membership snapshot.
// This intentionally uses the configured group member attribute rather than
// relying on a user-side reverse-membership attribute such as memberOf.
s.applyAdminGroupMembership(users, groups)
return ldapDesiredState{
users: users,
userIDs: userIDs,
groups: groups,
groupIDs: groupIDs,
}, nil
}
func (s *LdapService) applyAdminGroupMembership(desiredUsers []ldapDesiredUser, desiredGroups []ldapDesiredGroup) {
dbConfig := s.appConfigService.GetDbConfig()
if dbConfig.LdapAdminGroupName.Value == "" {
return
}
adminUsernames := make(map[string]struct{})
for _, group := range desiredGroups {
if group.input.Name != dbConfig.LdapAdminGroupName.Value {
continue
}
for _, username := range group.memberUsernames {
adminUsernames[username] = struct{}{}
}
}
for i := range desiredUsers {
_, isAdmin := adminUsernames[desiredUsers[i].input.Username]
desiredUsers[i].input.IsAdmin = desiredUsers[i].input.IsAdmin || isAdmin
}
}
func (s *LdapService) fetchGroupsFromLDAP(ctx context.Context, client ldapClient, usernamesByDN map[string]string) (desiredGroups []ldapDesiredGroup, ldapGroupIDs map[string]struct{}, err error) {
//nolint:gocognit
func (s *LdapService) SyncGroups(ctx context.Context, tx *gorm.DB, client *ldap.Conn) error {
dbConfig := s.appConfigService.GetDbConfig()
// Query LDAP for all groups we want to manage
searchAttrs := []string{
dbConfig.LdapAttributeGroupName.Value,
dbConfig.LdapAttributeGroupUniqueIdentifier.Value,
@@ -236,42 +149,90 @@ func (s *LdapService) fetchGroupsFromLDAP(ctx context.Context, client ldapClient
)
result, err := client.Search(searchReq)
if err != nil {
return nil, nil, fmt.Errorf("failed to query LDAP groups: %w", err)
return fmt.Errorf("failed to query LDAP: %w", err)
}
// Build the in-memory desired state for groups
ldapGroupIDs = make(map[string]struct{}, len(result.Entries))
desiredGroups = make([]ldapDesiredGroup, 0, len(result.Entries))
// Create a mapping for groups that exist
ldapGroupIDs := make(map[string]struct{}, len(result.Entries))
for _, value := range result.Entries {
ldapID := convertLdapIdToString(value.GetAttributeValue(dbConfig.LdapAttributeGroupUniqueIdentifier.Value))
ldapId := convertLdapIdToString(value.GetAttributeValue(dbConfig.LdapAttributeGroupUniqueIdentifier.Value))
// Skip groups without a valid LDAP ID
if ldapID == "" {
if ldapId == "" {
slog.Warn("Skipping LDAP group without a valid unique identifier", slog.String("attribute", dbConfig.LdapAttributeGroupUniqueIdentifier.Value))
continue
}
ldapGroupIDs[ldapID] = struct{}{}
ldapGroupIDs[ldapId] = struct{}{}
// Try to find the group in the database
var databaseGroup model.UserGroup
err = tx.
WithContext(ctx).
Where("ldap_id = ?", ldapId).
First(&databaseGroup).
Error
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
// This could error with ErrRecordNotFound and we want to ignore that here
return fmt.Errorf("failed to query for LDAP group ID '%s': %w", ldapId, err)
}
// Get group members and add to the correct Group
groupMembers := value.GetAttributeValues(dbConfig.LdapAttributeGroupMember.Value)
memberUsernames := make([]string, 0, len(groupMembers))
membersUserId := make([]string, 0, len(groupMembers))
for _, member := range groupMembers {
username := s.resolveGroupMemberUsername(ctx, client, member, usernamesByDN)
username := getDNProperty(dbConfig.LdapAttributeUserUsername.Value, member)
// If username extraction fails, try to query LDAP directly for the user
if username == "" {
continue
// Query LDAP to get the user by their DN
userSearchReq := ldap.NewSearchRequest(
member,
ldap.ScopeBaseObject,
0, 0, 0, false,
"(objectClass=*)",
[]string{dbConfig.LdapAttributeUserUsername.Value, dbConfig.LdapAttributeUserUniqueIdentifier.Value},
[]ldap.Control{},
)
userResult, err := client.Search(userSearchReq)
if err != nil || len(userResult.Entries) == 0 {
slog.WarnContext(ctx, "Could not resolve group member DN", slog.String("member", member), slog.Any("error", err))
continue
}
username = userResult.Entries[0].GetAttributeValue(dbConfig.LdapAttributeUserUsername.Value)
if username == "" {
slog.WarnContext(ctx, "Could not extract username from group member DN", slog.String("member", member))
continue
}
}
memberUsernames = append(memberUsernames, username)
username = norm.NFC.String(username)
var databaseUser model.User
err = tx.
WithContext(ctx).
Where("username = ? AND ldap_id IS NOT NULL", username).
First(&databaseUser).
Error
if errors.Is(err, gorm.ErrRecordNotFound) {
// The user collides with a non-LDAP user, so we skip it
continue
} else if err != nil {
return fmt.Errorf("failed to query for existing user '%s': %w", username, err)
}
membersUserId = append(membersUserId, databaseUser.ID)
}
syncGroup := dto.UserGroupCreateDto{
Name: value.GetAttributeValue(dbConfig.LdapAttributeGroupName.Value),
FriendlyName: value.GetAttributeValue(dbConfig.LdapAttributeGroupName.Value),
LdapID: ldapID,
LdapID: ldapId,
}
dto.Normalize(&syncGroup)
dto.Normalize(syncGroup)
err = syncGroup.Validate()
if err != nil {
@@ -279,21 +240,66 @@ func (s *LdapService) fetchGroupsFromLDAP(ctx context.Context, client ldapClient
continue
}
desiredGroups = append(desiredGroups, ldapDesiredGroup{
ldapID: ldapID,
input: syncGroup,
memberUsernames: memberUsernames,
})
if databaseGroup.ID == "" {
newGroup, err := s.groupService.createInternal(ctx, syncGroup, tx)
if err != nil {
return fmt.Errorf("failed to create group '%s': %w", syncGroup.Name, err)
}
_, err = s.groupService.updateUsersInternal(ctx, newGroup.ID, membersUserId, tx)
if err != nil {
return fmt.Errorf("failed to sync users for group '%s': %w", syncGroup.Name, err)
}
} else {
_, err = s.groupService.updateInternal(ctx, databaseGroup.ID, syncGroup, true, tx)
if err != nil {
return fmt.Errorf("failed to update group '%s': %w", syncGroup.Name, err)
}
_, err = s.groupService.updateUsersInternal(ctx, databaseGroup.ID, membersUserId, tx)
if err != nil {
return fmt.Errorf("failed to sync users for group '%s': %w", syncGroup.Name, err)
}
}
}
return desiredGroups, ldapGroupIDs, nil
// Get all LDAP groups from the database
var ldapGroupsInDb []model.UserGroup
err = tx.
WithContext(ctx).
Find(&ldapGroupsInDb, "ldap_id IS NOT NULL").
Select("ldap_id").
Error
if err != nil {
return fmt.Errorf("failed to fetch groups from database: %w", err)
}
// Delete groups that no longer exist in LDAP
for _, group := range ldapGroupsInDb {
if _, exists := ldapGroupIDs[*group.LdapID]; exists {
continue
}
err = tx.
WithContext(ctx).
Delete(&model.UserGroup{}, "ldap_id = ?", group.LdapID).
Error
if err != nil {
return fmt.Errorf("failed to delete group '%s': %w", group.Name, err)
}
slog.Info("Deleted group", slog.String("group", group.Name))
}
return nil
}
func (s *LdapService) fetchUsersFromLDAP(ctx context.Context, client ldapClient) (desiredUsers []ldapDesiredUser, ldapUserIDs map[string]struct{}, usernamesByDN map[string]string, err error) {
//nolint:gocognit
func (s *LdapService) SyncUsers(ctx context.Context, tx *gorm.DB, client *ldap.Conn) (savePictures []savePicture, deleteFiles []string, err error) {
dbConfig := s.appConfigService.GetDbConfig()
// Query LDAP for all users we want to manage
searchAttrs := []string{
"memberOf",
"sn",
"cn",
dbConfig.LdapAttributeUserUniqueIdentifier.Value,
@@ -317,197 +323,33 @@ func (s *LdapService) fetchUsersFromLDAP(ctx context.Context, client ldapClient)
result, err := client.Search(searchReq)
if err != nil {
return nil, nil, nil, fmt.Errorf("failed to query LDAP users: %w", err)
return nil, nil, fmt.Errorf("failed to query LDAP: %w", err)
}
// Build the in-memory desired state for users and a DN lookup for group membership resolution
ldapUserIDs = make(map[string]struct{}, len(result.Entries))
usernamesByDN = make(map[string]string, len(result.Entries))
desiredUsers = make([]ldapDesiredUser, 0, len(result.Entries))
// Create a mapping for users that exist
ldapUserIDs := make(map[string]struct{}, len(result.Entries))
savePictures = make([]savePicture, 0, len(result.Entries))
for _, value := range result.Entries {
username := norm.NFC.String(value.GetAttributeValue(dbConfig.LdapAttributeUserUsername.Value))
if normalizedDN := normalizeLDAPDN(value.DN); normalizedDN != "" && username != "" {
usernamesByDN[normalizedDN] = username
}
ldapID := convertLdapIdToString(value.GetAttributeValue(dbConfig.LdapAttributeUserUniqueIdentifier.Value))
ldapId := convertLdapIdToString(value.GetAttributeValue(dbConfig.LdapAttributeUserUniqueIdentifier.Value))
// Skip users without a valid LDAP ID
if ldapID == "" {
if ldapId == "" {
slog.Warn("Skipping LDAP user without a valid unique identifier", slog.String("attribute", dbConfig.LdapAttributeUserUniqueIdentifier.Value))
continue
}
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),
// 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,
LdapID: ldapID,
}
if newUser.DisplayName == "" {
newUser.DisplayName = strings.TrimSpace(newUser.FirstName + " " + newUser.LastName)
}
dto.Normalize(&newUser)
err = newUser.Validate()
if err != nil {
slog.WarnContext(ctx, "LDAP user object is not valid", slog.Any("error", err))
continue
}
desiredUsers = append(desiredUsers, ldapDesiredUser{
ldapID: ldapID,
input: newUser,
picture: value.GetAttributeValue(dbConfig.LdapAttributeUserProfilePicture.Value),
})
}
return desiredUsers, ldapUserIDs, usernamesByDN, nil
}
func (s *LdapService) resolveGroupMemberUsername(ctx context.Context, client ldapClient, member string, usernamesByDN map[string]string) string {
dbConfig := s.appConfigService.GetDbConfig()
// First try the DN cache we built while loading users
username, exists := usernamesByDN[normalizeLDAPDN(member)]
if exists && username != "" {
return username
}
// Then try to extract the username directly from the DN
username = getDNProperty(dbConfig.LdapAttributeUserUsername.Value, member)
if username != "" {
return norm.NFC.String(username)
}
// As a fallback, query LDAP for the referenced entry
userSearchReq := ldap.NewSearchRequest(
member,
ldap.ScopeBaseObject,
0, 0, 0, false,
"(objectClass=*)",
[]string{dbConfig.LdapAttributeUserUsername.Value},
[]ldap.Control{},
)
userResult, err := client.Search(userSearchReq)
if err != nil || len(userResult.Entries) == 0 {
slog.WarnContext(ctx, "Could not resolve group member DN", slog.String("member", member), slog.Any("error", err))
return ""
}
username = userResult.Entries[0].GetAttributeValue(dbConfig.LdapAttributeUserUsername.Value)
if username == "" {
slog.WarnContext(ctx, "Could not extract username from group member DN", slog.String("member", member))
return ""
}
return norm.NFC.String(username)
}
func (s *LdapService) reconcileGroups(ctx context.Context, tx *gorm.DB, desiredGroups []ldapDesiredGroup, ldapGroupIDs map[string]struct{}) error {
// Load the current LDAP-managed state from the database
ldapGroupsInDB, ldapGroupsByID, err := s.loadLDAPGroupsInDB(ctx, tx)
if err != nil {
return fmt.Errorf("failed to fetch groups from database: %w", err)
}
_, _, ldapUsersByUsername, err := s.loadLDAPUsersInDB(ctx, tx)
if err != nil {
return fmt.Errorf("failed to fetch users from database: %w", err)
}
// Apply creates and updates to match the desired LDAP group state
for _, desiredGroup := range desiredGroups {
memberUserIDs := make([]string, 0, len(desiredGroup.memberUsernames))
for _, username := range desiredGroup.memberUsernames {
databaseUser, exists := ldapUsersByUsername[username]
if !exists {
// The user collides with a non-LDAP user or was skipped during user sync, so we ignore it
continue
}
memberUserIDs = append(memberUserIDs, databaseUser.ID)
}
databaseGroup := ldapGroupsByID[desiredGroup.ldapID]
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
_, 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
}
_, 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)
}
}
// Delete groups that are no longer present in LDAP
for _, group := range ldapGroupsInDB {
if group.LdapID == nil {
continue
}
if _, exists := ldapGroupIDs[*group.LdapID]; exists {
continue
}
ldapUserIDs[ldapId] = struct{}{}
// Get the user from the database
var databaseUser model.User
err = tx.
WithContext(ctx).
Delete(&model.UserGroup{}, "ldap_id = ?", *group.LdapID).
Where("ldap_id = ?", ldapId).
First(&databaseUser).
Error
if err != nil {
return fmt.Errorf("failed to delete group '%s': %w", group.Name, err)
}
slog.Info("Deleted group", slog.String("group", group.Name))
}
return nil
}
//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()
// Load the current LDAP-managed state from the database
ldapUsersInDB, ldapUsersByID, _, err := s.loadLDAPUsersInDB(ctx, tx)
if err != nil {
return nil, nil, fmt.Errorf("failed to fetch users from database: %w", err)
}
// Apply creates and updates to match the desired LDAP user state
savePictures = make([]savePicture, 0, len(desiredUsers))
for _, desiredUser := range desiredUsers {
databaseUser := ldapUsersByID[desiredUser.ldapID]
// If a user is found (even if disabled), enable them since they're now back in LDAP.
// If a user is found (even if disabled), enable them since they're now back in LDAP
if databaseUser.ID != "" && databaseUser.Disabled {
err = tx.
WithContext(ctx).
@@ -515,52 +357,95 @@ func (s *LdapService) reconcileUsers(ctx context.Context, tx *gorm.DB, desiredUs
Where("id = ?", databaseUser.ID).
Update("disabled", false).
Error
if err != nil {
return nil, nil, fmt.Errorf("failed to enable user %s: %w", databaseUser.Username, err)
}
}
databaseUser.Disabled = false
ldapUsersByID[desiredUser.ldapID] = databaseUser
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
// This could error with ErrRecordNotFound and we want to ignore that here
return nil, nil, fmt.Errorf("failed to query for LDAP user ID '%s': %w", ldapId, err)
}
// Check if user is admin by checking if they are in the admin group
isAdmin := false
for _, group := range value.GetAttributeValues("memberOf") {
if getDNProperty(dbConfig.LdapAttributeGroupName.Value, group) == dbConfig.LdapAdminGroupName.Value {
isAdmin = true
break
}
}
newUser := dto.UserCreateDto{
Username: value.GetAttributeValue(dbConfig.LdapAttributeUserUsername.Value),
Email: utils.PtrOrNil(value.GetAttributeValue(dbConfig.LdapAttributeUserEmail.Value)),
FirstName: value.GetAttributeValue(dbConfig.LdapAttributeUserFirstName.Value),
LastName: value.GetAttributeValue(dbConfig.LdapAttributeUserLastName.Value),
DisplayName: value.GetAttributeValue(dbConfig.LdapAttributeUserDisplayName.Value),
IsAdmin: isAdmin,
LdapID: ldapId,
}
if newUser.DisplayName == "" {
newUser.DisplayName = strings.TrimSpace(newUser.FirstName + " " + newUser.LastName)
}
dto.Normalize(newUser)
err = newUser.Validate()
if err != nil {
slog.WarnContext(ctx, "LDAP user object is not valid", slog.Any("error", err))
continue
}
userID := databaseUser.ID
if databaseUser.ID == "" {
createdUser, err := s.userService.createUserInternal(ctx, desiredUser.input, true, tx)
createdUser, err := s.userService.createUserInternal(ctx, newUser, true, tx)
if errors.Is(err, &common.AlreadyInUseError{}) {
slog.Warn("Skipping creating LDAP user", slog.String("username", desiredUser.input.Username), slog.Any("error", err))
slog.Warn("Skipping creating LDAP user", slog.String("username", newUser.Username), slog.Any("error", err))
continue
} else if err != nil {
return nil, nil, fmt.Errorf("error creating user '%s': %w", desiredUser.input.Username, err)
return nil, nil, fmt.Errorf("error creating user '%s': %w", newUser.Username, err)
}
userID = createdUser.ID
ldapUsersByID[desiredUser.ldapID] = createdUser
} else {
_, err = s.userService.updateUserInternal(ctx, databaseUser.ID, desiredUser.input, false, true, tx)
_, err = s.userService.updateUserInternal(ctx, databaseUser.ID, newUser, false, true, tx)
if errors.Is(err, &common.AlreadyInUseError{}) {
slog.Warn("Skipping updating LDAP user", slog.String("username", desiredUser.input.Username), slog.Any("error", err))
slog.Warn("Skipping updating LDAP user", slog.String("username", newUser.Username), slog.Any("error", err))
continue
} else if err != nil {
return nil, nil, fmt.Errorf("error updating user '%s': %w", desiredUser.input.Username, err)
return nil, nil, fmt.Errorf("error updating user '%s': %w", newUser.Username, err)
}
}
if desiredUser.picture != "" {
// Save profile picture
pictureString := value.GetAttributeValue(dbConfig.LdapAttributeUserProfilePicture.Value)
if pictureString != "" {
// Storage operations must be executed outside of a transaction
savePictures = append(savePictures, savePicture{
userID: userID,
username: desiredUser.input.Username,
picture: desiredUser.picture,
userID: databaseUser.ID,
username: userID,
picture: pictureString,
})
}
}
// Disable or delete users that are no longer present in LDAP
deleteFiles = make([]string, 0, len(ldapUsersInDB))
for _, user := range ldapUsersInDB {
if user.LdapID == nil {
continue
}
// Get all LDAP users from the database
var ldapUsersInDb []model.User
err = tx.
WithContext(ctx).
Find(&ldapUsersInDb, "ldap_id IS NOT NULL").
Select("id, username, ldap_id, disabled").
Error
if err != nil {
return nil, nil, fmt.Errorf("failed to fetch users from database: %w", err)
}
// Mark users as disabled or delete users that no longer exist in LDAP
deleteFiles = make([]string, 0, len(ldapUserIDs))
for _, user := range ldapUsersInDb {
// Skip if the user ID exists in the fetched LDAP results
if _, exists := ldapUserIDs[*user.LdapID]; exists {
continue
}
@@ -572,73 +457,29 @@ func (s *LdapService) reconcileUsers(ctx context.Context, tx *gorm.DB, desiredUs
}
slog.Info("Disabled user", slog.String("username", user.Username))
continue
}
err = s.userService.deleteUserInternal(ctx, tx, user.ID, true)
if err != nil {
target := &common.LdapUserUpdateError{}
if errors.As(err, &target) {
return nil, nil, fmt.Errorf("failed to delete user %s: LDAP user must be disabled before deletion", user.Username)
} else {
err = s.userService.deleteUserInternal(ctx, tx, user.ID, true)
if err != nil {
target := &common.LdapUserUpdateError{}
if errors.As(err, &target) {
return nil, nil, fmt.Errorf("failed to delete user %s: LDAP user must be disabled before deletion", user.Username)
}
return nil, nil, fmt.Errorf("failed to delete user %s: %w", user.Username, err)
}
return nil, nil, fmt.Errorf("failed to delete user %s: %w", user.Username, err)
}
slog.Info("Deleted user", slog.String("username", user.Username))
deleteFiles = append(deleteFiles, path.Join("profile-pictures", user.ID+".png"))
slog.Info("Deleted user", slog.String("username", user.Username))
// Storage operations must be executed outside of a transaction
deleteFiles = append(deleteFiles, path.Join("profile-pictures", user.ID+".png"))
}
}
return savePictures, deleteFiles, nil
}
func (s *LdapService) loadLDAPUsersInDB(ctx context.Context, tx *gorm.DB) (users []model.User, byLdapID map[string]model.User, byUsername map[string]model.User, err error) {
// Load all LDAP-managed users and index them by LDAP ID and by username
err = tx.
WithContext(ctx).
Select("id, username, ldap_id, disabled").
Where("ldap_id IS NOT NULL").
Find(&users).
Error
if err != nil {
return nil, nil, nil, err
}
byLdapID = make(map[string]model.User, len(users))
byUsername = make(map[string]model.User, len(users))
for _, user := range users {
byLdapID[*user.LdapID] = user
byUsername[user.Username] = user
}
return users, byLdapID, byUsername, nil
}
func (s *LdapService) loadLDAPGroupsInDB(ctx context.Context, tx *gorm.DB) ([]model.UserGroup, map[string]model.UserGroup, error) {
var groups []model.UserGroup
// Load all LDAP-managed groups and index them by LDAP ID
err := tx.
WithContext(ctx).
Select("id, name, ldap_id").
Where("ldap_id IS NOT NULL").
Find(&groups).
Error
if err != nil {
return nil, nil, err
}
groupsByID := make(map[string]model.UserGroup, len(groups))
for _, group := range groups {
groupsByID[*group.LdapID] = group
}
return groups, groupsByID, nil
}
func (s *LdapService) saveProfilePicture(parentCtx context.Context, userId string, pictureString string) error {
var reader io.ReadSeeker
// Accept either a URL, a base64-encoded payload, or raw binary data
_, err := url.ParseRequestURI(pictureString)
if err == nil {
ctx, cancel := context.WithTimeout(parentCtx, 15*time.Second)
@@ -680,31 +521,6 @@ func (s *LdapService) saveProfilePicture(parentCtx context.Context, userId strin
return nil
}
// normalizeLDAPDN returns a canonical lowercase form of a DN for use as a map key.
// Different LDAP servers may format the same DN with varying attribute type casing (e.g. "CN=" vs "cn=") or extra whitespace (e.g. "dc=example, dc=com").
// Without normalization, cache lookups in usernamesByDN would miss when a member attribute value uses a different format than the DN returned in the search entry
//
// ldap.ParseDN is used instead of simple lowercasing because it correctly handles multi-valued RDNs (joined with "+") and strips inter-component whitespace.
// If parsing fails for any reason, we fall back to a simple lowercase+trim.
func normalizeLDAPDN(dn string) string {
parsed, err := ldap.ParseDN(dn)
if err != nil {
return strings.ToLower(strings.TrimSpace(dn))
}
// Reconstruct the DN in a canonical form: lowercase type=lowercase value, with RDN components separated by "," and multi-value attributes by "+"
parts := make([]string, 0, len(parsed.RDNs))
for _, rdn := range parsed.RDNs {
attrs := make([]string, 0, len(rdn.Attributes))
for _, attr := range rdn.Attributes {
attrs = append(attrs, strings.ToLower(attr.Type)+"="+strings.ToLower(attr.Value))
}
parts = append(parts, strings.Join(attrs, "+"))
}
return strings.Join(parts, ",")
}
// getDNProperty returns the value of a property from a LDAP identifier
// See: https://learn.microsoft.com/en-us/previous-versions/windows/desktop/ldap/distinguished-names
func getDNProperty(property string, str string) string {
@@ -712,7 +528,7 @@ func getDNProperty(property string, str string) string {
// First we split at the comma
property = strings.ToLower(property)
l := len(property) + 1
for v := range strings.SplitSeq(str, ",") {
for _, v := range strings.Split(str, ",") {
v = strings.TrimSpace(v)
if len(v) > l && strings.ToLower(v)[0:l] == property+"=" {
return v[l:]

View File

@@ -1,368 +1,9 @@
package service
import (
"net/http"
"testing"
"github.com/go-ldap/ldap/v3"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
"github.com/pocket-id/pocket-id/backend/internal/model"
"github.com/pocket-id/pocket-id/backend/internal/storage"
testutils "github.com/pocket-id/pocket-id/backend/internal/utils/testing"
)
type fakeLDAPClient struct {
searchFn func(searchRequest *ldap.SearchRequest) (*ldap.SearchResult, error)
}
func (c *fakeLDAPClient) Search(searchRequest *ldap.SearchRequest) (*ldap.SearchResult, error) {
if c.searchFn == nil {
return nil, nil
}
return c.searchFn(searchRequest)
}
func (c *fakeLDAPClient) Bind(_, _ string) error {
return nil
}
func (c *fakeLDAPClient) Close() error {
return nil
}
func TestLdapServiceSyncAllReconcilesUsersAndGroups(t *testing.T) {
service, db := newTestLdapService(t, 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": {""},
}),
ldapEntry("uid=bob,ou=people,dc=example,dc=com", map[string][]string{
"entryUUID": {"u-bob"},
"uid": {"bob"},
"mail": {"bob@example.com"},
"givenName": {"Bob"},
"sn": {"Brown"},
"displayName": {""},
}),
),
ldapSearchResult(
ldapEntry("cn=admins,ou=groups,dc=example,dc=com", map[string][]string{
"entryUUID": {"g-admins"},
"cn": {"admins"},
"member": {"uid=alice,ou=people,dc=example,dc=com"},
}),
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",
"uid=bob, ou=people, dc=example, dc=com",
},
}),
),
))
aliceLdapID := "u-alice"
missingLdapID := "u-missing"
teamLdapID := "g-team"
oldGroupLdapID := "g-old"
require.NoError(t, db.Create(&model.User{
Username: "alice-old",
Email: new("alice-old@example.com"),
EmailVerified: true,
FirstName: "Old",
LastName: "Name",
DisplayName: "Old Name",
LdapID: &aliceLdapID,
Disabled: true,
}).Error)
require.NoError(t, db.Create(&model.User{
Username: "missing",
Email: new("missing@example.com"),
EmailVerified: true,
FirstName: "Missing",
LastName: "User",
DisplayName: "Missing User",
LdapID: &missingLdapID,
}).Error)
require.NoError(t, db.Create(&model.UserGroup{
Name: "team-old",
FriendlyName: "team-old",
LdapID: &teamLdapID,
}).Error)
require.NoError(t, db.Create(&model.UserGroup{
Name: "old-group",
FriendlyName: "old-group",
LdapID: &oldGroupLdapID,
}).Error)
require.NoError(t, service.SyncAll(t.Context()))
var alice model.User
require.NoError(t, db.First(&alice, "ldap_id = ?", aliceLdapID).Error)
assert.Equal(t, "alice", alice.Username)
assert.Equal(t, new("alice@example.com"), alice.Email)
assert.Equal(t, "Alice", alice.FirstName)
assert.Equal(t, "Jones", alice.LastName)
assert.Equal(t, "Alice Jones", alice.DisplayName)
assert.True(t, alice.IsAdmin)
assert.False(t, alice.Disabled)
var bob model.User
require.NoError(t, db.First(&bob, "ldap_id = ?", "u-bob").Error)
assert.Equal(t, "bob", bob.Username)
assert.Equal(t, "Bob Brown", bob.DisplayName)
var missing model.User
require.NoError(t, db.First(&missing, "ldap_id = ?", missingLdapID).Error)
assert.True(t, missing.Disabled)
var oldGroupCount int64
require.NoError(t, db.Model(&model.UserGroup{}).Where("ldap_id = ?", oldGroupLdapID).Count(&oldGroupCount).Error)
assert.Zero(t, oldGroupCount)
var team model.UserGroup
require.NoError(t, db.Preload("Users").First(&team, "ldap_id = ?", teamLdapID).Error)
assert.Equal(t, "team", team.Name)
assert.Equal(t, "team", team.FriendlyName)
assert.ElementsMatch(t, []string{"alice", "bob"}, usernames(team.Users))
}
func TestLdapServiceSyncAllHandlesDuplicateLDAPIDsInSingleRun(t *testing.T) {
service, db := newTestLdapService(t, newFakeLDAPClient(
ldapSearchResult(
ldapEntry("uid=alice,ou=people,dc=example,dc=com", map[string][]string{
"entryUUID": {"u-dup"},
"uid": {"alice"},
"mail": {"alice@example.com"},
"givenName": {"Alice"},
"sn": {"Doe"},
"displayName": {"Alice Doe"},
}),
ldapEntry("uid=alice,ou=people,dc=example,dc=com", map[string][]string{
"entryUUID": {"u-dup"},
"uid": {"alice"},
"mail": {"alice@example.com"},
"givenName": {"Alicia"},
"sn": {"Doe"},
"displayName": {"Alicia Doe"},
}),
),
ldapSearchResult(
ldapEntry("cn=team,ou=groups,dc=example,dc=com", map[string][]string{
"entryUUID": {"g-dup"},
"cn": {"team"},
"member": {"uid=alice,ou=people,dc=example,dc=com"},
}),
ldapEntry("cn=team,ou=groups,dc=example,dc=com", map[string][]string{
"entryUUID": {"g-dup"},
"cn": {"team-renamed"},
"member": {"uid=alice,ou=people,dc=example,dc=com"},
}),
),
))
require.NoError(t, service.SyncAll(t.Context()))
var users []model.User
require.NoError(t, db.Find(&users, "ldap_id = ?", "u-dup").Error)
require.Len(t, users, 1)
assert.Equal(t, "alice", users[0].Username)
assert.Equal(t, "Alicia", users[0].FirstName)
assert.Equal(t, "Alicia Doe", users[0].DisplayName)
var groups []model.UserGroup
require.NoError(t, db.Preload("Users").Find(&groups, "ldap_id = ?", "g-dup").Error)
require.Len(t, groups, 1)
assert.Equal(t, "team-renamed", groups[0].Name)
assert.Equal(t, "team-renamed", groups[0].FriendlyName)
assert.ElementsMatch(t, []string{"alice"}, usernames(groups[0].Users))
}
func TestLdapServiceSyncAllSetsAdminFromGroupMembership(t *testing.T) {
tests := []struct {
name string
appConfig *model.AppConfig
groupEntry *ldap.Entry
groupName string
groupLookup string
}{
{
name: "memberOf missing on user",
appConfig: defaultTestLDAPAppConfig(),
groupEntry: ldapEntry("cn=admins,ou=groups,dc=example,dc=com", map[string][]string{
"entryUUID": {"g-admins"},
"cn": {"admins"},
"member": {"uid=testadmin,ou=people,dc=example,dc=com"},
}),
groupName: "admins",
groupLookup: "g-admins",
},
{
name: "configured group name attribute differs from DN RDN",
appConfig: func() *model.AppConfig {
cfg := defaultTestLDAPAppConfig()
cfg.LdapAttributeGroupName = model.AppConfigVariable{Value: "displayName"}
cfg.LdapAdminGroupName = model.AppConfigVariable{Value: "pocketid.admin"}
return cfg
}(),
groupEntry: ldapEntry("cn=admins,ou=groups,dc=example,dc=com", map[string][]string{
"entryUUID": {"g-display-admins"},
"cn": {"admins"},
"displayName": {"pocketid.admin"},
"member": {"uid=testadmin,ou=people,dc=example,dc=com"},
}),
groupName: "pocketid.admin",
groupLookup: "g-display-admins",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
service, db := newTestLdapServiceWithAppConfig(t, tt.appConfig, newFakeLDAPClient(
ldapSearchResult(
ldapEntry("uid=testadmin,ou=people,dc=example,dc=com", map[string][]string{
"entryUUID": {"u-testadmin"},
"uid": {"testadmin"},
"mail": {"testadmin@example.com"},
"givenName": {"Test"},
"sn": {"Admin"},
"displayName": {""},
}),
),
ldapSearchResult(tt.groupEntry),
))
require.NoError(t, service.SyncAll(t.Context()))
var user model.User
require.NoError(t, db.First(&user, "ldap_id = ?", "u-testadmin").Error)
assert.True(t, user.IsAdmin)
var group model.UserGroup
require.NoError(t, db.Preload("Users").First(&group, "ldap_id = ?", tt.groupLookup).Error)
assert.Equal(t, tt.groupName, group.Name)
assert.ElementsMatch(t, []string{"testadmin"}, usernames(group.Users))
})
}
}
func newTestLdapService(t *testing.T, client ldapClient) (*LdapService, *gorm.DB) {
t.Helper()
return newTestLdapServiceWithAppConfig(t, defaultTestLDAPAppConfig(), client)
}
func newTestLdapServiceWithAppConfig(t *testing.T, appConfigModel *model.AppConfig, client ldapClient) (*LdapService, *gorm.DB) {
t.Helper()
db := testutils.NewDatabaseForTest(t)
fileStorage, err := storage.NewDatabaseStorage(db)
require.NoError(t, err)
appConfig := NewTestAppConfigService(appConfigModel)
groupService := NewUserGroupService(db, appConfig, nil)
userService := NewUserService(
db,
nil,
nil,
nil,
appConfig,
NewCustomClaimService(db),
NewAppImagesService(map[string]string{}, fileStorage),
nil,
fileStorage,
)
service := NewLdapService(db, &http.Client{}, appConfig, userService, groupService, fileStorage)
service.clientFactory = func() (ldapClient, error) {
return client, nil
}
return service, db
}
func defaultTestLDAPAppConfig() *model.AppConfig {
return &model.AppConfig{
RequireUserEmail: model.AppConfigVariable{Value: "false"},
LdapEnabled: model.AppConfigVariable{Value: "true"},
LdapBase: model.AppConfigVariable{Value: "dc=example,dc=com"},
LdapUserSearchFilter: model.AppConfigVariable{Value: "(objectClass=person)"},
LdapUserGroupSearchFilter: model.AppConfigVariable{Value: "(objectClass=groupOfNames)"},
LdapAttributeUserUniqueIdentifier: model.AppConfigVariable{Value: "entryUUID"},
LdapAttributeUserUsername: model.AppConfigVariable{Value: "uid"},
LdapAttributeUserEmail: model.AppConfigVariable{Value: "mail"},
LdapAttributeUserFirstName: model.AppConfigVariable{Value: "givenName"},
LdapAttributeUserLastName: model.AppConfigVariable{Value: "sn"},
LdapAttributeUserDisplayName: model.AppConfigVariable{Value: "displayName"},
LdapAttributeUserProfilePicture: model.AppConfigVariable{Value: "jpegPhoto"},
LdapAttributeGroupMember: model.AppConfigVariable{Value: "member"},
LdapAttributeGroupUniqueIdentifier: model.AppConfigVariable{Value: "entryUUID"},
LdapAttributeGroupName: model.AppConfigVariable{Value: "cn"},
LdapAdminGroupName: model.AppConfigVariable{Value: "admins"},
LdapSoftDeleteUsers: model.AppConfigVariable{Value: "true"},
}
}
func newFakeLDAPClient(userResult, groupResult *ldap.SearchResult) ldapClient {
return &fakeLDAPClient{
searchFn: func(searchRequest *ldap.SearchRequest) (*ldap.SearchResult, error) {
switch searchRequest.Filter {
case "(objectClass=person)":
return userResult, nil
case "(objectClass=groupOfNames)":
return groupResult, nil
default:
return &ldap.SearchResult{}, nil
}
},
}
}
func ldapSearchResult(entries ...*ldap.Entry) *ldap.SearchResult {
return &ldap.SearchResult{Entries: entries}
}
func ldapEntry(dn string, attrs map[string][]string) *ldap.Entry {
entry := &ldap.Entry{
DN: dn,
Attributes: make([]*ldap.EntryAttribute, 0, len(attrs)),
}
for name, values := range attrs {
entry.Attributes = append(entry.Attributes, &ldap.EntryAttribute{
Name: name,
Values: values,
})
}
return entry
}
func usernames(users []model.User) []string {
result := make([]string, 0, len(users))
for _, user := range users {
result = append(result, user.Username)
}
return result
}
func TestGetDNProperty(t *testing.T) {
tests := []struct {
name string
@@ -423,58 +64,10 @@ func TestGetDNProperty(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := getDNProperty(tt.property, tt.dn)
assert.Equalf(t, tt.expectedResult, result, "getDNProperty(%q, %q)", tt.property, tt.dn)
})
}
}
func TestNormalizeLDAPDN(t *testing.T) {
tests := []struct {
name string
input string
expected string
}{
{
name: "already normalized",
input: "cn=alice,dc=example,dc=com",
expected: "cn=alice,dc=example,dc=com",
},
{
name: "uppercase attribute types",
input: "CN=Alice,DC=example,DC=com",
expected: "cn=alice,dc=example,dc=com",
},
{
name: "spaces after commas",
input: "cn=alice, dc=example, dc=com",
expected: "cn=alice,dc=example,dc=com",
},
{
name: "uppercase types and spaces",
input: "CN=Alice, DC=example, DC=com",
expected: "cn=alice,dc=example,dc=com",
},
{
name: "multi-valued RDN",
input: "cn=alice+uid=a123,dc=example,dc=com",
expected: "cn=alice+uid=a123,dc=example,dc=com",
},
{
name: "invalid DN falls back to lowercase+trim",
input: " NOT A VALID DN ",
expected: "not a valid dn",
},
{
name: "empty string",
input: "",
expected: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := normalizeLDAPDN(tt.input)
assert.Equalf(t, tt.expected, result, "normalizeLDAPDN(%q)", tt.input)
if result != tt.expectedResult {
t.Errorf("getDNProperty(%q, %q) = %q, want %q",
tt.property, tt.dn, result, tt.expectedResult)
}
})
}
}
@@ -505,7 +98,9 @@ func TestConvertLdapIdToString(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := convertLdapIdToString(tt.input)
assert.Equal(t, tt.expected, got)
if got != tt.expected {
t.Errorf("Expected %q, got %q", tt.expected, got)
}
})
}
}

View File

@@ -15,6 +15,7 @@ import (
"net/http"
"net/url"
"path"
"regexp"
"slices"
"strings"
"time"
@@ -56,7 +57,6 @@ type OidcService struct {
auditLogService *AuditLogService
customClaimService *CustomClaimService
webAuthnService *WebAuthnService
scimService *ScimService
httpClient *http.Client
jwkCache *jwk.Cache
@@ -71,7 +71,6 @@ func NewOidcService(
auditLogService *AuditLogService,
customClaimService *CustomClaimService,
webAuthnService *WebAuthnService,
scimService *ScimService,
httpClient *http.Client,
fileStorage storage.FileStorage,
) (s *OidcService, err error) {
@@ -82,7 +81,6 @@ func NewOidcService(
auditLogService: auditLogService,
customClaimService: customClaimService,
webAuthnService: webAuthnService,
scimService: scimService,
httpClient: httpClient,
fileStorage: fileStorage,
}
@@ -171,7 +169,7 @@ func (s *OidcService) Authorize(ctx context.Context, input dto.AuthorizeOidcClie
return "", "", err
}
if !IsUserGroupAllowedToAuthorize(user, client) {
if !s.IsUserGroupAllowedToAuthorize(user, client) {
return "", "", &common.OidcAccessDeniedError{}
}
@@ -227,8 +225,8 @@ func (s *OidcService) hasAuthorizedClientInternal(ctx context.Context, clientID,
}
// IsUserGroupAllowedToAuthorize checks if the user group of the user is allowed to authorize the client
func IsUserGroupAllowedToAuthorize(user model.User, client model.OidcClient) bool {
if !client.IsGroupRestricted {
func (s *OidcService) IsUserGroupAllowedToAuthorize(user model.User, client model.OidcClient) bool {
if len(client.AllowedUserGroups) == 0 {
return true
}
@@ -314,7 +312,7 @@ 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)
idToken, err := s.jwtService.GenerateIDToken(userClaims, input.ClientID, "")
if err != nil {
return CreatedTokens{}, err
}
@@ -404,7 +402,7 @@ func (s *OidcService) createTokenFromAuthorizationCode(ctx context.Context, inpu
}
}
if authorizationCodeMetaData.ClientID != input.ClientID || authorizationCodeMetaData.ExpiresAt.ToTime().Before(time.Now()) {
if authorizationCodeMetaData.ClientID != input.ClientID && authorizationCodeMetaData.ExpiresAt.ToTime().Before(time.Now()) {
return CreatedTokens{}, &common.OidcInvalidAuthorizationCodeError{}
}
@@ -731,7 +729,7 @@ func (s *OidcService) CreateClient(ctx context.Context, input dto.OidcClientCrea
Base: model.Base{
ID: input.ID,
},
CreatedByID: new(userID),
CreatedByID: utils.Ptr(userID),
}
updateOIDCClientModelFromDto(&client, &input.OidcClientUpdateDto)
@@ -780,14 +778,6 @@ func (s *OidcService) UpdateClient(ctx context.Context, clientID string, input d
updateOIDCClientModelFromDto(&client, &input)
if !input.IsGroupRestricted {
// Clear allowed user groups if the restriction is removed
err = tx.Model(&client).Association("AllowedUserGroups").Clear()
if err != nil {
return model.OidcClient{}, err
}
}
err = tx.WithContext(ctx).Save(&client).Error
if err != nil {
return model.OidcClient{}, err
@@ -826,7 +816,6 @@ func updateOIDCClientModelFromDto(client *model.OidcClient, input *dto.OidcClien
client.PkceEnabled = input.IsPublic || input.PkceEnabled
client.RequiresReauthentication = input.RequiresReauthentication
client.LaunchURL = input.LaunchURL
client.IsGroupRestricted = input.IsGroupRestricted
// Credentials
client.Credentials.FederatedIdentities = make([]model.OidcClientFederatedIdentity, len(input.Credentials.FederatedIdentities))
@@ -1091,7 +1080,6 @@ func (s *OidcService) UpdateAllowedUserGroups(ctx context.Context, id string, in
return model.OidcClient{}, err
}
s.scimService.ScheduleSync()
return client, nil
}
@@ -1208,7 +1196,7 @@ func (s *OidcService) getCallbackURL(client *model.OidcClient, inputCallbackURL
// If URLs are already configured, validate against them
if len(client.CallbackURLs) > 0 {
matched, err := utils.GetCallbackURLFromList(client.CallbackURLs, inputCallbackURL)
matched, err := s.getCallbackURLFromList(client.CallbackURLs, inputCallbackURL)
if err != nil {
return "", err
} else if matched == "" {
@@ -1231,7 +1219,7 @@ func (s *OidcService) getLogoutCallbackURL(client *model.OidcClient, inputLogout
return client.LogoutCallbackURLs[0], nil
}
matched, err := utils.GetCallbackURLFromList(client.LogoutCallbackURLs, inputLogoutCallbackURL)
matched, err := s.getCallbackURLFromList(client.LogoutCallbackURLs, inputLogoutCallbackURL)
if err != nil {
return "", err
} else if matched == "" {
@@ -1241,6 +1229,21 @@ func (s *OidcService) getLogoutCallbackURL(client *model.OidcClient, inputLogout
return matched, nil
}
func (s *OidcService) getCallbackURLFromList(urls []string, inputCallbackURL string) (callbackURL string, err error) {
for _, callbackPattern := range urls {
regexPattern := "^" + strings.ReplaceAll(regexp.QuoteMeta(callbackPattern), `\*`, ".*") + "$"
matched, err := regexp.MatchString(regexPattern, inputCallbackURL)
if err != nil {
return "", err
}
if matched {
return inputCallbackURL, nil
}
}
return "", nil
}
func (s *OidcService) addCallbackURLToClient(ctx context.Context, client *model.OidcClient, callbackURL string, tx *gorm.DB) error {
// Add the new callback URL to the existing list
client.CallbackURLs = append(client.CallbackURLs, callbackURL)
@@ -1282,7 +1285,6 @@ func (s *OidcService) CreateDeviceAuthorization(ctx context.Context, input dto.O
ExpiresAt: datatype.DateTime(time.Now().Add(DeviceCodeDuration)),
IsAuthorized: false,
ClientID: client.ID,
Nonce: input.Nonce,
}
if err := s.db.Create(deviceAuth).Error; err != nil {
@@ -1330,7 +1332,7 @@ func (s *OidcService) VerifyDeviceCode(ctx context.Context, userCode string, use
return fmt.Errorf("error finding user groups: %w", err)
}
if !IsUserGroupAllowedToAuthorize(user, deviceAuth.Client) {
if !s.IsUserGroupAllowedToAuthorize(user, deviceAuth.Client) {
return &common.OidcAccessDeniedError{}
}
@@ -1431,7 +1433,6 @@ func (s *OidcService) GetAllowedGroupsCountOfClient(ctx context.Context, id stri
}
func (s *OidcService) ListAuthorizedClients(ctx context.Context, userID string, listRequestOptions utils.ListRequestOptions) ([]model.UserAuthorizedOidcClient, utils.PaginationResponse, error) {
query := s.db.
WithContext(ctx).
Model(&model.UserAuthorizedOidcClient{}).
@@ -1644,19 +1645,34 @@ func clientAuthCredentialsFromCreateTokensDto(d *dto.OidcCreateTokensDto) Client
}
func (s *OidcService) verifyClientCredentialsInternal(ctx context.Context, tx *gorm.DB, input ClientAuthCredentials, allowPublicClientsWithoutAuth bool) (client *model.OidcClient, err error) {
if input.ClientID == "" {
isClientAssertion := input.ClientAssertionType == ClientAssertionTypeJWTBearer && input.ClientAssertion != ""
// Determine the client ID based on the authentication method
var clientID string
switch {
case isClientAssertion:
// Extract client ID from the JWT assertion's 'sub' claim
clientID, err = s.extractClientIDFromAssertion(input.ClientAssertion)
if err != nil {
slog.Error("Failed to extract client ID from assertion", "error", err)
return nil, &common.OidcClientAssertionInvalidError{}
}
case input.ClientID != "":
// Use the provided client ID for other authentication methods
clientID = input.ClientID
default:
return nil, &common.OidcMissingClientCredentialsError{}
}
// Load the OIDC client's configuration
err = tx.
WithContext(ctx).
First(&client, "id = ?", input.ClientID).
First(&client, "id = ?", clientID).
Error
if errors.Is(err, gorm.ErrRecordNotFound) {
slog.WarnContext(ctx, "Client not found", slog.String("client", input.ClientID))
return nil, &common.OidcClientNotFoundError{}
} else if err != nil {
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) && isClientAssertion {
return nil, &common.OidcClientAssertionInvalidError{}
}
return nil, err
}
@@ -1671,7 +1687,7 @@ func (s *OidcService) verifyClientCredentialsInternal(ctx context.Context, tx *g
return client, nil
// Next, check if we want to use client assertions from federated identities
case input.ClientAssertionType == ClientAssertionTypeJWTBearer && input.ClientAssertion != "":
case isClientAssertion:
err = s.verifyClientAssertionFromFederatedIdentities(ctx, client, input)
if err != nil {
slog.WarnContext(ctx, "Invalid assertion for client", slog.String("client", client.ID), slog.Any("error", err))
@@ -1768,20 +1784,36 @@ func (s *OidcService) verifyClientAssertionFromFederatedIdentities(ctx context.C
// (Note: we don't use jwt.WithIssuer() because that would be redundant)
_, err = jwt.Parse(assertion,
jwt.WithValidate(true),
jwt.WithAcceptableSkew(clockSkew),
jwt.WithKeySet(jwks, jws.WithInferAlgorithmFromKey(true), jws.WithUseDefault(true)),
jwt.WithAudience(audience),
jwt.WithSubject(subject),
)
if err != nil {
return fmt.Errorf("client assertion could not be verified: %w", err)
return fmt.Errorf("client assertion is not valid: %w", err)
}
// If we're here, the assertion is valid
return nil
}
// extractClientIDFromAssertion extracts the client_id from the JWT assertion's 'sub' claim
func (s *OidcService) extractClientIDFromAssertion(assertion string) (string, error) {
// Parse the JWT without verification first to get the claims
insecureToken, err := jwt.ParseInsecure([]byte(assertion))
if err != nil {
return "", fmt.Errorf("failed to parse JWT assertion: %w", err)
}
// Extract the subject claim which must be the client_id according to RFC 7523
sub, ok := insecureToken.Subject()
if !ok || sub == "" {
return "", fmt.Errorf("missing or invalid 'sub' claim in JWT assertion")
}
return sub, nil
}
func (s *OidcService) GetClientPreview(ctx context.Context, clientID string, userID string, scopes []string) (*dto.OidcClientPreviewDto, error) {
tx := s.db.Begin()
defer func() {
@@ -1803,7 +1835,7 @@ func (s *OidcService) GetClientPreview(ctx context.Context, clientID string, use
return nil, err
}
if !IsUserGroupAllowedToAuthorize(user, client) {
if !s.IsUserGroupAllowedToAuthorize(user, client) {
return nil, &common.OidcAccessDeniedError{}
}
@@ -1869,7 +1901,7 @@ func (s *OidcService) getUserClaims(ctx context.Context, user *model.User, scope
claims["sub"] = user.ID
if slices.Contains(scopes, "email") {
claims["email"] = user.Email
claims["email_verified"] = user.EmailVerified
claims["email_verified"] = s.appConfigService.GetDbConfig().EmailsVerified.IsTrue()
}
if slices.Contains(scopes, "groups") {
@@ -1930,7 +1962,7 @@ func (s *OidcService) IsClientAccessibleToUser(ctx context.Context, clientID str
return false, err
}
return IsUserGroupAllowedToAuthorize(user, client), nil
return s.IsUserGroupAllowedToAuthorize(user, client), nil
}
var errLogoTooLarge = errors.New("logo is too large")
@@ -2091,15 +2123,105 @@ func (s *OidcService) updateClientLogoType(ctx context.Context, clientID string,
return nil
}
func (s *OidcService) GetClientScimServiceProvider(ctx context.Context, clientID string) (model.ScimServiceProvider, error) {
var provider model.ScimServiceProvider
err := s.db.
WithContext(ctx).
First(&provider, "oidc_client_id = ?", clientID).
Error
if err != nil {
return model.ScimServiceProvider{}, err
func (s *OidcService) CreateAPI(ctx context.Context, input dto.OidcAPICreateDto) (model.OidcAPI, error) {
api := model.OidcAPI{
Name: input.Name,
}
return provider, nil
err := s.db.
WithContext(ctx).
Create(&api).
Error
if err != nil {
return model.OidcAPI{}, fmt.Errorf("failed to create API in database: %w", err)
}
return api, nil
}
func (s *OidcService) GetAPI(ctx context.Context, id string) (model.OidcAPI, error) {
var api model.OidcAPI
err := s.db.
WithContext(ctx).
First(&api, "id = ?", id).
Error
if err != nil {
return model.OidcAPI{}, fmt.Errorf("failed to get API from database: %w", err)
}
return api, nil
}
func (s *OidcService) ListAPIs(ctx context.Context, searchTerm string, listRequestOptions utils.ListRequestOptions) ([]model.OidcAPI, utils.PaginationResponse, error) {
var apis []model.OidcAPI
query := s.db.
WithContext(ctx).
Model(&model.OidcAPI{})
if searchTerm != "" {
query = query.Where("id = ? OR name = ? OR identifier = ?", searchTerm, searchTerm, searchTerm)
}
response, err := utils.PaginateFilterAndSort(listRequestOptions, query, &apis)
return apis, response, err
}
func (s *OidcService) UpdateAPI(ctx context.Context, id string, input dto.OidcAPIUpdateDto) (model.OidcAPI, error) {
tx := s.db.Begin()
defer func() {
tx.Rollback()
}()
var api model.OidcAPI
err := tx.
WithContext(ctx).
First(&api, "id = ?", id).
Error
if err != nil {
return model.OidcAPI{}, fmt.Errorf("failed to get API from database: %w", err)
}
api.Name = input.Name
api.Identifier = input.Identifier
// Convert permissions from DTO to model
api.Data.Permissions = make([]model.OidcAPIPermission, len(input.Permissions))
for i, p := range input.Permissions {
api.Data.Permissions[i] = model.OidcAPIPermission{
Name: p.Name,
Description: p.Description,
}
}
err = tx.
WithContext(ctx).
Save(&api).
Error
if err != nil {
return model.OidcAPI{}, fmt.Errorf("failed to save API in database: %w", err)
}
err = tx.Commit().Error
if err != nil {
return model.OidcAPI{}, fmt.Errorf("failed to commit transaction: %w", err)
}
return api, nil
}
func (s *OidcService) DeleteAPI(ctx context.Context, id string) error {
result := s.db.
WithContext(ctx).
Delete(&model.OidcAPI{}, "id = ?", id)
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
return gorm.ErrRecordNotFound
}
return nil
}

View File

@@ -160,7 +160,7 @@ func TestOidcService_verifyClientCredentialsInternal(t *testing.T) {
mockConfig := NewTestAppConfigService(&model.AppConfig{
SessionDuration: model.AppConfigVariable{Value: "60"}, // 60 minutes
})
mockJwtService, err := NewJwtService(t.Context(), db, mockConfig)
mockJwtService, err := NewJwtService(db, mockConfig)
require.NoError(t, err)
// Create a mock HTTP client with custom transport to return the JWKS
@@ -229,12 +229,6 @@ func TestOidcService_verifyClientCredentialsInternal(t *testing.T) {
Subject: federatedClient.ID,
JWKS: federatedClientIssuer + "/jwks.json",
},
{
Issuer: "federated-issuer-2",
Audience: federatedClientAudience,
Subject: "my-federated-client",
JWKS: federatedClientIssuer + "/jwks.json",
},
{Issuer: federatedClientIssuerDefaults},
},
},
@@ -467,43 +461,6 @@ func TestOidcService_verifyClientCredentialsInternal(t *testing.T) {
// Generate a token
input := dto.OidcCreateTokensDto{
ClientID: federatedClient.ID,
ClientAssertion: string(signedToken),
ClientAssertionType: ClientAssertionTypeJWTBearer,
}
createdToken, err := s.createTokenFromClientCredentials(t.Context(), input)
require.NoError(t, err)
require.NotNil(t, token)
// Verify the token
claims, err := s.jwtService.VerifyOAuthAccessToken(createdToken.AccessToken)
require.NoError(t, err, "Failed to verify generated token")
// Check the claims
subject, ok := claims.Subject()
_ = assert.True(t, ok, "User ID not found in token") &&
assert.Equal(t, "client-"+federatedClient.ID, subject, "Token subject should match federated client ID with prefix")
audience, ok := claims.Audience()
_ = assert.True(t, ok, "Audience not found in token") &&
assert.Equal(t, []string{federatedClient.ID}, audience, "Audience should contain the federated client ID")
})
t.Run("Succeeds with valid assertion and custom subject", func(t *testing.T) {
// Create JWT for federated identity
token, err := jwt.NewBuilder().
Issuer("federated-issuer-2").
Audience([]string{federatedClientAudience}).
Subject("my-federated-client").
IssuedAt(time.Now()).
Expiration(time.Now().Add(10 * time.Minute)).
Build()
require.NoError(t, err)
signedToken, err := jwt.Sign(token, jwt.WithKey(jwa.ES256(), privateJWK))
require.NoError(t, err)
// Generate a token
input := dto.OidcCreateTokensDto{
ClientID: federatedClient.ID,
ClientAssertion: string(signedToken),
ClientAssertionType: ClientAssertionTypeJWTBearer,
}
@@ -526,7 +483,6 @@ func TestOidcService_verifyClientCredentialsInternal(t *testing.T) {
t.Run("Fails with invalid assertion", func(t *testing.T) {
input := dto.OidcCreateTokensDto{
ClientID: confidentialClient.ID,
ClientAssertion: "invalid.jwt.token",
ClientAssertionType: ClientAssertionTypeJWTBearer,
}

View File

@@ -1,253 +0,0 @@
package service
import (
"context"
"errors"
"log/slog"
"net/url"
"strings"
"time"
"github.com/pocket-id/pocket-id/backend/internal/common"
"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/utils"
"github.com/pocket-id/pocket-id/backend/internal/utils/email"
"go.opentelemetry.io/otel/trace"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
type OneTimeAccessService struct {
db *gorm.DB
userService *UserService
appConfigService *AppConfigService
jwtService *JwtService
auditLogService *AuditLogService
emailService *EmailService
}
func NewOneTimeAccessService(db *gorm.DB, userService *UserService, jwtService *JwtService, auditLogService *AuditLogService, emailService *EmailService, appConfigService *AppConfigService) *OneTimeAccessService {
return &OneTimeAccessService{
db: db,
userService: userService,
appConfigService: appConfigService,
jwtService: jwtService,
auditLogService: auditLogService,
emailService: emailService,
}
}
func (s *OneTimeAccessService) RequestOneTimeAccessEmailAsAdmin(ctx context.Context, userID string, ttl time.Duration) error {
isDisabled := !s.appConfigService.GetDbConfig().EmailOneTimeAccessAsAdminEnabled.IsTrue()
if isDisabled {
return &common.OneTimeAccessDisabledError{}
}
_, err := s.requestOneTimeAccessEmailInternal(ctx, userID, "", ttl, false)
return err
}
func (s *OneTimeAccessService) RequestOneTimeAccessEmailAsUnauthenticatedUser(ctx context.Context, userID, redirectPath string) (string, error) {
isDisabled := !s.appConfigService.GetDbConfig().EmailOneTimeAccessAsUnauthenticatedEnabled.IsTrue()
if isDisabled {
return "", &common.OneTimeAccessDisabledError{}
}
var userId string
err := s.db.Model(&model.User{}).Select("id").Where("email = ?", userID).First(&userId).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
// Do not return error if user not found to prevent email enumeration
return "", nil
} else if err != nil {
return "", err
}
deviceToken, err := s.requestOneTimeAccessEmailInternal(ctx, userId, redirectPath, 15*time.Minute, true)
if err != nil {
return "", err
} else if deviceToken == nil {
return "", errors.New("device token expected but not returned")
}
return *deviceToken, nil
}
func (s *OneTimeAccessService) requestOneTimeAccessEmailInternal(ctx context.Context, userID, redirectPath string, ttl time.Duration, withDeviceToken bool) (*string, error) {
tx := s.db.Begin()
defer func() {
tx.Rollback()
}()
user, err := s.userService.getUserInternal(ctx, userID, tx)
if err != nil {
return nil, err
}
if user.Email == nil {
return nil, &common.UserEmailNotSetError{}
}
oneTimeAccessToken, deviceToken, err := s.createOneTimeAccessTokenInternal(ctx, user.ID, ttl, withDeviceToken, tx)
if err != nil {
return nil, err
}
err = tx.Commit().Error
if err != nil {
return nil, err
}
// We use a background context here as this is running in a goroutine
//nolint:contextcheck
go func() {
span := trace.SpanFromContext(ctx)
innerCtx := trace.ContextWithSpan(context.Background(), span)
link := common.EnvConfig.AppURL + "/lc"
linkWithCode := link + "/" + oneTimeAccessToken
// Add redirect path to the link
if strings.HasPrefix(redirectPath, "/") {
encodedRedirectPath := url.QueryEscape(redirectPath)
linkWithCode = linkWithCode + "?redirect=" + encodedRedirectPath
}
errInternal := SendEmail(innerCtx, s.emailService, email.Address{
Name: user.FullName(),
Email: *user.Email,
}, OneTimeAccessTemplate, &OneTimeAccessTemplateData{
Code: oneTimeAccessToken,
LoginLink: link,
LoginLinkWithCode: linkWithCode,
ExpirationString: utils.DurationToString(ttl),
})
if errInternal != nil {
slog.ErrorContext(innerCtx, "Failed to send one-time access token email", slog.Any("error", errInternal), slog.String("address", *user.Email))
return
}
}()
return deviceToken, nil
}
func (s *OneTimeAccessService) CreateOneTimeAccessToken(ctx context.Context, userID string, ttl time.Duration) (token string, err error) {
tx := s.db.Begin()
defer func() {
tx.Rollback()
}()
// Load the user to ensure it exists
_, err = s.userService.getUserInternal(ctx, userID, tx)
if errors.Is(err, gorm.ErrRecordNotFound) {
return "", &common.UserNotFoundError{}
} else if err != nil {
return "", err
}
// Create the one-time access token
token, _, err = s.createOneTimeAccessTokenInternal(ctx, userID, ttl, false, tx)
if err != nil {
return "", err
}
// Commit
err = tx.Commit().Error
if err != nil {
return "", err
}
return token, nil
}
func (s *OneTimeAccessService) createOneTimeAccessTokenInternal(ctx context.Context, userID string, ttl time.Duration, withDeviceToken bool, tx *gorm.DB) (token string, deviceToken *string, err error) {
oneTimeAccessToken, err := NewOneTimeAccessToken(userID, ttl, withDeviceToken)
if err != nil {
return "", nil, err
}
err = tx.WithContext(ctx).Create(oneTimeAccessToken).Error
if err != nil {
return "", nil, err
}
return oneTimeAccessToken.Token, oneTimeAccessToken.DeviceToken, nil
}
func (s *OneTimeAccessService) ExchangeOneTimeAccessToken(ctx context.Context, token, deviceToken, ipAddress, userAgent string) (model.User, string, error) {
tx := s.db.Begin()
defer func() {
tx.Rollback()
}()
var oneTimeAccessToken model.OneTimeAccessToken
err := tx.
WithContext(ctx).
Where("token = ? AND expires_at > ?", token, datatype.DateTime(time.Now())).
Preload("User").
Clauses(clause.Locking{Strength: "UPDATE"}).
First(&oneTimeAccessToken).
Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return model.User{}, "", &common.TokenInvalidOrExpiredError{}
}
return model.User{}, "", err
}
if oneTimeAccessToken.DeviceToken != nil && deviceToken != *oneTimeAccessToken.DeviceToken {
return model.User{}, "", &common.DeviceCodeInvalid{}
}
accessToken, err := s.jwtService.GenerateAccessToken(oneTimeAccessToken.User)
if err != nil {
return model.User{}, "", err
}
err = tx.
WithContext(ctx).
Delete(&oneTimeAccessToken).
Error
if err != nil {
return model.User{}, "", err
}
s.auditLogService.Create(ctx, model.AuditLogEventOneTimeAccessTokenSignIn, ipAddress, userAgent, oneTimeAccessToken.User.ID, model.AuditLogData{}, tx)
err = tx.Commit().Error
if err != nil {
return model.User{}, "", err
}
return oneTimeAccessToken.User, accessToken, nil
}
func NewOneTimeAccessToken(userID string, ttl time.Duration, withDeviceToken bool) (*model.OneTimeAccessToken, error) {
// If expires at is less than 15 minutes, use a 6-character token instead of 16
tokenLength := 16
if ttl <= 15*time.Minute {
tokenLength = 6
}
token, err := utils.GenerateRandomUnambiguousString(tokenLength)
if err != nil {
return nil, err
}
var deviceToken *string
if withDeviceToken {
dt, err := utils.GenerateRandomAlphanumericString(16)
if err != nil {
return nil, err
}
deviceToken = &dt
}
now := time.Now().Round(time.Second)
o := &model.OneTimeAccessToken{
UserID: userID,
ExpiresAt: datatype.DateTime(now.Add(ttl)),
Token: token,
DeviceToken: deviceToken,
}
return o, nil
}

View File

@@ -1,25 +0,0 @@
package service
import (
"context"
backoff "github.com/cenkalti/backoff/v5"
"github.com/go-co-op/gocron/v2"
)
// RegisterJobOpts holds optional configuration for registering a scheduled job.
type RegisterJobOpts struct {
// RunImmediately runs the job immediately after registration.
RunImmediately bool
// ExtraOptions are additional gocron job options.
ExtraOptions []gocron.JobOption
// BackOff is an optional backoff strategy. If non-nil, the job will be wrapped
// with automatic retry logic using the provided backoff on transient failures.
BackOff backoff.BackOff
}
// Scheduler is an interface for registering and managing background jobs.
type Scheduler interface {
RegisterJob(ctx context.Context, name string, def gocron.JobDefinition, job func(ctx context.Context) error, opts RegisterJobOpts) error
RemoveJob(name string) error
}

View File

@@ -1,807 +0,0 @@
package service
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"net/http"
"net/url"
"path"
"slices"
"strconv"
"strings"
"time"
"github.com/go-co-op/gocron/v2"
"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/utils"
"gorm.io/gorm"
)
const (
scimUserSchema = "urn:ietf:params:scim:schemas:core:2.0:User"
scimGroupSchema = "urn:ietf:params:scim:schemas:core:2.0:Group"
scimContentType = "application/scim+json"
)
const scimErrorBodyLimit = 4096
type scimSyncAction int
const (
scimActionNone scimSyncAction = iota
scimActionCreated
scimActionUpdated
scimActionDeleted
)
type scimSyncStats struct {
Created int
Updated int
Deleted int
}
// ScimService handles SCIM provisioning to external service providers.
type ScimService struct {
db *gorm.DB
scheduler Scheduler
httpClient *http.Client
}
func NewScimService(db *gorm.DB, scheduler Scheduler, httpClient *http.Client) *ScimService {
if httpClient == nil {
httpClient = &http.Client{Timeout: 20 * time.Second}
}
return &ScimService{db: db, scheduler: scheduler, httpClient: httpClient}
}
func (s *ScimService) GetServiceProvider(
ctx context.Context,
serviceProviderID string,
) (model.ScimServiceProvider, error) {
var provider model.ScimServiceProvider
err := s.db.WithContext(ctx).
Preload("OidcClient").
Preload("OidcClient.AllowedUserGroups").
First(&provider, "id = ?", serviceProviderID).
Error
if err != nil {
return model.ScimServiceProvider{}, err
}
return provider, nil
}
func (s *ScimService) ListServiceProviders(ctx context.Context) ([]model.ScimServiceProvider, error) {
var providers []model.ScimServiceProvider
err := s.db.WithContext(ctx).
Preload("OidcClient").
Find(&providers).
Error
if err != nil {
return nil, err
}
return providers, nil
}
func (s *ScimService) CreateServiceProvider(
ctx context.Context,
input *dto.ScimServiceProviderCreateDTO) (model.ScimServiceProvider, error) {
provider := model.ScimServiceProvider{
Endpoint: input.Endpoint,
Token: datatype.EncryptedString(input.Token),
OidcClientID: input.OidcClientID,
}
if err := s.db.WithContext(ctx).Create(&provider).Error; err != nil {
return model.ScimServiceProvider{}, err
}
return provider, nil
}
func (s *ScimService) UpdateServiceProvider(ctx context.Context,
serviceProviderID string,
input *dto.ScimServiceProviderCreateDTO,
) (model.ScimServiceProvider, error) {
var provider model.ScimServiceProvider
err := s.db.WithContext(ctx).
First(&provider, "id = ?", serviceProviderID).
Error
if err != nil {
return model.ScimServiceProvider{}, err
}
provider.Endpoint = input.Endpoint
provider.Token = datatype.EncryptedString(input.Token)
provider.OidcClientID = input.OidcClientID
if err := s.db.WithContext(ctx).Save(&provider).Error; err != nil {
return model.ScimServiceProvider{}, err
}
return provider, nil
}
func (s *ScimService) DeleteServiceProvider(ctx context.Context, serviceProviderID string) error {
return s.db.WithContext(ctx).
Delete(&model.ScimServiceProvider{}, "id = ?", serviceProviderID).
Error
}
//nolint:contextcheck
func (s *ScimService) ScheduleSync() {
jobName := "ScheduledScimSync"
start := time.Now().Add(5 * time.Minute)
_ = s.scheduler.RemoveJob(jobName)
err := s.scheduler.RegisterJob(
context.Background(), jobName,
gocron.OneTimeJob(gocron.OneTimeJobStartDateTime(start)), s.SyncAll, RegisterJobOpts{})
if err != nil {
slog.Error("Failed to schedule SCIM sync", slog.Any("error", err))
}
}
func (s *ScimService) SyncAll(ctx context.Context) error {
providers, err := s.ListServiceProviders(ctx)
if err != nil {
return err
}
var errs []error
for _, provider := range providers {
if ctx.Err() != nil {
errs = append(errs, ctx.Err())
break
}
err = s.SyncServiceProvider(ctx, provider.ID)
if err != nil {
errs = append(errs, fmt.Errorf("failed to sync SCIM provider %s: %w", provider.ID, err))
}
}
return errors.Join(errs...)
}
func (s *ScimService) SyncServiceProvider(ctx context.Context, serviceProviderID string) error {
start := time.Now()
provider, err := s.GetServiceProvider(ctx, serviceProviderID)
if err != nil {
return err
}
slog.InfoContext(ctx, "Syncing SCIM service provider",
slog.String("provider_id", provider.ID),
slog.String("oidc_client_id", provider.OidcClientID),
)
allowedGroupIDs := groupIDs(provider.OidcClient.AllowedUserGroups)
// Load users and groups that should be synced to the SCIM provider
groups, err := s.groupsForClient(ctx, provider.OidcClient, allowedGroupIDs)
if err != nil {
return err
}
users, err := s.usersForClient(ctx, provider.OidcClient, allowedGroupIDs)
if err != nil {
return err
}
// Load users and groups that already exist in the SCIM provider
userResources, err := listScimResources[dto.ScimUser](s, ctx, provider, "/Users")
if err != nil {
return err
}
groupResources, err := listScimResources[dto.ScimGroup](s, ctx, provider, "/Groups")
if err != nil {
return err
}
var errs []error
// Sync users first, so that groups can reference them
userStats, err := s.syncUsers(ctx, provider, users, &userResources)
if err != nil {
errs = append(errs, err)
}
groupStats, err := s.syncGroups(ctx, provider, groups, groupResources.Resources, userResources.Resources)
if err != nil {
errs = append(errs, err)
}
if len(errs) > 0 {
err = errors.Join(errs...)
slog.WarnContext(ctx, "SCIM sync completed with errors",
slog.String("provider_id", provider.ID),
slog.Int("error_count", len(errs)),
slog.Int("users_created", userStats.Created),
slog.Int("users_updated", userStats.Updated),
slog.Int("users_deleted", userStats.Deleted),
slog.Int("groups_created", groupStats.Created),
slog.Int("groups_updated", groupStats.Updated),
slog.Int("groups_deleted", groupStats.Deleted),
slog.Duration("duration", time.Since(start)),
slog.Any("error", err),
)
return err
}
provider.LastSyncedAt = new(datatype.DateTime(time.Now()))
err = s.db.WithContext(ctx).Save(&provider).Error
if err != nil {
return err
}
slog.InfoContext(ctx, "SCIM sync completed",
slog.String("provider_id", provider.ID),
slog.Int("users_created", userStats.Created),
slog.Int("users_updated", userStats.Updated),
slog.Int("users_deleted", userStats.Deleted),
slog.Int("groups_created", groupStats.Created),
slog.Int("groups_updated", groupStats.Updated),
slog.Int("groups_deleted", groupStats.Deleted),
slog.Duration("duration", time.Since(start)),
)
return nil
}
func (s *ScimService) syncUsers(
ctx context.Context,
provider model.ScimServiceProvider,
users []model.User,
resourceList *dto.ScimListResponse[dto.ScimUser],
) (stats scimSyncStats, err error) {
var errs []error
// Update or create users
for _, u := range users {
existing := getResourceByExternalID(u.ID, resourceList.Resources)
action, created, err := s.syncUser(ctx, provider, u, existing)
if created != nil && existing == nil {
resourceList.Resources = append(resourceList.Resources, *created)
}
if err != nil {
errs = append(errs, err)
continue
}
// Update stats based on action taken by syncUser
switch action {
case scimActionCreated:
stats.Created++
case scimActionUpdated:
stats.Updated++
case scimActionDeleted:
stats.Deleted++
case scimActionNone:
}
}
// Delete users that are present in SCIM provider but not locally.
userSet := make(map[string]struct{})
for _, u := range users {
userSet[u.ID] = struct{}{}
}
for _, r := range resourceList.Resources {
if _, ok := userSet[r.ExternalID]; !ok {
if err := s.deleteScimResource(ctx, provider, "/Users/"+url.PathEscape(r.ID)); err != nil {
errs = append(errs, err)
} else {
stats.Deleted++
}
}
}
return stats, errors.Join(errs...)
}
func (s *ScimService) syncGroups(
ctx context.Context,
provider model.ScimServiceProvider,
groups []model.UserGroup,
remoteGroups []dto.ScimGroup,
userResources []dto.ScimUser,
) (stats scimSyncStats, err error) {
var errs []error
// Update or create groups
for _, g := range groups {
existing := getResourceByExternalID[dto.ScimGroup](g.ID, remoteGroups)
action, err := s.syncGroup(ctx, provider, g, existing, userResources)
if err != nil {
errs = append(errs, err)
continue
}
// Update stats based on action taken by syncGroup
switch action {
case scimActionCreated:
stats.Created++
case scimActionUpdated:
stats.Updated++
case scimActionDeleted:
stats.Deleted++
case scimActionNone:
}
}
// Delete groups that are present in SCIM provider but not locally
groupSet := make(map[string]struct{})
for _, g := range groups {
groupSet[g.ID] = struct{}{}
}
for _, r := range remoteGroups {
if _, ok := groupSet[r.ExternalID]; !ok {
if err := s.deleteScimResource(ctx, provider, "/Groups/"+url.PathEscape(r.GetID())); err != nil {
errs = append(errs, err)
} else {
stats.Deleted++
}
}
}
return stats, errors.Join(errs...)
}
func (s *ScimService) syncUser(ctx context.Context,
provider model.ScimServiceProvider,
user model.User,
userResource *dto.ScimUser,
) (scimSyncAction, *dto.ScimUser, error) {
// If user is not allowed for the client, delete it from SCIM provider
if userResource != nil && !IsUserGroupAllowedToAuthorize(user, provider.OidcClient) {
return scimActionDeleted, nil, s.deleteScimResource(ctx, provider, fmt.Sprintf("/Users/%s", url.PathEscape(userResource.ID)))
}
payload := dto.ScimUser{
ScimResourceData: dto.ScimResourceData{
Schemas: []string{scimUserSchema},
ExternalID: user.ID,
},
UserName: user.Username,
Name: &dto.ScimName{
GivenName: user.FirstName,
FamilyName: user.LastName,
},
Display: user.DisplayName,
Active: !user.Disabled,
}
if user.Email != nil {
payload.Emails = []dto.ScimEmail{{
Value: *user.Email,
Primary: true,
}}
}
// If the user exists on the SCIM provider, and it has been modified, update it
if userResource != nil {
if user.LastModified().Before(userResource.GetMeta().LastModified) {
return scimActionNone, nil, nil
}
path := fmt.Sprintf("/Users/%s", url.PathEscape(userResource.GetID()))
userResource, err := updateScimResource(s, ctx, provider, path, payload)
if err != nil {
return scimActionNone, nil, err
}
return scimActionUpdated, userResource, nil
}
// Otherwise, create a new SCIM user
userResource, err := createScimResource(s, ctx, provider, "/Users", payload)
if err != nil {
return scimActionNone, nil, err
}
return scimActionCreated, userResource, nil
}
func (s *ScimService) syncGroup(
ctx context.Context,
provider model.ScimServiceProvider,
group model.UserGroup,
groupResource *dto.ScimGroup,
userResources []dto.ScimUser,
) (scimSyncAction, error) {
// If group is not allowed for the client, delete it from SCIM provider
if groupResource != nil && !groupAllowedForClient(group.ID, provider.OidcClient) {
return scimActionDeleted, s.deleteScimResource(ctx, provider, fmt.Sprintf("/Groups/%s", url.PathEscape(groupResource.GetID())))
}
// Prepare group members
members := make([]dto.ScimGroupMember, len(group.Users))
for i, user := range group.Users {
userResource := getResourceByExternalID(user.ID, userResources)
if userResource == nil {
// Groups depend on user IDs already being provisioned
return scimActionNone, fmt.Errorf("cannot sync group %s: user %s is not provisioned in SCIM provider", group.ID, user.ID)
}
members[i] = dto.ScimGroupMember{
Value: userResource.GetID(),
}
}
groupPayload := dto.ScimGroup{
ScimResourceData: dto.ScimResourceData{
Schemas: []string{scimGroupSchema},
ExternalID: group.ID,
},
Display: group.FriendlyName,
Members: members,
}
// If the group exists on the SCIM provider, and it has been modified, update it
if groupResource != nil {
if group.LastModified().Before(groupResource.GetMeta().LastModified) {
return scimActionNone, nil
}
path := fmt.Sprintf("/Groups/%s", url.PathEscape(groupResource.GetID()))
_, err := updateScimResource(s, ctx, provider, path, groupPayload)
if err != nil {
return scimActionNone, err
}
return scimActionUpdated, nil
}
// Otherwise, create a new SCIM group
_, err := createScimResource(s, ctx, provider, "/Groups", groupPayload)
if err != nil {
return scimActionNone, err
}
return scimActionCreated, nil
}
func groupAllowedForClient(groupID string, client model.OidcClient) bool {
if !client.IsGroupRestricted {
return true
}
for _, allowedGroup := range client.AllowedUserGroups {
if allowedGroup.ID == groupID {
return true
}
}
return false
}
func groupIDs(groups []model.UserGroup) []string {
ids := make([]string, len(groups))
for i, g := range groups {
ids[i] = g.ID
}
return ids
}
func (s *ScimService) groupsForClient(
ctx context.Context,
client model.OidcClient,
allowedGroupIDs []string,
) ([]model.UserGroup, error) {
var groups []model.UserGroup
query := s.db.WithContext(ctx).Preload("Users").Model(&model.UserGroup{})
if client.IsGroupRestricted {
if len(allowedGroupIDs) == 0 {
return groups, nil
}
query = query.Where("id IN ?", allowedGroupIDs)
}
if err := query.Find(&groups).Error; err != nil {
return nil, err
}
return groups, nil
}
func (s *ScimService) usersForClient(
ctx context.Context,
client model.OidcClient,
allowedGroupIDs []string,
) ([]model.User, error) {
var users []model.User
query := s.db.WithContext(ctx).Model(&model.User{})
if client.IsGroupRestricted {
if len(allowedGroupIDs) == 0 {
return users, nil
}
query = query.
Joins("JOIN user_groups_users ON users.id = user_groups_users.user_id").
Where("user_groups_users.user_group_id IN ?", allowedGroupIDs).
Select("users.*").
Distinct()
}
query = query.Preload("UserGroups")
if err := query.Find(&users).Error; err != nil {
return nil, err
}
return users, nil
}
func getResourceByExternalID[T dto.ScimResource](externalID string, resource []T) *T {
for i := range resource {
if resource[i].GetExternalID() == externalID {
return &resource[i]
}
}
return nil
}
func listScimResources[T any](
s *ScimService,
ctx context.Context,
provider model.ScimServiceProvider,
path string,
) (result dto.ScimListResponse[T], err error) {
startIndex := 1
count := 1000
for {
// Use SCIM pagination to avoid missing resources on large providers
queryParams := map[string]string{
"startIndex": strconv.Itoa(startIndex),
"count": strconv.Itoa(count),
}
resp, err := s.scimRequest(ctx, provider, http.MethodGet, path, nil, queryParams)
if err != nil {
return dto.ScimListResponse[T]{}, err
}
if err := ensureScimStatus(ctx, resp, provider, http.StatusOK); err != nil {
return dto.ScimListResponse[T]{}, err
}
var page dto.ScimListResponse[T]
if err := json.NewDecoder(resp.Body).Decode(&page); err != nil {
return dto.ScimListResponse[T]{}, fmt.Errorf("failed to decode SCIM list response: %w", err)
}
resp.Body.Close()
// Initialize metadata only once
if result.TotalResults == 0 {
result.TotalResults = page.TotalResults
}
result.Resources = append(result.Resources, page.Resources...)
// If we've fetched everything, stop
if len(result.Resources) >= page.TotalResults || len(page.Resources) == 0 {
break
}
startIndex += page.ItemsPerPage
}
result.ItemsPerPage = len(result.Resources)
return result, nil
}
func createScimResource[T dto.ScimResource](
s *ScimService,
ctx context.Context,
provider model.ScimServiceProvider,
path string, payload T) (*T, error) {
resp, err := s.scimRequest(ctx, provider, http.MethodPost, path, payload, nil)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if err := ensureScimStatus(ctx, resp, provider, http.StatusOK, http.StatusCreated); err != nil {
return nil, err
}
var resource T
if err := json.NewDecoder(resp.Body).Decode(&resource); err != nil {
return nil, fmt.Errorf("failed to decode SCIM create response: %w", err)
}
return &resource, nil
}
func updateScimResource[T dto.ScimResource](
s *ScimService,
ctx context.Context,
provider model.ScimServiceProvider,
path string,
payload T,
) (*T, error) {
resp, err := s.scimRequest(ctx, provider, http.MethodPut, path, payload, nil)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if err := ensureScimStatus(ctx, resp, provider, http.StatusOK, http.StatusCreated); err != nil {
return nil, err
}
var resource T
if err := json.NewDecoder(resp.Body).Decode(&resource); err != nil {
return nil, fmt.Errorf("failed to decode SCIM update response: %w", err)
}
return &resource, nil
}
func (s *ScimService) deleteScimResource(ctx context.Context, provider model.ScimServiceProvider, path string) error {
resp, err := s.scimRequest(ctx, provider, http.MethodDelete, path, nil, nil)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusNotFound {
return nil
}
return ensureScimStatus(ctx, resp, provider, http.StatusOK, http.StatusNoContent)
}
func (s *ScimService) scimRequest(
ctx context.Context,
provider model.ScimServiceProvider,
method,
path string,
payload any,
queryParams map[string]string,
) (*http.Response, error) {
urlString, err := scimURL(provider.Endpoint, path, queryParams)
if err != nil {
return nil, err
}
var bodyBytes []byte
if payload != nil {
encoded, err := json.Marshal(payload)
if err != nil {
return nil, fmt.Errorf("failed to encode SCIM payload: %w", err)
}
bodyBytes = encoded
}
retryAttempts := 3
for attempt := 1; attempt <= retryAttempts; attempt++ {
var body io.Reader
if bodyBytes != nil {
body = bytes.NewReader(bodyBytes)
}
req, err := http.NewRequestWithContext(ctx, method, urlString, body)
if err != nil {
return nil, err
}
req.Header.Set("Accept", scimContentType)
if payload != nil {
req.Header.Set("Content-Type", scimContentType)
}
token := string(provider.Token)
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
slog.Debug("Sending SCIM request",
slog.String("method", method),
slog.String("url", urlString),
slog.String("provider_id", provider.ID),
)
resp, err := s.httpClient.Do(req)
if err != nil {
return nil, err
}
// Only retry on 429 to avoid masking other errors
if resp.StatusCode != http.StatusTooManyRequests || attempt == retryAttempts {
return resp, nil
}
retryDelay := scimRetryDelay(resp.Header.Get("Retry-After"), attempt)
slog.WarnContext(ctx, "SCIM provider rate-limited, retrying",
slog.String("provider_id", provider.ID),
slog.String("method", method),
slog.String("url", urlString),
slog.Int("attempt", attempt),
slog.Duration("retry_after", retryDelay),
)
resp.Body.Close()
if err := utils.SleepWithContext(ctx, retryDelay); err != nil {
return nil, err
}
}
return nil, fmt.Errorf("scim request retry attempts exceeded")
}
func scimRetryDelay(retryAfter string, attempt int) time.Duration {
// Respect Retry-After when provided
if retryAfter != "" {
if seconds, err := strconv.Atoi(retryAfter); err == nil {
return time.Duration(seconds) * time.Second
}
if t, err := http.ParseTime(retryAfter); err == nil {
if delay := time.Until(t); delay > 0 {
return delay
}
}
}
// Exponential backoff otherwise
maxDelay := 10 * time.Second
delay := 500 * time.Millisecond * (time.Duration(1) << (attempt - 1)) //nolint:gosec // attempt is bounded 1-3
if delay > maxDelay {
return maxDelay
}
return delay
}
func scimURL(endpoint, p string, queryParams map[string]string) (string, error) {
u, err := url.Parse(endpoint)
if err != nil {
return "", fmt.Errorf("invalid scim endpoint: %w", err)
}
u.Path = path.Join(strings.TrimRight(u.Path, "/"), p)
q := u.Query()
for key, value := range queryParams {
q.Set(key, value)
}
u.RawQuery = q.Encode()
return u.String(), nil
}
func ensureScimStatus(
ctx context.Context,
resp *http.Response,
provider model.ScimServiceProvider,
allowedStatuses ...int) error {
if slices.Contains(allowedStatuses, resp.StatusCode) {
return nil
}
body := readScimErrorBody(resp.Body)
slog.ErrorContext(ctx, "SCIM request failed",
slog.String("provider_id", provider.ID),
slog.String("method", resp.Request.Method),
slog.String("url", resp.Request.URL.String()),
slog.Int("status", resp.StatusCode),
slog.String("response_body", body),
)
return fmt.Errorf("scim request failed with status %d: %s", resp.StatusCode, body)
}
func readScimErrorBody(body io.Reader) string {
payload, err := io.ReadAll(io.LimitReader(body, scimErrorBodyLimit))
if err != nil {
return ""
}
return strings.TrimSpace(string(payload))
}

View File

@@ -3,9 +3,7 @@ package service
import (
"context"
"errors"
"time"
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
"gorm.io/gorm"
"github.com/pocket-id/pocket-id/backend/internal/common"
@@ -16,12 +14,11 @@ import (
type UserGroupService struct {
db *gorm.DB
scimService *ScimService
appConfigService *AppConfigService
}
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) *UserGroupService {
return &UserGroupService{db: db, appConfigService: appConfigService}
}
func (s *UserGroupService) List(ctx context.Context, name string, listRequestOptions utils.ListRequestOptions) (groups []model.UserGroup, response utils.PaginationResponse, err error) {
@@ -56,7 +53,6 @@ func (s *UserGroupService) getInternal(ctx context.Context, id string, tx *gorm.
Where("id = ?", id).
Preload("CustomClaims").
Preload("Users").
Preload("AllowedOidcClients").
First(&group).
Error
return group, err
@@ -91,16 +87,7 @@ func (s *UserGroupService) Delete(ctx context.Context, id string) error {
return err
}
err = tx.Commit().Error
if err != nil {
return err
}
if s.scimService != nil {
s.scimService.ScheduleSync()
}
return nil
return tx.Commit().Error
}
func (s *UserGroupService) Create(ctx context.Context, input dto.UserGroupCreateDto) (group model.UserGroup, err error) {
@@ -128,11 +115,6 @@ func (s *UserGroupService) createInternal(ctx context.Context, input dto.UserGro
}
return model.UserGroup{}, err
}
if s.scimService != nil {
s.scimService.ScheduleSync()
}
return group, nil
}
@@ -168,7 +150,6 @@ func (s *UserGroupService) updateInternal(ctx context.Context, id string, input
group.Name = input.Name
group.FriendlyName = input.FriendlyName
group.UpdatedAt = new(datatype.DateTime(time.Now()))
err = tx.
WithContext(ctx).
@@ -180,11 +161,6 @@ func (s *UserGroupService) updateInternal(ctx context.Context, id string, input
} else if err != nil {
return model.UserGroup{}, err
}
if s.scimService != nil {
s.scimService.ScheduleSync()
}
return group, nil
}
@@ -237,8 +213,6 @@ func (s *UserGroupService) updateUsersInternal(ctx context.Context, id string, u
}
// Save the updated group
group.UpdatedAt = new(datatype.DateTime(time.Now()))
err = tx.
WithContext(ctx).
Save(&group).
@@ -247,10 +221,6 @@ func (s *UserGroupService) updateUsersInternal(ctx context.Context, id string, u
return model.UserGroup{}, err
}
if s.scimService != nil {
s.scimService.ScheduleSync()
}
return group, nil
}
@@ -278,58 +248,3 @@ func (s *UserGroupService) GetUserCountOfGroup(ctx context.Context, id string) (
Count()
return count, nil
}
func (s *UserGroupService) UpdateAllowedOidcClient(ctx context.Context, id string, input dto.UserGroupUpdateAllowedOidcClientsDto) (group model.UserGroup, err error) {
tx := s.db.Begin()
defer func() {
tx.Rollback()
}()
group, err = s.getInternal(ctx, id, tx)
if err != nil {
return model.UserGroup{}, err
}
// Fetch the clients based on the client IDs
var clients []model.OidcClient
if len(input.OidcClientIDs) > 0 {
err = tx.
WithContext(ctx).
Where("id IN (?)", input.OidcClientIDs).
Find(&clients).
Error
if err != nil {
return model.UserGroup{}, err
}
}
// Replace the current clients with the new set of clients
err = tx.
WithContext(ctx).
Model(&group).
Association("AllowedOidcClients").
Replace(clients)
if err != nil {
return model.UserGroup{}, err
}
// Save the updated group
err = tx.
WithContext(ctx).
Save(&group).
Error
if err != nil {
return model.UserGroup{}, err
}
err = tx.Commit().Error
if err != nil {
return model.UserGroup{}, err
}
if s.scimService != nil {
s.scimService.ScheduleSync()
}
return group, nil
}

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