Compare commits

...

68 Commits

Author SHA1 Message Date
Elias Schneider
20254eea7c release: 2.10.0 2026-07-10 17:08:11 +02:00
Elias Schneider
f62d476c56 chore: upgrade go version 2026-07-10 16:58:27 +02:00
Elias Schneider
119eecefc3 chore(translations): update translations via Crowdin (#1573) 2026-07-10 16:25:05 +02:00
Elias Schneider
59a6868ecd refactor: run formatter 2026-07-10 15:57:17 +02:00
Elias Schneider
2910c517bb fix: mobile layout improvements 2026-07-10 15:56:47 +02:00
Elias Schneider
da3677f33d docs: fix wrong OpenAPI documentation 2026-07-10 15:44:28 +02:00
Elias Schneider
968d07229e docs: add link to API docs 2026-07-10 15:38:29 +02:00
Elias Schneider
28a553f63b feat: add tab bar navigation for crowded pages 2026-07-10 15:33:02 +02:00
Alessandro (Ale) Segala
b2711ced99 fix: /authorize endpoint crashes when list of scopes is empty (#1575) 2026-07-08 17:48:32 -07:00
Sean McKenzie
6734585712 feat: add description field to oidc clients (#1547)
Co-authored-by: Elias Schneider <login@eliasschneider.com>
2026-07-08 13:41:51 +02:00
Elias Schneider
c85a4e63da fix: disable one time access token exchange for disabled users 2026-07-08 12:04:16 +02:00
Elias Schneider
ce7d3a7e1d tests(unit): fix wrong migration name 2026-07-08 11:51:43 +02:00
Elias Schneider
a5f2192d67 tests(e2e): fix migration version in database.json 2026-07-08 11:19:09 +02:00
Elias Schneider
190914fd72 refactor: remove duplicate fosite config properties 2026-07-08 11:01:50 +02:00
Elias Schneider
25dcad757a feat: add support for unencrypted OIDC request parameter 2026-07-08 10:42:05 +02:00
Elias Schneider
c72da58eaf refactor: rename migrations 2026-07-07 17:16:10 +02:00
Elias Schneider
f3b6ceb876 fix: add missing burst to rate limit 2026-07-07 14:01:26 +02:00
Elias Schneider
fa2d08cb6d refactor: remove redundant dtos 2026-07-07 11:51:37 +02:00
Elias Schneider
2f55b7cbc3 fix: device authorization resolve resource creating device_code 2026-07-07 11:51:02 +02:00
Elias Schneider
8035a4c8d5 perf: include permissions in api list response 2026-07-07 11:38:59 +02:00
Elias Schneider
7667377c98 refactor: pass transaction to resolveResource 2026-07-07 11:14:19 +02:00
Elias Schneider
5e2cc6f40e fix: merge requested scopes instead of replacing them 2026-07-07 11:03:06 +02:00
Elias Schneider
e8cb0c831c fix: re-check api permissions on access token refresh 2026-07-07 10:58:39 +02:00
Elias Schneider
337fc6fd1e chore: update Fosite version 2026-07-06 23:43:09 +02:00
Elias Schneider
590feadab9 chore(translations): update translations via Crowdin (#1569) 2026-07-06 23:35:03 +02:00
Elias Schneider
9a94aa0694 refactor: move Pocket ID specific logic from Fosite into Pocket ID repo 2026-07-06 23:34:33 +02:00
Elias Schneider
34e9a6d198 fix: restore behavior that unknown scopes get ignored 2026-07-06 22:42:38 +02:00
Elias Schneider
6120bb4dd8 chore(translations): update translations via Crowdin (#1568) 2026-07-06 22:24:03 +02:00
Elias Schneider
09d196f7c5 feat: add OAuth APIs with scoped permissions (#1542)
Co-authored-by: Alessandro (Ale) Segala <43508+ItalyPaleAle@users.noreply.github.com>
2026-07-06 12:25:02 -07:00
github-actions[bot]
0b2706a488 chore: update AAGUIDs (#1567)
Co-authored-by: stonith404 <58886915+stonith404@users.noreply.github.com>
2026-07-06 08:58:44 +02:00
Alessandro (Ale) Segala
eefd51cc4d fix: various bugs in observability / OTel (#1564) 2026-07-05 14:14:39 -07:00
Alessandro (Ale) Segala
f68ad42618 chore: use go-kit for sending emails (#1565) 2026-07-05 13:10:11 -07:00
dependabot[bot]
0d2624db9c chore(deps): Bump the "all-dependencies" group with 3 updates across multiple ecosystems (#1563)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Kyle Mendell <ksm@ofkm.us>
2026-07-05 22:06:07 +02:00
Elias Schneider
5c988a921a chore(translations): update translations via Crowdin (#1559)
Co-authored-by: Alessandro (Ale) Segala <43508+ItalyPaleAle@users.noreply.github.com>
2026-07-03 08:37:08 +02:00
Alessandro (Ale) Segala
9607495ab4 refactor: integrate Francis actor framework for background jobs, cron scheduling, and rate limiting (#1556)
Co-authored-by: Elias Schneider <login@eliasschneider.com>
Co-authored-by: Claude <noreply@anthropic.com>
2026-07-03 08:36:45 +02:00
Alessandro (Ale) Segala
d359438814 tests(e2e): make API key renewal date picker navigation date-independent (#1560) 2026-07-02 21:16:47 -05:00
Elias Schneider
931a6c2adb feat: drop TOFU support for callback URL 2026-07-02 14:05:37 +02:00
James18232
8564d35682 feat: login code input boxes (#1545)
Co-authored-by: james <james@goldfish.net>
Co-authored-by: Elias Schneider <login@eliasschneider.com>
2026-07-02 07:56:18 +00:00
Elias Schneider
ecad31cae2 refactor: migrate signup functionality to single usersignup module 2026-06-30 20:53:31 +02:00
Elias Schneider
58fcf7cbe6 refactor: migrate Webauthn functionality to single webauthn module 2026-06-29 14:05:07 +02:00
Elias Schneider
96ea55b9bc chore(translations): update translations via Crowdin (#1550) 2026-06-29 12:12:51 +02:00
Elias Schneider
9fce987106 refactor: migrate API key functionality to single apikey module 2026-06-29 12:11:18 +02:00
James18232
97bd466f38 feat: prompt admin with PKCE client support hint (#1499)
Co-authored-by: james <james@goldfish.net>
Co-authored-by: Alessandro (Ale) Segala <43508+ItalyPaleAle@users.noreply.github.com>
Co-authored-by: Elias Schneider <login@eliasschneider.com>
Co-authored-by: Kyle Mendell <kmendell@ofkm.us>
2026-06-28 11:10:30 -07:00
Elias Schneider
1b3ba3f6c2 chore(translations): update translations via Crowdin (#1548) 2026-06-28 12:01:44 -05:00
Elias Schneider
254ae88cda ci/cd: add exempt user to PR quality checks 2026-06-27 14:41:14 +02:00
Elias Schneider
cb665681c4 chore(translations): update translations via Crowdin (#1543) 2026-06-27 13:01:19 +02:00
Elias Schneider
d467855870 feat: add ability to skip consent for client 2026-06-26 23:35:26 +02:00
Elias Schneider
16b5c16a66 fix: CSP error with response_mode=form_post 2026-06-26 14:51:56 +02:00
Elias Schneider
2ed703540d fix: don't reject offline_accessscope 2026-06-26 14:43:22 +02:00
Elias Schneider
cf54786cc3 ci/cd: don't tag next image with current version 2026-06-26 13:50:52 +02:00
Elias Schneider
cdc23744ae chore: change Depot project 2026-06-25 22:41:40 +02:00
Elias Schneider
4b957a0106 ci/cd: don't build unnecessary binaries for next image 2026-06-25 22:23:41 +02:00
Elias Schneider
49305ed1f9 ci/cd: remove static next image tag 2026-06-24 22:29:27 +02:00
Elias Schneider
99ab38dc79 chore: add support for Docker Hub 2026-06-24 22:00:49 +02:00
Kyle Mendell
35dcf58ee9 chore: use goreleaser for binaries and static image builds (#1478)
Co-authored-by: Elias Schneider <login@eliasschneider.com>
2026-06-24 21:49:27 +02:00
Elias Schneider
d0243fe396 chore: add AGENTS.md 2026-06-23 09:09:34 +02:00
Elias Schneider
59fd6b3726 chore(translations): update translations via Crowdin (#1524) 2026-06-23 08:38:59 +02:00
Elias Schneider
7eaaea717b tests(e2e): fix flaky test 2026-06-22 23:00:10 +02:00
Elias Schneider
e538ea0bf1 chore(deps): update vulnerable dependencies 2026-06-22 22:37:51 +02:00
Elias Schneider
8689ddd72b feat: improve error handling on authorize page 2026-06-22 22:12:14 +02:00
Elias Schneider
519cda0eef refactor: remove dead code 2026-06-22 21:58:55 +02:00
Elias Schneider
cf9a31986f chore: wrap last two migrations into transaction 2026-06-22 18:44:48 +02:00
Elias Schneider
8158452b37 refactor: use fosite for OAuth 2.0 logic (#1520) 2026-06-22 18:42:02 +02:00
Elias Schneider
dbbe2a403a tests(e2e): fix locators after shadcn upgrade 2026-06-21 20:52:42 +02:00
Elias Schneider
80509c83ee feat: upgrade shadcn components 2026-06-20 23:57:34 +02:00
James18232
2e77d57282 fix: login code null submission and login code length check (#1512)
Co-authored-by: james <james@goldfish.net>
Co-authored-by: Kyle Mendell <kmendell@ofkm.us>
2026-06-20 20:42:50 +02:00
dependabot[bot]
f673a91a67 chore(deps): Bump the "all-dependencies" group with 2 updates across multiple ecosystems (#1528)
Signed-off-by: dependabot[bot] <support@github.com>
2026-06-20 09:58:24 -05:00
Elias Schneider
35987b9c8d ci/cd: close low-demand feature requests 2026-06-16 14:16:24 +02:00
471 changed files with 21770 additions and 11409 deletions

View File

@@ -4,6 +4,7 @@ on:
push:
branches:
- main
workflow_dispatch:
concurrency:
group: build-next-image
@@ -20,117 +21,72 @@ jobs:
build-next:
runs-on: depot-ubuntu-latest
env:
CONTAINER_IMAGE_NAME: ghcr.io/${{ github.repository_owner }}/pocket-id
steps:
- name: Checkout code
uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Setup pnpm
uses: pnpm/action-setup@v5
with:
run_install: false
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: 24
cache: "pnpm"
cache: pnpm
- name: Setup Go
uses: actions/setup-go@v6
with:
go-version-file: "backend/go.mod"
go-version-file: backend/go.mod
cache-dependency-path: backend/go.sum
- name: Set up Depot CLI
uses: depot/setup-action@v1
- name: Setup depot buildx driver
run: depot configure-docker
- name: Login to GitHub Container Registry
uses: docker/login-action@v3
uses: docker/login-action@v4.2.0
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Container Image Metadata
id: meta
uses: docker/metadata-action@v5
- name: Login to Docker Hub
uses: docker/login-action@v4.2.0
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
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Container Image Metadata
id: distroless-meta
uses: docker/metadata-action@v5
- name: Run GoReleaser
uses: goreleaser/goreleaser-action@v7.2.2
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
distribution: goreleaser-pro
version: "~> v2"
args: release --clean --skip=announce,validate --parallelism=4
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
BUILD_NEXT: "true"
SKIP_RELEASE: "true"
SKIP_CHANGELOG: "true"
GORELEASER_KEY: ${{ secrets.GORELEASER_KEY }}
MACOS_SIGN_P12: ${{ secrets.MACOS_SIGN_P12 }}
MACOS_SIGN_PASSWORD: ${{ secrets.MACOS_SIGN_PASSWORD }}
MACOS_NOTARY_KEY: ${{ secrets.MACOS_NOTARY_KEY }}
MACOS_NOTARY_KEY_ID: ${{ secrets.MACOS_NOTARY_KEY_ID }}
MACOS_NOTARY_ISSUER_ID: ${{ secrets.MACOS_NOTARY_ISSUER_ID }}
- name: Install frontend dependencies
run: pnpm install --frozen-lockfile
- name: Build frontend
working-directory: frontend
run: pnpm run build
- name: Build binaries
run: sh scripts/development/build-binaries.sh --docker-only
- name: Build and push container image
id: build-push-image
uses: depot/build-push-action@v1
- name: Binary attestation
uses: actions/attest@v4
with:
context: .
file: docker/Dockerfile-prebuilt
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
sbom: false
provenance: true
- name: Build and push container image (distroless)
uses: depot/build-push-action@v1
id: container-build-push-distroless
with:
context: .
file: docker/Dockerfile-distroless
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.distroless-meta.outputs.tags }}
labels: ${{ steps.distroless-meta.outputs.labels }}
sbom: false
provenance: true
subject-checksums: ./dist/checksums.txt
- name: Container image attestation
uses: actions/attest-build-provenance@v2
uses: actions/attest@v4
with:
subject-name: "${{ env.CONTAINER_IMAGE_NAME }}"
subject-digest: ${{ steps.build-push-image.outputs.digest }}
push-to-registry: true
- name: Container image attestation (distroless)
uses: actions/attest-build-provenance@v2
with:
subject-name: "${{ env.CONTAINER_IMAGE_NAME }}"
subject-digest: ${{ steps.container-build-push-distroless.outputs.digest }}
push-to-registry: true
subject-checksums: ./dist/digests.txt

View File

@@ -0,0 +1,24 @@
name: Close Stale Feature Requests
on:
schedule:
- cron: "0 0 * * 1" # Runs every Monday at midnight
workflow_dispatch:
inputs:
dry-run:
description: "Log matching issues without closing them"
required: false
default: false
type: boolean
permissions:
contents: read
issues: write
jobs:
close-stale-feature-requests:
runs-on: ubuntu-latest
steps:
- uses: pocket-id/low-demand-closer@v1
with:
dry-run: ${{ inputs['dry-run'] || false }}

View File

@@ -80,7 +80,7 @@ jobs:
dependabot[bot]
renovate[bot]
github-actions[bot]
exempt-users: ""
exempt-users: "James18232"
exempt-author-association: "OWNER,MEMBER,COLLABORATOR"
exempt-label: "quality/exempt"
exempt-pr-label: ""

View File

@@ -8,151 +8,84 @@ on:
permissions:
contents: write
packages: write
attestations: write
id-token: write
attestations: write
artifact-metadata: write
jobs:
build:
release:
runs-on: depot-ubuntu-24.04-16
env:
CONTAINER_IMAGE_NAME: ghcr.io/${{ github.repository_owner }}/pocket-id
steps:
- name: Checkout code
uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Setup pnpm
uses: pnpm/action-setup@v5
- name: Set up Depot CLI
uses: depot/setup-action@v1
with:
run_install: false
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: 24
cache: "pnpm"
cache: pnpm
- uses: actions/setup-go@v6
- name: Setup Go
uses: actions/setup-go@v6
with:
go-version-file: "backend/go.mod"
go-version-file: backend/go.mod
cache-dependency-path: backend/go.sum
- name: Set up Depot CLI
uses: depot/setup-action@v1
- name: Setup depot buildx driver
run: depot configure-docker
- name: Login to GitHub Container Registry
uses: docker/login-action@v3
uses: docker/login-action@v4.2.0
with:
registry: ghcr.io
username: ${{github.repository_owner}}
password: ${{secrets.GITHUB_TOKEN}}
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Docker metadata
id: meta
uses: docker/metadata-action@v5
- name: Login to Docker Hub
uses: docker/login-action@v4.2.0
with:
images: |
${{ env.CONTAINER_IMAGE_NAME }}
tags: |
type=semver,pattern={{version}},prefix=v
type=semver,pattern={{major}}.{{minor}},prefix=v
type=semver,pattern={{major}},prefix=v
labels: |
org.opencontainers.image.authors=Pocket ID
org.opencontainers.image.url=https://github.com/pocket-id/pocket-id
org.opencontainers.image.documentation=https://github.com/pocket-id/pocket-id/blob/main/README.md
org.opencontainers.image.source=https://github.com/pocket-id/pocket-id
org.opencontainers.image.version=next
org.opencontainers.image.licenses=BSD-2-Clause
org.opencontainers.image.ref.name=pocket-id
org.opencontainers.image.title=Pocket ID
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Docker metadata (distroless)
id: meta-distroless
uses: docker/metadata-action@v5
- name: Run GoReleaser
uses: goreleaser/goreleaser-action@v7.2.2
with:
images: |
${{ env.CONTAINER_IMAGE_NAME }}
flavor: |
suffix=-distroless,onlatest=true
tags: |
type=semver,pattern={{version}},prefix=v
type=semver,pattern={{major}}.{{minor}},prefix=v
type=semver,pattern={{major}},prefix=v
labels: |
org.opencontainers.image.authors=Pocket ID
org.opencontainers.image.url=https://github.com/pocket-id/pocket-id
org.opencontainers.image.documentation=https://github.com/pocket-id/pocket-id/blob/main/README.md
org.opencontainers.image.source=https://github.com/pocket-id/pocket-id
org.opencontainers.image.version=next-distroless
org.opencontainers.image.licenses=BSD-2-Clause
org.opencontainers.image.ref.name=pocket-id
org.opencontainers.image.title=Pocket ID
- name: Install frontend dependencies
run: pnpm --filter pocket-id-frontend install --frozen-lockfile
- name: Build frontend
run: pnpm --filter pocket-id-frontend build
- name: Build binaries
run: sh scripts/development/build-binaries.sh
- name: Build and push container image
uses: depot/build-push-action@v1
id: container-build-push
with:
context: .
file: docker/Dockerfile-prebuilt
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
sbom: false
provenance: true
- name: Build and push container image (distroless)
uses: depot/build-push-action@v1
id: container-build-push-distroless
with:
context: .
file: docker/Dockerfile-distroless
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.meta-distroless.outputs.tags }}
labels: ${{ steps.meta-distroless.outputs.labels }}
sbom: false
provenance: true
- name: Binary attestation
uses: actions/attest-build-provenance@v2
with:
subject-path: "backend/.bin/pocket-id-**"
- name: Container image attestation
uses: actions/attest-build-provenance@v2
with:
subject-name: "${{ env.CONTAINER_IMAGE_NAME }}"
subject-digest: ${{ steps.container-build-push.outputs.digest }}
push-to-registry: true
- name: Container image attestation (distroless)
uses: actions/attest-build-provenance@v2
with:
subject-name: "${{ env.CONTAINER_IMAGE_NAME }}"
subject-digest: ${{ steps.container-build-push-distroless.outputs.digest }}
push-to-registry: true
- name: Upload binaries to release
distribution: goreleaser-pro
version: "~> v2"
args: release --clean --skip=validate --parallelism=4
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: gh release upload ${{ github.ref_name }} backend/.bin/*
GORELEASER_KEY: ${{ secrets.GORELEASER_KEY }}
MACOS_SIGN_P12: ${{ secrets.MACOS_SIGN_P12 }}
MACOS_SIGN_PASSWORD: ${{ secrets.MACOS_SIGN_PASSWORD }}
MACOS_NOTARY_KEY: ${{ secrets.MACOS_NOTARY_KEY }}
MACOS_NOTARY_KEY_ID: ${{ secrets.MACOS_NOTARY_KEY_ID }}
MACOS_NOTARY_ISSUER_ID: ${{ secrets.MACOS_NOTARY_ISSUER_ID }}
DISCORD_WEBHOOK_ID: ${{ secrets.DISCORD_WEBHOOK_ID }}
DISCORD_WEBHOOK_TOKEN: ${{ secrets.DISCORD_WEBHOOK_TOKEN }}
publish-release:
runs-on: depot-ubuntu-latest
needs: [build]
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
steps:
- name: Checkout code
uses: actions/checkout@v6
- name: Mark release as published
- name: Binary attestation
uses: actions/attest@v4
with:
subject-checksums: ./dist/checksums.txt
- name: Container image attestation
uses: actions/attest@v4
with:
subject-checksums: ./dist/digests.txt
- name: Publish release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: gh release edit ${{ github.ref_name }} --draft=false

2
.gitignore vendored
View File

@@ -60,3 +60,5 @@ yarn-error.log*
#Debug
backend/cmd/__debug_*
# Added by goreleaser init:
dist/

247
.goreleaser.yaml Normal file
View File

@@ -0,0 +1,247 @@
# yaml-language-server: $schema=https://goreleaser.com/static/schema-pro.json
# vim: set ts=2 sw=2 tw=0 fo=jcroql
version: 2
pro: true
project_name: pocket-id
before:
hooks:
- pnpm install --frozen-lockfile
- pnpm -C frontend build
builds:
- id: pocket-id
main: ./cmd
dir: backend
binary: pocket-id
env:
- CGO_ENABLED=0
- GOMAXPROCS=2
goos:
- linux
- darwin
- windows
- freebsd
- openbsd
goarch:
- amd64
- arm64
- "386"
- arm
goarm:
- "7"
goamd64:
- v1
go386:
- sse2
ignore:
- goos: darwin
goarch: "386"
- goos: darwin
goarch: arm
- goos: windows
goarch: "386"
- goos: windows
goarch: arm
- goos: freebsd
goarch: "386"
- goos: freebsd
goarch: arm
- goos: openbsd
goarch: "386"
- goos: openbsd
goarch: arm
ldflags:
- -w -s
- -buildid={{ .Version }}
- -X github.com/pocket-id/pocket-id/backend/internal/common.Version={{ .Version }}
flags:
- -trimpath
- -p=2
mod_timestamp: "{{ .CommitTimestamp }}"
skip: '{{ if index .Env "BUILD_NEXT" }}true{{ end }}'
- id: pocket-id-next
main: ./cmd
dir: backend
binary: pocket-id
env:
- CGO_ENABLED=0
- GOMAXPROCS=2
targets:
- linux_amd64_v1
- linux_arm64_v8.0
ldflags:
- -w -s
- -buildid={{ .Version }}
- -X github.com/pocket-id/pocket-id/backend/internal/common.Version={{ .Version }}
flags:
- -trimpath
- -p=2
mod_timestamp: "{{ .CommitTimestamp }}"
skip: '{{ if not (index .Env "BUILD_NEXT") }}true{{ end }}'
archives:
- id: default
formats:
- binary
name_template: "{{ .Binary }}_{{ .Os }}_{{ .Arch }}{{ with .Arm }}v{{ . }}{{ end }}"
checksum:
name_template: checksums.txt
docker_digest:
name_template: digests.txt
dockers_v2:
- id: pocket-id
ids:
- pocket-id
dockerfile: docker/Dockerfile-prebuilt
images:
- "pocketid/pocket-id"
- "ghcr.io/{{ .Env.GITHUB_REPOSITORY_OWNER }}/pocket-id"
tags:
- "{{ .Tag }}"
- "v{{ .Major }}"
- "v{{ .Major }}.{{ .Minor }}"
- "latest"
platforms:
- linux/amd64
- linux/arm64
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": "{{ .Version }}"
"org.opencontainers.image.revision": "{{ .FullCommit }}"
"org.opencontainers.image.licenses": "BSD-2-Clause"
"org.opencontainers.image.ref.name": "pocket-id"
"org.opencontainers.image.title": "Pocket ID"
disable: '{{ if index .Env "BUILD_NEXT" }}true{{ end }}'
sbom: true
extra_files:
- scripts/docker
- id: pocket-id-distroless
ids:
- pocket-id
dockerfile: docker/Dockerfile-distroless
images:
- "pocketid/pocket-id"
- "ghcr.io/{{ .Env.GITHUB_REPOSITORY_OWNER }}/pocket-id"
tags:
- "{{ .Tag }}-distroless"
- "v{{ .Major }}-distroless"
- "v{{ .Major }}.{{ .Minor }}-distroless"
- "latest-distroless"
platforms:
- linux/amd64
- linux/arm64
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": "{{ .Version }}"
"org.opencontainers.image.revision": "{{ .FullCommit }}"
"org.opencontainers.image.licenses": "BSD-2-Clause"
"org.opencontainers.image.ref.name": "pocket-id"
"org.opencontainers.image.title": "Pocket ID"
disable: '{{ if index .Env "BUILD_NEXT" }}true{{ end }}'
sbom: true
- id: pocket-id-next
ids:
- pocket-id-next
dockerfile: docker/Dockerfile-prebuilt
images:
- "pocketid/pocket-id"
- "ghcr.io/{{ .Env.GITHUB_REPOSITORY_OWNER }}/pocket-id"
tags:
- "next"
platforms:
- linux/amd64
- linux/arm64
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": "{{ .Version }}"
"org.opencontainers.image.revision": "{{ .FullCommit }}"
"org.opencontainers.image.licenses": "BSD-2-Clause"
"org.opencontainers.image.ref.name": "pocket-id"
"org.opencontainers.image.title": "Pocket ID"
disable: '{{ if not (index .Env "BUILD_NEXT") }}true{{ end }}'
sbom: true
extra_files:
- scripts/docker
- id: pocket-id-next-distroless
ids:
- pocket-id-next
dockerfile: docker/Dockerfile-distroless
images:
- "pocketid/pocket-id"
- "ghcr.io/{{ .Env.GITHUB_REPOSITORY_OWNER }}/pocket-id"
tags:
- "next-distroless"
platforms:
- linux/amd64
- linux/arm64
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": "{{ .Version }}"
"org.opencontainers.image.revision": "{{ .FullCommit }}"
"org.opencontainers.image.licenses": "BSD-2-Clause"
"org.opencontainers.image.ref.name": "pocket-id"
"org.opencontainers.image.title": "Pocket ID"
disable: '{{ if not (index .Env "BUILD_NEXT") }}true{{ end }}'
sbom: true
notarize:
macos:
- enabled: '{{ if and (isEnvSet "MACOS_SIGN_P12") (not (index .Env "BUILD_NEXT")) }}true{{ end }}'
ids:
- pocket-id
sign:
certificate: "{{ .Env.MACOS_SIGN_P12 }}"
password: "{{ .Env.MACOS_SIGN_PASSWORD }}"
notarize:
issuer_id: "{{ .Env.MACOS_NOTARY_ISSUER_ID }}"
key_id: "{{ .Env.MACOS_NOTARY_KEY_ID }}"
key: "{{ .Env.MACOS_NOTARY_KEY }}"
wait: true
timeout: 20m
changelog:
disable: true
announce:
discord:
enabled: true
message_template: "Pocket ID version {{ .Tag }} has been released! Checkout the changelog at {{ .ReleaseURL }}"
author: "GitHub Actions"
# Color code of the embed. You have to use decimal numeral system, not hexadecimal.
# color: "9320959"
icon_url: "https://github.githubassets.com/images/modules/logos_page/GitHub-Mark.png"
release:
github:
owner: pocket-id
name: pocket-id
disable: '{{ if index .Env "SKIP_RELEASE" }}true{{ end }}'
draft: true
replace_existing_draft: false
use_existing_draft: true
replace_existing_artifacts: true
prerelease: auto
make_latest: true
mode: keep-existing
name_template: "{{ .Tag }}"

View File

@@ -1 +1 @@
2.9.0
2.10.0

View File

@@ -1,4 +1,4 @@
{
"go.buildTags": "e2etest",
"prettier.documentSelectors": ["**/*.svelte"],
}
"go.buildTags": "e2etest",
"prettier.documentSelectors": ["**/*.svelte"]
}

103
AGENTS.md Normal file
View File

@@ -0,0 +1,103 @@
# AGENTS.md
Pocket ID — a passkey-only OIDC provider. Go backend serves a SvelteKit SPA (embedded in
the binary for production). This file lists what isn't obvious from reading the code.
## Layout
- `backend/` — Go module (gin, GORM, [ory/fosite fork](#backend-go)). Its own toolchain, not part of the repository.
- `frontend/` — SvelteKit 5 SPA. Builds into `backend/frontend/dist` and is embedded via `go:embed`.
- `tests/` — Playwright end-to-end tests (drives a Dockerized full stack).
## Build / test / lint
```sh
# backend/ — the exclude_frontend tag is mandatory locally; CI uses it too
go test -tags=exclude_frontend ./... # unit/integration tests
go test -tags=exclude_frontend -run TestName ./internal/... # a single test
golangci-lint run --build-tags=exclude_frontend # lint (config: backend/.golangci.yml)
# frontend/ (or root)
pnpm check # svelte-check — the ONLY frontend type gate (no unit tests exist)
pnpm lint # prettier --check && eslint (note: not enforced by CI)
pnpm format # prettier --write — REQUIRED before opening a PR
```
End-to-end (needs Docker; **stop any local backend on `:1411` first** — see gotchas):
```sh
cd tests/setup && docker compose up -d --build # rebuild after ANY code change, or you test stale code
cd ../.. && pnpm test # = playwright test in tests/
```
## Critical gotchas
- **`exclude_frontend` build tag.** `backend/frontend/` embeds `dist/` via `go:embed`. Without
a built frontend present, plain `go run`/`go test`/`golangci-lint` fail to compile. Always pass
`-tags exclude_frontend` for backend dev/test/lint. Production builds omit it to embed the SPA.
- **Never edit generated files:** `frontend/src/lib/paraglide/**` (Paraglide i18n output) and
`backend/frontend/dist/**`. For i18n, only edit `frontend/messages/en.json`; other locales come
from Crowdin.
- **Migrations are split by DB.** Raw SQL via golang-migrate in
`backend/resources/migrations/{sqlite,postgres}/` — separate files _and_ separate version
timelines. Add a matching up/down pair to **both**. Not GORM AutoMigrate. SQLite migrations
are not auto-wrapped in a transaction (`NoTxWrap`); wrap multi-statement ones manually
(`PRAGMA foreign_keys=OFF; BEGIN; … COMMIT; PRAGMA foreign_keys=ON;`).
## Backend (Go)
- **Config:** global `common.EnvConfig` (caarlos0/env); any secret var supports a `*_FILE` variant.
- **Logging:** stdlib `log/slog` only (bridged to OpenTelemetry). No zerolog/logrus in app code.
- `go.mod` pins a **fork** of fosite (`replace github.com/ory/fosite => github.com/pocket-id/fosite`).
## Frontend (SvelteKit)
- **Svelte 5 runes only:** `$state`, `$derived`, `$props`, `$bindable`. No `export let`. Event
modifiers are gone — use `preventDefault` from `$lib/utils/event-util` (`onsubmit={preventDefault(fn)}`).
- **Forms:** use the custom `createForm(schema, initial)` from `$lib/utils/form-util.ts` with
`form-input.svelte`. The vendored shadcn formsnap/superforms wrappers exist but app forms don't
use them — match the surrounding file. Import zod as `import { z } from 'zod/v4'`.
## Coding Style Guidelines
### Comments
- Exactly one sentence per line
- There is NO maximum line width: never wrap a single sentence across multiple comment lines, no matter how long that sentence is
- A new line in a comment means a new sentence; a wrapped line does not exist
- No trailing period on single-line comments
- Prefer comments that explain intent, invariants, or why a branch exists
- Avoid comments that simply restate the next line of code
- For multi-step logic, use short section comments to separate the steps and explain why each step exists
- Inside a function, put a one-sentence comment above each major action; the comments double as visual separators between sections and should say what the step does and why, not how
- Favor a few well-placed section comments over a wall of code; a reader should be able to skim the comments and understand the method's flow
```go
// Wrong — one sentence wrapped across multiple lines
// This function performs the main validation logic. It checks
// the input against the schema and returns an error if the
// input is invalid.
// Wrong — trailing period on single-line comment
// Validate the input.
// Right — one sentence per line, each line as long as it needs to be
// This function performs the main validation logic
// It checks the input against the schema and returns an error if the input is invalid
// Right
// Validate the input
// Right
// Normalize the request host so callers can pass either Host or X-Forwarded-Host values
// Right
// Browsers do not accept a cookie Domain attribute set to an IP address
// Returning an empty domain tells the caller to set a host-only cookie instead
// Wrong — restates the code
// Trim whitespace and lowercase the host
host = strings.TrimSpace(strings.ToLower(host))
```
Section comments inside a function — one sentence per major action, describing what and why, acting as visual separators:

View File

@@ -1,3 +1,81 @@
## v2.10.0
### Bug Fixes
- login code null submission and login code length check ([#1512](https://github.com/pocket-id/pocket-id/pull/1512) by @James18232)
- don't reject `offline_access`scope ([2ed7035](https://github.com/pocket-id/pocket-id/commit/2ed703540d901eaf58427d0cf54fb0a5e68bc6a0) by @stonith404)
- CSP error with `response_mode=form_post` ([16b5c16](https://github.com/pocket-id/pocket-id/commit/16b5c16a664a2f1b20ad824d907294de9cb60c9e) by @stonith404)
- various bugs in observability / OTel ([#1564](https://github.com/pocket-id/pocket-id/pull/1564) by @ItalyPaleAle)
- restore behavior that unknown scopes get ignored ([34e9a6d](https://github.com/pocket-id/pocket-id/commit/34e9a6d1982a3d50bb3874aff3316c3154113725) by @stonith404)
- re-check api permissions on access token refresh ([e8cb0c8](https://github.com/pocket-id/pocket-id/commit/e8cb0c831ca914522babce101cf8533acbf94136) by @stonith404)
- merge requested scopes instead of replacing them ([5e2cc6f](https://github.com/pocket-id/pocket-id/commit/5e2cc6f40e5c7139b122d8a59ca121af1fdbdceb) by @stonith404)
- device authorization resolve resource creating device_code ([2f55b7c](https://github.com/pocket-id/pocket-id/commit/2f55b7cbc3d601128061dbc70014e77f5b1ec211) by @stonith404)
- add missing burst to rate limit ([f3b6ceb](https://github.com/pocket-id/pocket-id/commit/f3b6ceb8768616fb155455cceb7a539c13c3da9f) by @stonith404)
- disable one time access token exchange for disabled users ([c85a4e6](https://github.com/pocket-id/pocket-id/commit/c85a4e63dabcb2c71bbf35e03482969cb07a6df8) by @stonith404)
- /authorize endpoint crashes when list of scopes is empty ([#1575](https://github.com/pocket-id/pocket-id/pull/1575) by @ItalyPaleAle)
- mobile layout improvements ([2910c51](https://github.com/pocket-id/pocket-id/commit/2910c517bb7545f932c34321907299064b08bb10) by @stonith404)
### Documentation
- add link to API docs ([968d072](https://github.com/pocket-id/pocket-id/commit/968d07229eeefa763fe981e5c7e4970d5640c783) by @stonith404)
- fix wrong OpenAPI documentation ([da3677f](https://github.com/pocket-id/pocket-id/commit/da3677f33d50c96f06ee1d8f65d9f802f67af270) by @stonith404)
### Features
- upgrade shadcn components ([80509c8](https://github.com/pocket-id/pocket-id/commit/80509c83ee0ce92b416286217c11f116d5b9fe2a) by @stonith404)
- improve error handling on authorize page ([8689ddd](https://github.com/pocket-id/pocket-id/commit/8689ddd72b143ceac29adb63d624cc18585f580e) by @stonith404)
- add ability to skip consent for client ([d467855](https://github.com/pocket-id/pocket-id/commit/d4678558708542719c5d3cc48c48d3053f37800c) by @stonith404)
- prompt admin with PKCE client support hint ([#1499](https://github.com/pocket-id/pocket-id/pull/1499) by @James18232)
- login code input boxes ([#1545](https://github.com/pocket-id/pocket-id/pull/1545) by @James18232)
- drop TOFU support for callback URL ([931a6c2](https://github.com/pocket-id/pocket-id/commit/931a6c2adb39fb8cdfe18487f4206f8848b3d026) by @stonith404)
- add OAuth APIs with scoped permissions ([#1542](https://github.com/pocket-id/pocket-id/pull/1542) by @stonith404)
- add support for unencrypted OIDC request parameter ([25dcad7](https://github.com/pocket-id/pocket-id/commit/25dcad757a7d7670bfd8d4d4ac209e26dc919596) by @stonith404)
- add description field to oidc clients ([#1547](https://github.com/pocket-id/pocket-id/pull/1547) by @seanmckenzie428)
- add tab bar navigation for crowded pages ([28a553f](https://github.com/pocket-id/pocket-id/commit/28a553f63b85ecccaa764279af5f65ea1bf2d013) by @stonith404)
### Other
- close low-demand feature requests ([35987b9](https://github.com/pocket-id/pocket-id/commit/35987b9c8d44efb08928d031ab9f1e84eda9882f) by @stonith404)
- Bump the "all-dependencies" group with 2 updates across multiple ecosystems ([#1528](https://github.com/pocket-id/pocket-id/pull/1528) by @dependabot[bot])
- fix locators after shadcn upgrade ([dbbe2a4](https://github.com/pocket-id/pocket-id/commit/dbbe2a403aa6afc39e14cf559bd074c6640e3b0c) by @stonith404)
- use fosite for OAuth 2.0 logic ([#1520](https://github.com/pocket-id/pocket-id/pull/1520) by @stonith404)
- wrap last two migrations into transaction ([cf9a319](https://github.com/pocket-id/pocket-id/commit/cf9a31986f521451bc9e1c2754a09ee2d141cd57) by @stonith404)
- remove dead code ([519cda0](https://github.com/pocket-id/pocket-id/commit/519cda0eef1083274deed9bf6c501b815986241a) by @stonith404)
- update vulnerable dependencies ([e538ea0](https://github.com/pocket-id/pocket-id/commit/e538ea0bf1ba4d5f70f977c7021bfa4f4543070e) by @stonith404)
- fix flaky test ([7eaaea7](https://github.com/pocket-id/pocket-id/commit/7eaaea717b418c167b733723eeb000073c6915fe) by @stonith404)
- add AGENTS.md ([d0243fe](https://github.com/pocket-id/pocket-id/commit/d0243fe396fb4ae2ec8d66ab95c80c8a283a631b) by @stonith404)
- use goreleaser for binaries and static image builds ([#1478](https://github.com/pocket-id/pocket-id/pull/1478) by @kmendell)
- add support for Docker Hub ([99ab38d](https://github.com/pocket-id/pocket-id/commit/99ab38dc799d07d4d91863199b1350fb7d06e03e) by @stonith404)
- remove static next image tag ([49305ed](https://github.com/pocket-id/pocket-id/commit/49305ed1f98d838167703d3dc2f6e3e5786fb9fc) by @stonith404)
- don't build unnecessary binaries for `next` image ([4b957a0](https://github.com/pocket-id/pocket-id/commit/4b957a0106899666b55420b61d2bb12d08d82022) by @stonith404)
- change Depot project ([cdc2374](https://github.com/pocket-id/pocket-id/commit/cdc23744ae1cb731184828afe326fc2a1712c02f) by @stonith404)
- don't tag `next` image with current version ([cf54786](https://github.com/pocket-id/pocket-id/commit/cf54786cc3bfe59bd01e8d5cbb4a3ccabb7d6466) by @stonith404)
- add exempt user to PR quality checks ([254ae88](https://github.com/pocket-id/pocket-id/commit/254ae88cda67ecc1095b0b738630388c93d90c44) by @stonith404)
- migrate API key functionality to single `apikey` module ([9fce987](https://github.com/pocket-id/pocket-id/commit/9fce98710679e522233c3b7af8d3b6cd2ce5594c) by @stonith404)
- migrate Webauthn functionality to single `webauthn` module ([58fcf7c](https://github.com/pocket-id/pocket-id/commit/58fcf7cbe665be8d7670170097735fe8efe31c6f) by @stonith404)
- migrate signup functionality to single `usersignup` module ([ecad31c](https://github.com/pocket-id/pocket-id/commit/ecad31cae2dbbc51d15a7007601829c57accff75) by @stonith404)
- make API key renewal date picker navigation date-independent ([#1560](https://github.com/pocket-id/pocket-id/pull/1560) by @ItalyPaleAle)
- integrate Francis actor framework for background jobs, cron scheduling, and rate limiting ([#1556](https://github.com/pocket-id/pocket-id/pull/1556) by @ItalyPaleAle)
- Bump the "all-dependencies" group with 3 updates across multiple ecosystems ([#1563](https://github.com/pocket-id/pocket-id/pull/1563) by @dependabot[bot])
- use go-kit for sending emails ([#1565](https://github.com/pocket-id/pocket-id/pull/1565) by @ItalyPaleAle)
- update AAGUIDs ([#1567](https://github.com/pocket-id/pocket-id/pull/1567) by @github-actions[bot])
- move Pocket ID specific logic from Fosite into Pocket ID repo ([9a94aa0](https://github.com/pocket-id/pocket-id/commit/9a94aa0694abda5bdd7c9380cbadc1f080a8027e) by @stonith404)
- update Fosite version ([337fc6f](https://github.com/pocket-id/pocket-id/commit/337fc6fd1ec53aa65275f5ad964869462664a194) by @stonith404)
- pass transaction to `resolveResource` ([7667377](https://github.com/pocket-id/pocket-id/commit/7667377c98c01f45dc5e959c999c60143efca1ef) by @stonith404)
- remove redundant dtos ([fa2d08c](https://github.com/pocket-id/pocket-id/commit/fa2d08cb6d60bc3c9d8865c36c07ef8868e8ed9e) by @stonith404)
- rename migrations ([c72da58](https://github.com/pocket-id/pocket-id/commit/c72da58eaf45eafff3232bcc050fd87e84ae050f) by @stonith404)
- remove duplicate fosite config properties ([190914f](https://github.com/pocket-id/pocket-id/commit/190914fd72481c2da7372215f369a5b96feed3f0) by @stonith404)
- fix migration version in `database.json` ([a5f2192](https://github.com/pocket-id/pocket-id/commit/a5f2192d67045cca63a4e0038e118986754d4184) by @stonith404)
- fix wrong migration name ([ce7d3a7](https://github.com/pocket-id/pocket-id/commit/ce7d3a7e1d6ab08696a149fa8805fcb99337afa9) by @stonith404)
- run formatter ([59a6868](https://github.com/pocket-id/pocket-id/commit/59a6868ecdb017354d4dfd8d70c4ba0710f02f08) by @stonith404)
- upgrade go version ([f62d476](https://github.com/pocket-id/pocket-id/commit/f62d476c56d39f7f20c443eb430462e9141d31fa) by @stonith404)
### Performance Improvements
- include permissions in api list response ([8035a4c](https://github.com/pocket-id/pocket-id/commit/8035a4c8d589109e767a031dae3c473ea3e77d75) by @stonith404)
**Full Changelog**: https://github.com/pocket-id/pocket-id/compare/v2.9.0...v2.10.0
## v2.9.0
### Bug Fixes

View File

@@ -4,9 +4,8 @@ package frontend
import (
"github.com/gin-gonic/gin"
"github.com/pocket-id/pocket-id/backend/internal/service"
)
func RegisterFrontend(router *gin.Engine, oidcService *service.OidcService) error {
func RegisterFrontend(router *gin.Engine) error {
return ErrFrontendNotIncluded
}

View File

@@ -16,8 +16,6 @@ import (
"github.com/gin-gonic/gin"
"github.com/pocket-id/pocket-id/backend/internal/middleware"
"github.com/pocket-id/pocket-id/backend/internal/service"
"golang.org/x/time/rate"
)
//go:embed all:dist/*
@@ -55,7 +53,7 @@ func init() {
}
}
func RegisterFrontend(router *gin.Engine, oidcService *service.OidcService) 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)
@@ -87,19 +85,6 @@ func RegisterFrontend(router *gin.Engine, oidcService *service.OidcService) erro
if isSPARequest(path, distFS) {
nonce := middleware.GetCSPNonce(c)
// For an authorization request that responds via response_mode=form_post, the
// consent page auto-submits a form to the client's callback, so that callback
// must be allowed in the CSP form-action directive
isAuthPostRequest, redirectURI := isOAuth2AuthorizationPostRequest(c, oidcService)
if isAuthPostRequest {
clientID := c.Query("client_id")
// In that case, we need to validate and allow form submissions to the redirect_uri
validatedRedirectURI, err := oidcService.ResolveAllowedCallbackURL(c.Request.Context(), clientID, redirectURI)
if err == nil {
c.Header("Content-Security-Policy", middleware.BuildCSP(nonce, validatedRedirectURI))
}
}
// Do not cache the HTML shell, as it embeds a per-request nonce
c.Header("Content-Type", "text/html; charset=utf-8")
c.Header("Cache-Control", "no-store")
@@ -115,55 +100,11 @@ func RegisterFrontend(router *gin.Engine, oidcService *service.OidcService) erro
fileServer.ServeHTTP(c.Writer, c.Request)
}
rateLimitMiddleware := middleware.NewRateLimitMiddleware().Add(rate.Every(300*time.Millisecond), 50)
router.NoRoute(rateLimitOnlyForOAuth2AuthorizationPostRequest(rateLimitMiddleware, oidcService, distFS), handler)
router.NoRoute(handler)
return nil
}
func rateLimitOnlyForOAuth2AuthorizationPostRequest(rateLimitMiddleware gin.HandlerFunc, oidcService *service.OidcService, distFS fs.FS) gin.HandlerFunc {
return func(c *gin.Context) {
path := strings.TrimPrefix(c.Request.URL.Path, "/")
if isSPARequest(path, distFS) {
isAuthPostRequest, _ := isOAuth2AuthorizationPostRequest(c, oidcService)
if isAuthPostRequest {
rateLimitMiddleware(c)
return
}
}
c.Next()
}
}
// isOAuth2AuthorizationRequest checks if this is an OAuth2 authorization request with response_mode=form_post
// In that case, we need to validate and allow form submissions to the redirect_uri
func isOAuth2AuthorizationPostRequest(c *gin.Context, oidcService *service.OidcService) (bool, string) {
responseMode := c.Query("response_mode")
redirectURI := c.Query("redirect_uri")
clientID := c.Query("client_id")
requestUri := c.Query("request_uri")
if c.Request.URL.Path != "/authorize" {
return false, ""
}
if requestUri != "" && clientID != "" {
par, err := oidcService.GetPushedAuthorizationRequest(c.Request.Context(), clientID, requestUri)
if err != nil {
return false, ""
}
responseMode = par.Parameters.ResponseMode
redirectURI = par.Parameters.RedirectURI
}
ok := responseMode == "form_post" && redirectURI != "" && clientID != ""
return ok, redirectURI
}
func isSPARequest(path string, distFS fs.FS) bool {
if path == "" {
return true

View File

@@ -3,12 +3,9 @@
package frontend
import (
"net/http"
"net/http/httptest"
"testing"
"testing/fstest"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
)
@@ -29,109 +26,3 @@ func TestIsSPARequest(t *testing.T) {
assert.True(t, isSPARequest("authorize", distFS))
})
}
func TestRateLimitOnlyForOAuth2AuthorizationPostRequest(t *testing.T) {
gin.SetMode(gin.TestMode)
distFS := fstest.MapFS{
"assets/app.js": &fstest.MapFile{Data: []byte("console.log('test')")},
}
t.Run("rate limits spa form_post request", func(t *testing.T) {
rateLimited := false
nextCalled := false
middleware := rateLimitOnlyForOAuth2AuthorizationPostRequest(func(c *gin.Context) {
rateLimited = true
c.Abort()
}, nil, distFS)
router := gin.New()
router.NoRoute(
middleware,
func(c *gin.Context) {
nextCalled = true
},
)
recorder := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/authorize?response_mode=form_post&client_id=test&redirect_uri=https://example.com/callback", nil)
router.ServeHTTP(recorder, req)
assert.True(t, rateLimited)
assert.False(t, nextCalled)
})
t.Run("does not rate limit page request with no form_post params", func(t *testing.T) {
rateLimited := false
nextCalled := false
middleware := rateLimitOnlyForOAuth2AuthorizationPostRequest(func(c *gin.Context) {
rateLimited = true
c.Abort()
}, nil, distFS)
router := gin.New()
router.NoRoute(
middleware,
func(c *gin.Context) {
nextCalled = true
},
)
recorder := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/authorize", nil)
router.ServeHTTP(recorder, req)
assert.False(t, rateLimited)
assert.True(t, nextCalled)
})
t.Run("does not rate limit static asset request with form_post params", func(t *testing.T) {
rateLimited := false
nextCalled := false
middleware := rateLimitOnlyForOAuth2AuthorizationPostRequest(func(c *gin.Context) {
rateLimited = true
c.Abort()
}, nil, distFS)
router := gin.New()
router.NoRoute(
middleware,
func(c *gin.Context) {
nextCalled = true
},
)
recorder := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/assets/app.js?response_mode=form_post&client_id=test&redirect_uri=https://example.com/callback", nil)
router.ServeHTTP(recorder, req)
assert.False(t, rateLimited)
assert.True(t, nextCalled)
})
t.Run("does not rate limit non-authorize spa path with form_post params", func(t *testing.T) {
rateLimited := false
nextCalled := false
// oidcService is nil: a non-/authorize path is rejected by the path guard
// before the request_uri branch that would dereference it.
middleware := rateLimitOnlyForOAuth2AuthorizationPostRequest(func(c *gin.Context) {
rateLimited = true
c.Abort()
}, nil, distFS)
router := gin.New()
router.NoRoute(
middleware,
func(c *gin.Context) {
nextCalled = true
},
)
recorder := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/settings?response_mode=form_post&client_id=test&redirect_uri=https://example.com/callback", nil)
router.ServeHTTP(recorder, req)
assert.False(t, rateLimited)
assert.True(t, nextCalled)
})
}

View File

@@ -1,41 +1,44 @@
module github.com/pocket-id/pocket-id/backend
go 1.26
go 1.26.5
require (
github.com/aws/aws-sdk-go-v2 v1.42.0
github.com/aws/aws-sdk-go-v2/config v1.32.24
github.com/aws/aws-sdk-go-v2/credentials v1.19.23
github.com/aws/aws-sdk-go-v2/service/s3 v1.103.3
github.com/aws/smithy-go v1.27.1
github.com/aws/aws-sdk-go-v2 v1.42.1
github.com/aws/aws-sdk-go-v2/config v1.32.25
github.com/aws/aws-sdk-go-v2/credentials v1.19.24
github.com/aws/aws-sdk-go-v2/service/s3 v1.104.0
github.com/aws/smithy-go v1.27.3
github.com/caarlos0/env/v11 v11.4.1
github.com/cenkalti/backoff/v5 v5.0.3
github.com/coreos/go-systemd v0.0.0-20191104093116-d3cd4ed1dbcf
github.com/disintegration/imageorient v0.0.0-20180920195336-8147d86e83ec
github.com/disintegration/imaging v1.6.2
github.com/dunglas/go-urlpattern v0.0.0-20241020164140-716dfa1c80b1
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6
github.com/emersion/go-smtp v0.24.0
github.com/fsnotify/fsnotify v1.10.1
github.com/gin-contrib/slog v1.2.1
github.com/gin-gonic/gin v1.12.0
github.com/glebarez/go-sqlite v1.22.0
github.com/glebarez/sqlite v1.11.0
github.com/go-co-op/gocron/v2 v2.21.2
github.com/go-jose/go-jose/v4 v4.1.4
github.com/go-ldap/ldap/v3 v3.4.13
github.com/go-playground/validator/v10 v10.30.3
github.com/go-webauthn/webauthn v0.17.4
github.com/golang-migrate/migrate/v4 v4.19.1
github.com/google/uuid v1.6.0
github.com/hashicorp/go-uuid v1.0.3
github.com/italypaleale/francis v0.1.0-beta.6
github.com/italypaleale/go-kit v0.0.0-20260705144331-cc6661f9a8cf
github.com/italypaleale/go-sql-utils v0.2.4
github.com/jackc/pgx/v5 v5.10.0
github.com/jinzhu/copier v0.4.0
github.com/joho/godotenv v1.5.1
github.com/lestrrat-go/httprc/v3 v3.0.6
github.com/lestrrat-go/jwx/v3 v3.1.1
github.com/libtnb/sqlite v1.1.2
github.com/lmittmann/tint v1.1.3
github.com/mattn/go-isatty v0.0.22
github.com/mileusna/useragent v1.3.5
github.com/orandin/slog-gorm v1.4.0
github.com/ory/fosite v0.49.1-0.20250703093431-a5f0b09bf31c
github.com/oschwald/maxminddb-golang/v2 v2.4.0
github.com/spf13/cobra v1.10.2
github.com/stretchr/testify v1.11.1
@@ -46,35 +49,41 @@ require (
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0
go.opentelemetry.io/otel v1.44.0
go.opentelemetry.io/otel/log v0.20.0
go.opentelemetry.io/otel/metric v1.44.0
go.opentelemetry.io/otel/sdk v1.44.0
go.opentelemetry.io/otel/sdk/log v0.20.0
go.opentelemetry.io/otel/sdk/metric v1.44.0
go.opentelemetry.io/otel/trace v1.44.0
golang.org/x/crypto v0.52.0
golang.org/x/image v0.42.0
golang.org/x/crypto v0.53.0
golang.org/x/image v0.43.0
golang.org/x/sync v0.21.0
golang.org/x/text v0.38.0
golang.org/x/time v0.15.0
gorm.io/driver/postgres v1.6.0
gorm.io/gorm v1.31.1
gorm.io/plugin/opentelemetry v0.1.16
modernc.org/sqlite v1.53.0
)
require (
github.com/Azure/go-ntlmssp v0.1.0 // indirect
filippo.io/edwards25519 v1.2.0 // indirect
github.com/Azure/go-ntlmssp v0.1.1 // indirect
github.com/ClickHouse/ch-go v0.61.5 // indirect
github.com/ClickHouse/clickhouse-go/v2 v2.30.0 // indirect
github.com/alphadose/haxmap v1.4.1 // indirect
github.com/andybalholm/brotli v1.1.1 // indirect
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.13 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.29 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.29 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.29 // indirect
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.30 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.12 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.30 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.30 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.30 // indirect
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.31 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.13 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.22 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.29 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.30 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.29 // indirect
github.com/aws/aws-sdk-go-v2/service/signin v1.1.5 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.31.3 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.6 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.43.3 // indirect
github.com/aws/aws-sdk-go-v2/service/signin v1.4.0 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.32.0 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.37.0 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.44.0 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/bits-and-blooms/bitset v1.24.4 // indirect
github.com/bytedance/gopkg v0.1.4 // indirect
@@ -82,8 +91,10 @@ require (
github.com/bytedance/sonic/loader v0.5.1 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/cloudwego/base64x v0.1.7 // indirect
github.com/cristalhq/jwt/v5 v5.4.0 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1 // indirect
github.com/dgraph-io/ristretto/v2 v2.4.0 // indirect
github.com/disintegration/gift v1.2.1 // indirect
github.com/dsoprea/go-exif v0.0.0-20230826092837-6579e82b732d // indirect
github.com/dsoprea/go-exif/v2 v2.0.0-20230826092837-6579e82b732d // indirect
@@ -93,38 +104,50 @@ require (
github.com/dsoprea/go-photoshop-info-format v0.0.0-20200610045659-121dd752914d // indirect
github.com/dsoprea/go-png-image-structure v0.0.0-20210512210324-29b889a6093d // indirect
github.com/dsoprea/go-utility v0.0.0-20221003172846-a3e1774ef349 // indirect
github.com/dunglas/httpsfv v1.1.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/felixge/httpsnoop v1.1.0 // indirect
github.com/fxamacker/cbor/v2 v2.9.2 // indirect
github.com/gabriel-vasile/mimetype v1.4.13 // indirect
github.com/gin-contrib/sse v1.1.1 // indirect
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 // indirect
github.com/go-errors/errors v1.5.1 // indirect
github.com/go-faster/city v1.0.1 // indirect
github.com/go-faster/errors v0.7.1 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-sql-driver/mysql v1.9.3 // indirect
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
github.com/go-webauthn/x v0.2.6 // indirect
github.com/go-xmlfmt/xmlfmt v1.1.3 // indirect
github.com/goccy/go-json v0.10.6 // indirect
github.com/goccy/go-yaml v1.19.2 // indirect
github.com/gogo/googleapis v1.4.1 // indirect
github.com/gogo/protobuf v1.3.2 // indirect
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
github.com/golang/geo v0.0.0-20250319145452-ed1c8b99c3d7 // indirect
github.com/google/go-github/v39 v39.2.0 // indirect
github.com/google/go-querystring v1.2.0 // indirect
github.com/google/go-tpm v0.9.8 // indirect
github.com/gorilla/websocket v1.5.3 // indirect
github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 // indirect
github.com/h2non/filetype v1.1.3 // indirect
github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
github.com/hashicorp/go-retryablehttp v0.7.8 // indirect
github.com/hashicorp/go-version v1.6.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/jackc/pgerrcode v0.0.0-20250907135507-afb5586c32a6 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/pgx/v5 v5.9.1 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/jaegertracing/jaeger-idl v0.9.0 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/jonboulle/clockwork v0.5.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/compress v1.18.5 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/lestrrat-go/blackmagic v1.0.4 // indirect
@@ -134,33 +157,59 @@ require (
github.com/lestrrat-go/option/v2 v2.0.0 // indirect
github.com/lib/pq v1.12.3 // indirect
github.com/mattn/go-sqlite3 v1.14.42 // indirect
github.com/mattn/goveralls v0.0.12 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/nlnwa/whatwg-url v0.6.2 // indirect
github.com/openzipkin/zipkin-go v0.4.3 // indirect
github.com/ory/go-acc v0.2.9-0.20230103102148-6b1c9a70dbbe // indirect
github.com/ory/go-convenience v0.1.0 // indirect
github.com/ory/pop/v6 v6.4.1 // indirect
github.com/ory/x v0.0.729 // indirect
github.com/paulmach/orb v0.11.1 // indirect
github.com/pelletier/go-toml/v2 v2.3.1 // indirect
github.com/philhofer/fwd v1.2.0 // indirect
github.com/pierrec/lz4/v4 v4.1.21 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/prometheus/client_golang v1.23.2 // indirect
github.com/prometheus/client_model v0.6.2 // indirect
github.com/prometheus/common v0.67.5 // indirect
github.com/prometheus/common v0.69.0 // indirect
github.com/prometheus/otlptranslator v1.0.0 // indirect
github.com/prometheus/procfs v0.20.1 // indirect
github.com/prometheus/procfs v0.21.1 // indirect
github.com/quic-go/qpack v0.6.0 // indirect
github.com/quic-go/quic-go v0.59.1 // indirect
github.com/quic-go/quic-go v0.60.0 // indirect
github.com/quic-go/webtransport-go v0.10.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/robfig/cron/v3 v3.0.1 // indirect
github.com/sagikazarmark/locafero v0.12.0 // indirect
github.com/seatgeek/logrus-gelf-formatter v0.0.0-20210414080842-5b05eb8ff761 // indirect
github.com/segmentio/asm v1.2.1 // indirect
github.com/shopspring/decimal v1.4.0 // indirect
github.com/sirupsen/logrus v1.9.4 // indirect
github.com/spf13/afero v1.15.0 // indirect
github.com/spf13/cast v1.10.0 // indirect
github.com/spf13/pflag v1.0.10 // indirect
github.com/spf13/viper v1.21.0 // indirect
github.com/subosito/gotenv v1.6.0 // indirect
github.com/tinylib/msgp v1.6.4 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.3.1 // indirect
github.com/valyala/fastjson v1.6.10 // indirect
github.com/vmihailenco/msgpack/v5 v5.4.1 // indirect
github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect
github.com/x448/float16 v0.8.4 // indirect
go.mongodb.org/mongo-driver/v2 v2.6.0 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/contrib/bridges/prometheus v0.69.0 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/httptrace/otelhttptrace v0.69.0 // indirect
go.opentelemetry.io/contrib/propagators/b3 v1.44.0 // indirect
go.opentelemetry.io/contrib/propagators/jaeger v1.44.0 // indirect
go.opentelemetry.io/contrib/samplers/jaegerremote v0.37.1 // indirect
go.opentelemetry.io/otel/exporters/jaeger v1.17.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.20.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.20.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.44.0 // indirect
@@ -172,21 +221,31 @@ require (
go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.20.0 // indirect
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.44.0 // indirect
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.44.0 // indirect
go.opentelemetry.io/otel/exporters/zipkin v1.44.0 // indirect
go.opentelemetry.io/otel/metric v1.44.0 // indirect
go.opentelemetry.io/proto/otlp v1.10.0 // indirect
go.yaml.in/yaml/v2 v2.4.4 // indirect
go.uber.org/mock v0.6.0 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/arch v0.27.0 // indirect
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f // indirect
golang.org/x/net v0.55.0 // indirect
golang.org/x/exp v0.0.0-20260611194520-c48552f49976 // indirect
golang.org/x/mod v0.37.0 // indirect
golang.org/x/net v0.56.0 // indirect
golang.org/x/oauth2 v0.36.0 // indirect
golang.org/x/sys v0.45.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa // indirect
google.golang.org/grpc v1.81.1 // indirect
golang.org/x/sys v0.46.0 // indirect
golang.org/x/time v0.15.0 // indirect
golang.org/x/tools v0.46.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20260630182238-925bb5da69e7 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260630182238-925bb5da69e7 // indirect
google.golang.org/grpc v1.82.0 // indirect
google.golang.org/protobuf v1.36.11 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
modernc.org/libc v1.71.0 // indirect
gorm.io/driver/clickhouse v0.7.0 // indirect
gorm.io/driver/mysql v1.5.7 // indirect
k8s.io/utils v0.0.0-20260617174310-a95e086a2553 // indirect
modernc.org/libc v1.73.5 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
modernc.org/sqlite v1.48.2 // indirect
)
replace github.com/ory/fosite => github.com/pocket-id/fosite v0.0.0-20260708083902-56a3c0f378d6

View File

@@ -1,47 +1,59 @@
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25UVaW/CKtUDjefjrs0SPonmDGUVOYP0=
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
github.com/Azure/go-ntlmssp v0.1.0 h1:DjFo6YtWzNqNvQdrwEyr/e4nhU3vRiwenz5QX7sFz+A=
github.com/Azure/go-ntlmssp v0.1.0/go.mod h1:NYqdhxd/8aAct/s4qSYZEerdPuH1liG2/X9DiVTbhpk=
filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg=
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
github.com/Azure/go-ntlmssp v0.1.1 h1:l+FM/EEMb0U9QZE7mKNEDw5Mu3mFiaa2GKOoTSsNDPw=
github.com/Azure/go-ntlmssp v0.1.1/go.mod h1:NYqdhxd/8aAct/s4qSYZEerdPuH1liG2/X9DiVTbhpk=
github.com/ClickHouse/ch-go v0.61.5 h1:zwR8QbYI0tsMiEcze/uIMK+Tz1D3XZXLdNrlaOpeEI4=
github.com/ClickHouse/ch-go v0.61.5/go.mod h1:s1LJW/F/LcFs5HJnuogFMta50kKDO0lf9zzfrbl0RQg=
github.com/ClickHouse/clickhouse-go/v2 v2.30.0 h1:AG4D/hW39qa58+JHQIFOSnxyL46H6h2lrmGGk17dhFo=
github.com/ClickHouse/clickhouse-go/v2 v2.30.0/go.mod h1:i9ZQAojcayW3RsdCb3YR+n+wC2h65eJsZCscZ1Z1wyo=
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
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.42.0 h1:XvXMJTkFQtpBKIWZnmr9ZEOc2InWM2yldjXEJ/bymhA=
github.com/aws/aws-sdk-go-v2 v1.42.0/go.mod h1:27+ACypSLljLAEKsCYOmrjKh83vuTRkuAe9Uv/3A4bg=
github.com/alphadose/haxmap v1.4.1 h1:VtD6VCxUkjNIfJk/aWdYFfOzrRddDFjmvmRmILg7x8Q=
github.com/alphadose/haxmap v1.4.1/go.mod h1:rjHw1IAqbxm0S3U5tD16GoKsiAd8FWx5BJ2IYqXwgmM=
github.com/andybalholm/brotli v1.1.1 h1:PR2pgnyFznKEugtsUo0xLdDop5SKXd5Qf5ysW+7XdTA=
github.com/andybalholm/brotli v1.1.1/go.mod h1:05ib4cKhjx3OQYUY22hTVd34Bc8upXjOLL2rKwwZBoA=
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3dyBCFEj5IhUbnKptjxatkF07cF2ak3yi77so=
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw=
github.com/aws/aws-sdk-go-v2 v1.42.1 h1:9eOTgu1z/dVtYpNZ3/8/XbbaX0x/BqE3HUzAzs6K0ek=
github.com/aws/aws-sdk-go-v2 v1.42.1/go.mod h1:5pKeft2eJj+gElQ38Jqg4ibCqh+/AK33/0X3hip7IjM=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.13 h1:p1BBrg/Hhp6uK7zpejeI8QFXHJeC/mynzi04Sl03k9g=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.13/go.mod h1:8cIfkE9MDhkRZGpQ22aV6/lkYeYSozpz16Smrs5x4Ls=
github.com/aws/aws-sdk-go-v2/config v1.32.24 h1:aEDEj533yGdVvEHfkCY0D/1FbDrjnZr4pIulxRjqpHs=
github.com/aws/aws-sdk-go-v2/config v1.32.24/go.mod h1:yZtrGKJGlqfEW+/m2uTsJK+Jz7xF5R0eZfgcIG9m1ss=
github.com/aws/aws-sdk-go-v2/credentials v1.19.23 h1:Zhu3GOpRCkNjtE/gJpuPDsytSnaCCTQk8neAGsgzG5Y=
github.com/aws/aws-sdk-go-v2/credentials v1.19.23/go.mod h1:VsJF2ropPB37gDr7M2rLSpCE8IQWdpl62uae7qxZmqU=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.29 h1:r6qZHbT+wxgWO/e9vYNUEtg7lv5+UN3pRqKhLXvnArg=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.29/go.mod h1:QRnaRcTVGKPGRy8w78HMQtKUGRYcnMZAANATkeVA6Mo=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.29 h1:f3vKqSo13fhTYb+JEcXwXefZQE26I1FB5eTSniU67ko=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.29/go.mod h1:MzoLFUArKGpGD+ukmPiTPG1X5x4o6M2kq4v2dr1FiEc=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.29 h1:RdwIf/CuUsvJX3RgJagbOyotl/cxoLY4xviKuE7p2GY=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.29/go.mod h1:71wt8W2EgswdZy9Mf9KNnzxZ3TiZlv4caKghPktDOkA=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.30 h1:VTGy885W5DKBxWRUJbym9hytNaYzsyaPkCHGRRMAOhU=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.30/go.mod h1:AS0HycUvJRFvTt613AYDOgO2jzw+00cVSMny8XB3yMY=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.12 h1:ZD2+BSw9vFsNlKYIasSNt3uDbjqqXIBcM13UJv/Lx2k=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.12/go.mod h1:Ms4zlcVBbXbiP7EVLhl+lgjvA/a7YphqQ3Ih3174EmI=
github.com/aws/aws-sdk-go-v2/config v1.32.25 h1:ACCejvStYoilgwrfegSt5ZntCbPrk52qfwyNcnl3omM=
github.com/aws/aws-sdk-go-v2/config v1.32.25/go.mod h1:LJyU8sDRbXUxFn8xMJIGP+v9QYYwveNLI8a/giAOiAs=
github.com/aws/aws-sdk-go-v2/credentials v1.19.24 h1:2hQqYCV9yqyePQ9o6dCrZc/zO8U3TwPr9mIKlZnPu/I=
github.com/aws/aws-sdk-go-v2/credentials v1.19.24/go.mod h1:IDwpACtwqHLISdzfwUUNq4P9DsB/h5BLg4FwJPNfqFY=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.30 h1:/hi1JADLEW9YYryEz1w4GQu0EtP23pP553Cf9KgsDV4=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.30/go.mod h1:/3AOgy4K17Dm4ucMZVC/MJkzy5kmfKUcINRHZyo0koQ=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.30 h1:xM/Is9cKMHa8Jj8zkvWhvrFkZsXJV9E+BB4g0HW0duQ=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.30/go.mod h1:WueJeNDZvK1fMYEWJIkcivBfEzUkTpBhzlrUKKY8EuA=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.30 h1:jn46zC9LdsVR/ZpMIJqMqb8hHv31BlLx3ulVqNspUOk=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.30/go.mod h1:1hTMsAgbdS/AtUi4bw8+gUuh1pceo+eXRLfpSuSQj3M=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.31 h1:3GUprIsfmGcC5SACIyB0e7E0BM1O1b3Erl5CePYIAeQ=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.31/go.mod h1:7PuV1yl5e2xnUbm+RqvVg5i2iBM8EyijZNoI9wsOoOc=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.13 h1:mbRIur/BiHK6SKPjoBIXSE/hJ6g6JGRLuxQy1jGjlN4=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.13/go.mod h1:ITg9em2KbJx1s0y4aqRX5OYWG6HBZ5TVR//OdpEZ2CQ=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.22 h1:V51LGlOq/1VsDsHUdoklAQi7rMmx4qQubvFYAlP2254=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.22/go.mod h1:4Pzhyz8hJOm2bepgl+NjvRx8vlUFAIIvJnZ/MkcNPpU=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.29 h1:DRebniUGZ2MqiiIVmQJ04vIXr918hubdHMnarSLEWyU=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.29/go.mod h1:LfRkPCD8YHDM2E5eTkos2UpwYeZnBcVarTa8L59bJHA=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.30 h1:/Z5jmNrKsSD7EmDjzAPsm/3L9IuOkzaynklJZ1qX7S4=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.30/go.mod h1:lEzEZnOosE7zi8Z6royW1cFJTD9fpab4Ul1SBrllewk=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.29 h1:hiME6pBzC7OTl9LMtlyTWBuEl1f4QBcUmFDKC7MLXtc=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.29/go.mod h1:G7RP+uhagpKtKhd1BM9N6JQqjCcGEU47K5lBVZQyRQw=
github.com/aws/aws-sdk-go-v2/service/s3 v1.103.3 h1:JRseEu/vIDMaWis4bSw0QbXL+cvIGc1XnX076H5ZXLE=
github.com/aws/aws-sdk-go-v2/service/s3 v1.103.3/go.mod h1:77ZAgynvx1txMvDG8gGWoWkO1augYDxkp9JElWFgjQU=
github.com/aws/aws-sdk-go-v2/service/signin v1.1.5 h1:6Xt6Ztjkwdia/7EtEaG7ki/qZUYlCcd7tGUotQed1QE=
github.com/aws/aws-sdk-go-v2/service/signin v1.1.5/go.mod h1:LxYujSTLPRlp2vTtcUO/+1ilrew8ytt6SvQyOgejzFQ=
github.com/aws/aws-sdk-go-v2/service/sso v1.31.3 h1:ey1XLTYXb9PcLt4535632o5kCGXNXEhNb620Dqwuylo=
github.com/aws/aws-sdk-go-v2/service/sso v1.31.3/go.mod h1:Lk7PlmoTYryQmyBG0EXqj5BcUbj3whXdU2s3yGI3EAc=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.6 h1:yLr03zQE/5Eu5l3QU0Si+xMbLMbSDF2YXsigqXngs6g=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.36.6/go.mod h1:Q5N6icH+KJZDLh+ESNwzdv6cZ6vLFF/egy3IOxWhmz4=
github.com/aws/aws-sdk-go-v2/service/sts v1.43.3 h1:VrIhKRCSK1umelSgB9RghvA9RTUYeQffyAS5ApXehNI=
github.com/aws/aws-sdk-go-v2/service/sts v1.43.3/go.mod h1:r8wkDOuLaaMFqFiYAb8dGY2A3gJCOujMc6CFOVC4Zhc=
github.com/aws/smithy-go v1.27.1 h1:4T340VFndXtADGF52gYa1POyL7s9E4Z1OeZ1hCscIw8=
github.com/aws/smithy-go v1.27.1/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
github.com/aws/aws-sdk-go-v2/service/s3 v1.104.0 h1:ta8csKy5vN91F3i5gGR85lFV0srBqySEji7Jroes6rE=
github.com/aws/aws-sdk-go-v2/service/s3 v1.104.0/go.mod h1:77ZAgynvx1txMvDG8gGWoWkO1augYDxkp9JElWFgjQU=
github.com/aws/aws-sdk-go-v2/service/signin v1.4.0 h1:sLzmJGCMv+C8KqiJgEqDLB6vxaJGmobRh4rr//ZpA3w=
github.com/aws/aws-sdk-go-v2/service/signin v1.4.0/go.mod h1:mxC0nT/C8wMMS97DemZPzvUZxvIt+2Iq+eS3JdFZGgg=
github.com/aws/aws-sdk-go-v2/service/sso v1.32.0 h1:qjMmry/cBDee1E/2gyvel0uRYCi3mwRZ2hf6N+GAodo=
github.com/aws/aws-sdk-go-v2/service/sso v1.32.0/go.mod h1:u8af9Nqkmqnr96f7v9nHqzZT9XBwbXEkTiqT4ROuJSE=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.37.0 h1:fpOlDPI55HdszaxapEGk6HsGosOUaM2YPWJpjMgp8UI=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.37.0/go.mod h1:DMPWJBjYs6+3+f/qhBFEFPPlQ6NlhWjai3dJNvipJ84=
github.com/aws/aws-sdk-go-v2/service/sts v1.44.0 h1:bLZ0PolJ8J+HkJHztcXORUpHXBye2U8298lCEMi6ZCU=
github.com/aws/aws-sdk-go-v2/service/sts v1.44.0/go.mod h1:9gdl4RrflIdpDb2TlXshWgR1F9TeCkvqDx77Vpr4Z/Q=
github.com/aws/smithy-go v1.27.3 h1:F3Zb497UhhskkfpJmfkXswyo+t0sh9OTBnIHjogWbVY=
github.com/aws/smithy-go v1.27.3/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/bits-and-blooms/bitset v1.20.0/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8=
@@ -68,12 +80,18 @@ github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmC
github.com/coreos/go-systemd v0.0.0-20191104093116-d3cd4ed1dbcf h1:iW4rZ826su+pqaw19uhpSCzhj44qo35pNgKFGqzDKkU=
github.com/coreos/go-systemd v0.0.0-20191104093116-d3cd4ed1dbcf/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/cristalhq/jwt/v5 v5.4.0 h1:Wxi1TocFHaijyV608j7v7B9mPc4ZNjvWT3LKBO0d4QI=
github.com/cristalhq/jwt/v5 v5.4.0/go.mod h1:+b/BzaCWEpFDmXxspJ5h4SdJ1N/45KMjKOetWzmHvDA=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1 h1:5RVFMOWjMyRy8cARdy79nAmgYw3hK/4HUq48LQ6Wwqo=
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40=
github.com/dgraph-io/ristretto/v2 v2.4.0 h1:I/w09yLjhdcVD2QV192UJcq8dPBaAJb9pOuMyNy0XlU=
github.com/dgraph-io/ristretto/v2 v2.4.0/go.mod h1:0KsrXtXvnv0EqnzyowllbVJB8yBonswa2lTCK2gGo9E=
github.com/dgryski/go-farm v0.0.0-20240924180020-3414d57e47da h1:aIftn67I1fkbMa512G+w+Pxci9hJPB8oMnkcP3iZF38=
github.com/dgryski/go-farm v0.0.0-20240924180020-3414d57e47da/go.mod h1:SqUrOPUnsFjfmXRMNPybcSiG0BgUW2AuFH8PAnS2iTw=
github.com/dhui/dktest v0.4.6 h1:+DPKyScKSEp3VLtbMDHcUq6V5Lm5zfZZVb0Sk7Ahom4=
github.com/dhui/dktest v0.4.6/go.mod h1:JHTSYDtKkvFNFHJKqCzVzqXecyv+tKt8EzceOmQOgbU=
github.com/disintegration/gift v1.1.2/go.mod h1:Jh2i7f7Q2BM7Ezno3PhfezbR1xpUg9dUg3/RlKGr4HI=
@@ -119,14 +137,16 @@ github.com/dsoprea/go-utility v0.0.0-20221003172846-a3e1774ef349/go.mod h1:KVK+/
github.com/dsoprea/go-utility/v2 v2.0.0-20200717064901-2fccff4aa15e/go.mod h1:uAzdkPTub5Y9yQwXe8W4m2XuP0tK4a9Q/dantD0+uaU=
github.com/dunglas/go-urlpattern v0.0.0-20241020164140-716dfa1c80b1 h1:RW22Y3QjGrb97NUA8yupdFcaqg//+hMI2fZrETBvQ4s=
github.com/dunglas/go-urlpattern v0.0.0-20241020164140-716dfa1c80b1/go.mod h1:mnVcdqOeYg0HvT6veRo7wINa1mJ+lC/R4ig2lWcapSI=
github.com/dunglas/httpsfv v1.1.0 h1:Jw76nAyKWKZKFrpMMcL76y35tOpYHqQPzHQiwDvpe54=
github.com/dunglas/httpsfv v1.1.0/go.mod h1:zID2mqw9mFsnt7YC3vYQ9/cjq30q41W+1AnDwH8TiMg=
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=
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ=
github.com/emersion/go-smtp v0.24.0 h1:g6AfoF140mvW0vLNPD/LuCBLEAdlxOjIXqbIkJIS6Wk=
github.com/emersion/go-smtp v0.24.0/go.mod h1:ZtRRkbTyp2XTHCA+BmyTFTrj8xY4I+b4McvHxCU2gsQ=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/fatih/color v1.19.0 h1:Zp3PiM21/9Ld6FzSKyL5c/BULoe/ONr9KlbYVOfG8+w=
github.com/fatih/color v1.19.0/go.mod h1:zNk67I0ZUT1bEGsSGyCZYZNrHuTkJJB+r6Q9VuMi0LE=
github.com/felixge/httpsnoop v1.1.0 h1:3YtUj32ZZkqZtt3sZZsClsymw/QDuVfpNhoA31zeORc=
github.com/felixge/httpsnoop v1.1.0/go.mod h1:Zqxgdd+1Rkcz8euOqdr7lqgCRJztwr5hp9vDSi5UZCE=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho=
github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo=
github.com/fxamacker/cbor/v2 v2.9.2 h1:X4Ksno9+x3cz0TZv69ec1hxP/+tymuR8PXQJyDwfh78=
@@ -139,10 +159,6 @@ github.com/gin-contrib/sse v1.1.1 h1:uGYpNwTacv5R68bSGMapo62iLTRa9l5zxGCps4hK6ko
github.com/gin-contrib/sse v1.1.1/go.mod h1:QXzuVkA0YO7o/gun03UI1Q+FTI8ZV/n5t03kIQAI89s=
github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8=
github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc=
github.com/glebarez/go-sqlite v1.22.0 h1:uAcMJhaA6r3LHMTFgP0SifzgXg46yJkgxqyuyec+ruQ=
github.com/glebarez/go-sqlite v1.22.0/go.mod h1:PlBIdHe0+aUEFn+r2/uthrWq4FxbzugL0L8Li6yQJbc=
github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw=
github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ=
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 h1:BP4M0CvQ4S3TGls2FvczZtj5Re/2ZzkV9VwqPHH/3Bo=
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0=
github.com/go-co-op/gocron/v2 v2.21.2 h1:bD8/YwkojYHgXFr3iEulL148KBdTbKVxUZzFKpXcdbY=
@@ -152,6 +168,12 @@ github.com/go-errors/errors v1.0.2/go.mod h1:psDX2osz5VnTOnFWbDeWwS7yejl+uV3FEWE
github.com/go-errors/errors v1.1.1/go.mod h1:psDX2osz5VnTOnFWbDeWwS7yejl+uV3FEWEp4lssFEs=
github.com/go-errors/errors v1.5.1 h1:ZwEMSLRCapFLflTpT7NKaAc7ukJ8ZPEjzlxt8rPN8bk=
github.com/go-errors/errors v1.5.1/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og=
github.com/go-faster/city v1.0.1 h1:4WAxSZ3V2Ws4QRDrscLEDcibJY8uf41H6AhXDrNDcGw=
github.com/go-faster/city v1.0.1/go.mod h1:jKcUJId49qdW3L1qKHH/3wPeUstCVpVSXTM6vO3VcTw=
github.com/go-faster/errors v0.7.1 h1:MkJTnDoEdi9pDabt1dpWf7AA8/BaSYZqibYyhZ20AYg=
github.com/go-faster/errors v0.7.1/go.mod h1:5ySTjWFiphBs07IKuiL69nxdfd5+fzh1u7FPGZP2quo=
github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA=
github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
github.com/go-ldap/ldap/v3 v3.4.13 h1:+x1nG9h+MZN7h/lUi5Q3UZ0fJ1GyDQYbPvbuH38baDQ=
github.com/go-ldap/ldap/v3 v3.4.13/go.mod h1:LxsGZV6vbaK0sIvYfsv47rfh4ca0JXokCoKjZxsszv0=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
@@ -167,6 +189,9 @@ github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJn
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.30.3 h1:4MU6YkEwx7GbcPJOZxrtbu+QfF3pJLJuaYTeAH0DYy8=
github.com/go-playground/validator/v10 v10.30.3/go.mod h1:4Axh7oCNGcoGkqLoE4YWt6n20mcEIsPRlB7vPk3lpyc=
github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI=
github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo=
github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=
github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro=
github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/go-webauthn/webauthn v0.17.4 h1:KFTSz3R2RYDiUn/0cDi3XTJgFenSG74eKTTHlqWhlxk=
@@ -180,6 +205,8 @@ github.com/goccy/go-json v0.10.6 h1:p8HrPJzOakx/mn/bQtjgNjdTcN+/S6FcG2CTtQOrHVU=
github.com/goccy/go-json v0.10.6/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/gogo/googleapis v1.4.1 h1:1Yx4Myt7BxzvUr5ldGSbwYiZG6t9wGBZ+8/fX3Wvtq0=
github.com/gogo/googleapis v1.4.1/go.mod h1:2lpHqI5OcWCtVElxXnPt+s8oJvMpySlOyM6xDCrzib4=
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=
@@ -192,9 +219,12 @@ github.com/golang/geo v0.0.0-20250319145452-ed1c8b99c3d7 h1:kG/6mhO8OwbQrA/0XEPw
github.com/golang/geo v0.0.0-20250319145452-ed1c8b99c3d7/go.mod h1:J+F9/3Ofc8ysEOY2/cNjxTMl2eB1gvPIywEHUplPgDA=
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/golang/snappy v0.0.1/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.5/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=
@@ -213,24 +243,44 @@ github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17k
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/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 h1:5VipnvEpbqr2gA2VbM+nYVbkIF28c5ZQfqCBQ5g2xfk=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0/go.mod h1:Hyl3n6Twe1hvtd9XUXDec4pTvgMSEixRuQKPTMH2bNs=
github.com/h2non/filetype v1.1.3 h1:FKkx9QbD7HR/zjK1Ia5XiBsq9zdLi5Kf3zGyFTAFkGg=
github.com/h2non/filetype v1.1.3/go.mod h1:319b3zT68BvV+WRj7cwy856M2ehB3HqNOt6sy1HndBY=
github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ=
github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48=
github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k=
github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M=
github.com/hashicorp/go-retryablehttp v0.7.8 h1:ylXZWnqa7Lhqpk0L1P1LzDtGcCR0rPVUrx/c8Unxc48=
github.com/hashicorp/go-retryablehttp v0.7.8/go.mod h1:rjiScheydd+CxvumBsIrFKlx3iS0jrZ7LvzFGFmuKbw=
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/go-version v1.6.0 h1:feTTfFNnjP967rlCxM/I9g701jU+RN74YKx2mOkIeek=
github.com/hashicorp/go-version v1.6.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/italypaleale/francis v0.1.0-beta.6 h1:88XGnFMwQEMsGSJvI9vrGGvp//WKipXH8YPnfNHDStQ=
github.com/italypaleale/francis v0.1.0-beta.6/go.mod h1:3QQ1iSRGzvxJZ5gfVlVR1RyPDUAgzyN+m2vHeeR7OVs=
github.com/italypaleale/go-kit v0.0.0-20260705144331-cc6661f9a8cf h1:8tk4010URtPgVqwZb7CDiNsuQI71qc8t7VSS4IaxoTY=
github.com/italypaleale/go-kit v0.0.0-20260705144331-cc6661f9a8cf/go.mod h1:pl0r3F+thZIyDsyDo8aOUsAIVcsRuAeP1bB4GuAHLoY=
github.com/italypaleale/go-sql-utils v0.2.4 h1:6CN8y3qEdNzvYlS/JK6N65E8cL9F8a6OBCJjzaQIv3c=
github.com/italypaleale/go-sql-utils v0.2.4/go.mod h1:BJStxMfB6fzYVcOe0oZQCjGIPZQu76UBmg1Wuy6Z/7I=
github.com/jackc/pgerrcode v0.0.0-20250907135507-afb5586c32a6 h1:D/V0gu4zQ3cL2WKeVNVM4r2gLxGGf6McLwgXzRTo2RQ=
github.com/jackc/pgerrcode v0.0.0-20250907135507-afb5586c32a6/go.mod h1:a/s9Lp5W7n/DD0VrVoyJ00FbP2ytTPDVOivvn2bMlds=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.9.1 h1:uwrxJXBnx76nyISkhr33kQLlUqjv7et7b9FjCen/tdc=
github.com/jackc/pgx/v5 v5.9.1/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
github.com/jackc/pgx/v5 v5.10.0 h1:VhSvgU2jSli8o3AqIEOTJr7rZwAEUVo4E4XhR94Zfr0=
github.com/jackc/pgx/v5 v5.10.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
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/jaegertracing/jaeger-idl v0.9.0 h1:dI4olA7ArW3cjXwVbic/aYKDbdlfe7V+9wPQqAdzu8Y=
github.com/jaegertracing/jaeger-idl v0.9.0/go.mod h1:W+9vbcr2cVZyS6z/cbr540EOzSkKYml3hmaWEavxkB0=
github.com/jcmturner/aescts/v2 v2.0.0 h1:9YKLH6ey7H4eDBXW8khjYslgyqG2xZikXP0EQFKrle8=
github.com/jcmturner/aescts/v2 v2.0.0/go.mod h1:AiaICIRyfYg35RUkr8yESTqvSy7csK90qZ5xfvvsoNs=
github.com/jcmturner/dnsutils/v2 v2.0.0 h1:lltnkeZGL0wILNvrNiVCR6Ro5PGU/SeBvVO/8c/iPbo=
@@ -256,12 +306,26 @@ github.com/jonboulle/clockwork v0.5.0 h1:Hyh9A8u51kptdkR+cqRpT1EebBwTn1oK9YfGYbd
github.com/jonboulle/clockwork v0.5.0/go.mod h1:3mZlmanh0g2NDKO5TWZVJAfofYk64M7XN3SzBPjZF60=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/klauspost/compress v1.13.6/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47etQ6QUkpK6sk=
github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE=
github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/knadh/koanf/maps v0.1.2 h1:RBfmAW5CnZT+PJ1CVc1QSJKf4Xu9kxfQgYVQSu8hpbo=
github.com/knadh/koanf/maps v0.1.2/go.mod h1:npD/QZY3V6ghQDdcQzl1W4ICNVTkohC8E73eI2xW4yI=
github.com/knadh/koanf/parsers/json v0.1.0 h1:dzSZl5pf5bBcW0Acnu20Djleto19T0CfHcvZ14NJ6fU=
github.com/knadh/koanf/parsers/json v0.1.0/go.mod h1:ll2/MlXcZ2BfXD6YJcjVFzhG9P0TdJ207aIBKQhV2hY=
github.com/knadh/koanf/providers/rawbytes v0.1.0 h1:dpzgu2KO6uf6oCb4aP05KDmKmAmI51k5pe8RYKQ0qME=
github.com/knadh/koanf/providers/rawbytes v0.1.0/go.mod h1:mMTB1/IcJ/yE++A2iEZbY1MLygX7vttU+C+S/YmPu9c=
github.com/knadh/koanf/v2 v2.3.2 h1:Ee6tuzQYFwcZXQpc2MiVeC6qHMandf5SMUJJNoFp/c4=
github.com/knadh/koanf/v2 v2.3.2/go.mod h1:gRb40VRAbd4iJMYYD5IxZ6hfuopFcXBpc9bbQpZwo28=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
@@ -284,23 +348,36 @@ github.com/lestrrat-go/option/v2 v2.0.0 h1:XxrcaJESE1fokHy3FpaQ/cXW8ZsIdWcdFzzLO
github.com/lestrrat-go/option/v2 v2.0.0/go.mod h1:oSySsmzMoR0iRzCDCaUfsCzxQHUEuhOViQObyy7S6Vg=
github.com/lib/pq v1.12.3 h1:tTWxr2YLKwIvK90ZXEw8GP7UFHtcbTtty8zsI+YjrfQ=
github.com/lib/pq v1.12.3/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA=
github.com/libtnb/sqlite v1.1.2 h1:3g8nez8MwdEhn+oqt3bfKWnYJxk0ZGTO0kVA1WT+hjQ=
github.com/libtnb/sqlite v1.1.2/go.mod h1:2PGCWOR6HqqXuoIVMmwfJ655epvRO8sw0b9kHWuKNsQ=
github.com/lmittmann/tint v1.1.3 h1:Hv4EaHWXQr+GTFnOU4VKf8UvAtZgn0VuKT+G0wFlO3I=
github.com/lmittmann/tint v1.1.3/go.mod h1:HIS3gSy7qNwGCj+5oRjAutErFBl4BzdQP6cJZ0NfMwE=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4=
github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
github.com/mattn/go-sqlite3 v1.14.42 h1:MigqEP4ZmHw3aIdIT7T+9TLa90Z6smwcthx+Azv4Cgo=
github.com/mattn/go-sqlite3 v1.14.42/go.mod h1:pjEuOr8IwzLJP2MfGeTb0A35jauH+C2kbHKBr7yXKVQ=
github.com/mattn/goveralls v0.0.12 h1:PEEeF0k1SsTjOBQ8FOmrOAoCu4ytuMaWCnWe94zxbCg=
github.com/mattn/goveralls v0.0.12/go.mod h1:44ImGEUfmqH8bBtaMrYKsM65LXfNLWmwaxFGjZwgMSQ=
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/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw=
github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s=
github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ=
github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw=
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0=
github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y=
github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ=
github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 h1:RWengNIwukTxcDr9M+97sNutRR1RKhG96O6jWumTTnw=
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826/go.mod h1:TaXosZuwdSHYgviHp1DAtfrULt5eUgsSMsZf+YrPgl8=
github.com/montanaflynn/stats v0.0.0-20171201202039-1bf9dbcd8cbe/go.mod h1:wL8QJuTMNUDYhXwkmfOly8iTdp5TEcJFWZD2D7SIkUc=
github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
@@ -309,37 +386,66 @@ github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOF
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/nlnwa/whatwg-url v0.6.2 h1:jU61lU2ig4LANydbEJmA2nPrtCGiKdtgT0rmMd2VZ/Q=
github.com/nlnwa/whatwg-url v0.6.2/go.mod h1:x0FPXJzzOEieQtsBT/AKvbiBbQ46YlL6Xa7m02M1ECk=
github.com/nyaruka/phonenumbers v1.5.0 h1:0M+Gd9zl53QC4Nl5z1Yj1O/zPk2XXBUwR/vlzdXSJv4=
github.com/nyaruka/phonenumbers v1.5.0/go.mod h1:gv+CtldaFz+G3vHHnasBSirAi3O2XLqZzVWz4V1pl2E=
github.com/oleiade/reflections v1.1.0 h1:D+I/UsXQB4esMathlt0kkZRJZdUDmhv5zGi/HOwYTWo=
github.com/oleiade/reflections v1.1.0/go.mod h1:mCxx0QseeVCHs5Um5HhJeCKVC7AwS8kO67tky4rdisA=
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/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=
github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M=
github.com/openzipkin/zipkin-go v0.4.3 h1:9EGwpqkgnwdEIJ+Od7QVSEIH+ocmm5nPat0G7sjsSdg=
github.com/openzipkin/zipkin-go v0.4.3/go.mod h1:M9wCJZFWCo2RiY+o1eBCEMe0Dp2S5LDHcMZmk3RmK7c=
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/ory/go-acc v0.2.9-0.20230103102148-6b1c9a70dbbe h1:rvu4obdvqR0fkSIJ8IfgzKOWwZ5kOT2UNfLq81Qk7rc=
github.com/ory/go-acc v0.2.9-0.20230103102148-6b1c9a70dbbe/go.mod h1:z4n3u6as84LbV4YmgjHhnwtccQqzf4cZlSk9f1FhygI=
github.com/ory/go-convenience v0.1.0 h1:zouLKfF2GoSGnJwGq+PE/nJAE6dj2Zj5QlTgmMTsTS8=
github.com/ory/go-convenience v0.1.0/go.mod h1:uEY/a60PL5c12nYz4V5cHY03IBmwIAEm8TWB0yn9KNs=
github.com/ory/herodot v0.10.3-0.20250318104651-3179543efba8 h1:bBFBzJ+sy1l/9+uYaz5TLGNNe0GWeXPMyqLhUEy9gPg=
github.com/ory/herodot v0.10.3-0.20250318104651-3179543efba8/go.mod h1:aq2fDNzFXlh8wF6+ILtlEin2oZSrqR79/Zdsi05WEVA=
github.com/ory/jsonschema/v3 v3.0.9-0.20250317235931-280c5fc7bf0e h1:4tUrC7x4YWRVMFp+c64KACNSGchW1zXo4l6Pa9/1hA8=
github.com/ory/jsonschema/v3 v3.0.9-0.20250317235931-280c5fc7bf0e/go.mod h1:XWLxVK4un/iuIcrw+6lCeanbF3NZwO5k6RdLeu/loQk=
github.com/ory/pop/v6 v6.4.1 h1:mxwfgwIB+kRlE4hvcoeEuxFqXZai6TWgQ23sOCBTERo=
github.com/ory/pop/v6 v6.4.1/go.mod h1:o+a3+gdnfWUd/IpFCTKidX7sRgQ6GdPmH02XYiMH8cw=
github.com/ory/x v0.0.729 h1:7ttCYNCjCdspI6X0oaxGAXoiYWSBrwGRz6w/IG8s3I4=
github.com/ory/x v0.0.729/go.mod h1:qdUK3Sp4K4nRbYJG0sEnFO1tDLN/Ct53G+ymre0JhCU=
github.com/oschwald/maxminddb-golang/v2 v2.4.0 h1:3ftnrR1/XwiQ788bWIRhsE1DK3GOgJ6tm6S2qTktLm8=
github.com/oschwald/maxminddb-golang/v2 v2.4.0/go.mod h1:7jcFtmhWVDEV+UopVv9NjcPm200uMyEHN14LIVV4hW8=
github.com/paulmach/orb v0.11.1 h1:3koVegMC4X/WeiXYz9iswopaTwMem53NzTJuTF20JzU=
github.com/paulmach/orb v0.11.1/go.mod h1:5mULz1xQfs3bmQm63QEJA6lNGujuRafwA5S/EnuLaLU=
github.com/paulmach/protoscan v0.2.1/go.mod h1:SpcSwydNLrxUGSDvXvO0P7g7AuhJ7lcKfDlhJCDw2gY=
github.com/pelletier/go-toml/v2 v2.3.1 h1:MYEvvGnQjeNkRF1qUuGolNtNExTDwct51yp7olPtrEc=
github.com/pelletier/go-toml/v2 v2.3.1/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM=
github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM=
github.com/pierrec/lz4/v4 v4.1.21 h1:yOVMLb6qSIDP67pl/5F7RepeKYu/VmTyEXvuMI5d9mQ=
github.com/pierrec/lz4/v4 v4.1.21/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
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/pocket-id/fosite v0.0.0-20260708083902-56a3c0f378d6 h1:gD1LKfqFkbIHkE6d308IFJkZbHw4VwL7buxEnblNQTs=
github.com/pocket-id/fosite v0.0.0-20260708083902-56a3c0f378d6/go.mod h1:KeQ7tTIBm3DyeBnKcKLnPbSdrd6ttM6w3TD3yy9x8rM=
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.69.0 h1:OA85nJQS/T/MaYh/Q2CcgDKSGWqNIgrBDvDH85CuiNk=
github.com/prometheus/common v0.69.0/go.mod h1:ZzL3f6u94qUxh9p+tJTrF+FvBS1XXbbRAZCQkytAL0Y=
github.com/prometheus/otlptranslator v1.0.0 h1:s0LJW/iN9dkIH+EnhiD3BlkkP5QVIUVEoIwkU+A6qos=
github.com/prometheus/otlptranslator v1.0.0/go.mod h1:vRYWnXvI6aWGpsdY/mOT/cbeVRBlPWtBNDb7kGR3uKM=
github.com/prometheus/procfs v0.20.1 h1:XwbrGOIplXW/AU3YhIhLODXMJYyC1isLFfYCsTEycfc=
github.com/prometheus/procfs v0.20.1/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo=
github.com/prometheus/procfs v0.21.1 h1:GljZCt+zSTS+NZq88cyQ1LjZ+RCHp3uVuabBWA5+OJI=
github.com/prometheus/procfs v0.21.1/go.mod h1:aB55Cww9pdSJVHk0hUf0inxWyyjPogFIjmHKYgMKmtY=
github.com/quic-go/go-ossfuzz-seeds v0.1.0 h1:APacT+iIaNF6fd8AGEiN3bT/Jtkd2jz4v4TzM7MFjy0=
github.com/quic-go/go-ossfuzz-seeds v0.1.0/go.mod h1:3IOHRbJIc+L6YKMwfDtJAM9Vj9k0YY4muhuyUYk5tbk=
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.1 h1:0Gmua0HW1Tv7ANR7hUYwRyD0MG5OJfgvYSZasGZzBic=
github.com/quic-go/quic-go v0.59.1/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
github.com/quic-go/quic-go v0.60.0 h1:xcQioE8OM66UQLeUMHltK1CCcOu3JbVB4JAQdDQSB+0=
github.com/quic-go/quic-go v0.60.0/go.mod h1:wpKpjmPpftl30sL6pFh7REVpjbcCVy4zt2vDyK1TuJk=
github.com/quic-go/webtransport-go v0.10.0 h1:LqXXPOXuETY5Xe8ITdGisBzTYmUOy5eSj+9n4hLTjHI=
github.com/quic-go/webtransport-go v0.10.0/go.mod h1:LeGIXr5BQKE3UsynwVBeQrU1TPrbh73MGoC6jd+V7ow=
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=
@@ -347,18 +453,35 @@ github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzG
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/sagikazarmark/locafero v0.12.0 h1:/NQhBAkUb4+fH1jivKHWusDYFjMOOKU88eegjfxfHb4=
github.com/sagikazarmark/locafero v0.12.0/go.mod h1:sZh36u/YSZ918v0Io+U9ogLYQJ9tLLBmM4eneO6WwsI=
github.com/seatgeek/logrus-gelf-formatter v0.0.0-20210414080842-5b05eb8ff761 h1:0b8DF5kR0PhRoRXDiEEdzrgBc8UqVY4JWLkQJCRsLME=
github.com/seatgeek/logrus-gelf-formatter v0.0.0-20210414080842-5b05eb8ff761/go.mod h1:/THDZYi7F/BsVEcYzYPqdcWFQ+1C2InkawTKfLOAnzg=
github.com/segmentio/asm v1.2.1 h1:DTNbBqs57ioxAD4PrArqftgypG4/qNpXoJx8TVXxPR0=
github.com/segmentio/asm v1.2.1/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs=
github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k=
github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME=
github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w=
github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g=
github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=
github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
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=
github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU=
github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
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/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4=
github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0=
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=
@@ -366,19 +489,45 @@ github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXl
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
github.com/tidwall/gjson v1.19.0 h1:xwxm7n691Uf3u5OFjzngavjGTh55KX5q/9w9xHW88JU=
github.com/tidwall/gjson v1.19.0/go.mod h1:V37/opeE/JbLUOfH0QTXiNez2l0RUjYUhpT4szFQAfc=
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
github.com/tidwall/pretty v1.0.0/go.mod h1:XNkn88O1ChpSDQmQeStsy+sBenx6DDtFZJxhVysOjyk=
github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
github.com/tinylib/msgp v1.6.4 h1:mOwYbyYDLPj35mkA2BjjYejgJk9BuHxDdvRnb6v2ZcQ=
github.com/tinylib/msgp v1.6.4/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
github.com/urfave/negroni v1.0.0 h1:kIimOitoypq34K7TG7DUaJ9kq/N4Ofuwi1sjz0KipXc=
github.com/urfave/negroni v1.0.0/go.mod h1:Meg73S6kFm/4PpbYdq35yYWoCZ9mS/YSx+lKnmiohz4=
github.com/valyala/fastjson v1.6.10 h1:/yjJg8jaVQdYR3arGxPE2X5z89xrlhS0eGXdv+ADTh4=
github.com/valyala/fastjson v1.6.10/go.mod h1:e6FubmQouUNP73jtMLmcbxS6ydWIpOfhz34TSfO3JaE=
github.com/vmihailenco/msgpack/v5 v5.4.1 h1:cQriyiUvjTwOHg8QZaPihLWeRAAVoCpE00IUPn0Bjt8=
github.com/vmihailenco/msgpack/v5 v5.4.1/go.mod h1:GaZTsDaehaPpQVyxrf5mtQlH+pc21PIudVV/E3rRQok=
github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g=
github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds=
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/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI=
github.com/xdg-go/scram v1.1.1/go.mod h1:RaEWvsqvNKKvBPvcKeFjrG2cJqOkHTiyTpzz23ni57g=
github.com/xdg-go/stringprep v1.0.3/go.mod h1:W3f5j4i+9rC0kuIEJL0ky1VpHXQU3ocBgklLGvcBnW8=
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
github.com/youmark/pkcs8 v0.0.0-20181117223130-1be2e3e5546d/go.mod h1:rHwXgn7JulP+udvsHwJoVG1YGAP6VLg4y9I5dyZdqmA=
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/zitadel/exifremove v0.1.0 h1:qD50ezWsfeeqfcvs79QyyjVfK+snN12v0U0deaU8aKg=
github.com/zitadel/exifremove v0.1.0/go.mod h1:rzKJ3woL/Rz2KthVBiSBKIBptNTvgmk9PLaeUKTm+ek=
go.mongodb.org/mongo-driver v1.11.4/go.mod h1:PTSz5yu21bkT/wXpkS7WR5f0ddqw5quethTUn9WM+2g=
go.mongodb.org/mongo-driver/v2 v2.6.0 h1:b9sJOYrkmt4l8bY43ZenFBcPlhYIjaOfYHLtbB/5qi8=
go.mongodb.org/mongo-driver/v2 v2.6.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
@@ -391,12 +540,20 @@ go.opentelemetry.io/contrib/exporters/autoexport v0.69.0 h1:R3jsCoTIzv0BiYNhW0ax
go.opentelemetry.io/contrib/exporters/autoexport v0.69.0/go.mod h1:m07gqyr2QhQxKOKb5vqKCCBtLH3uqlNYR7PU/FISXVU=
go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin v0.69.0 h1:u5gsfBL8t1Km4ROhQKAs0cA0t9CzUE7nfkASj/UjAtI=
go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin v0.69.0/go.mod h1:W6FFYCZQuntC5hxVesXpu7Ppd9sT0a84njildAijc+k=
go.opentelemetry.io/contrib/instrumentation/net/http/httptrace/otelhttptrace v0.69.0 h1:MCcYL7J6Vt/X0kjqbMZkekCmwsurbQRbL69vkiye2lk=
go.opentelemetry.io/contrib/instrumentation/net/http/httptrace/otelhttptrace v0.69.0/go.mod h1:3jnStNwSufK+f5ktjL4EPcwtig4rtd81NS70lqHuXl8=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 h1:8tvICD4vSTOOsNrsI4Ljf6C+6UKvpTEH5XY3JMoyPoo=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0/go.mod h1:z9+yiacE0IHRqM4qFfkbt/JYlmYXgss8GY/jXoNuPJI=
go.opentelemetry.io/contrib/propagators/b3 v1.44.0 h1:1IFH4oFKK8KupzIelCl3u+bkxpGRps1oWRjQI2+TTWs=
go.opentelemetry.io/contrib/propagators/b3 v1.44.0/go.mod h1:JqWFXsc7VDaqIyubFhEd2cPHqsrzqP0Lvn783SUwyro=
go.opentelemetry.io/contrib/propagators/jaeger v1.44.0 h1:OyzvsAMc/zHt0DRPcfstn0wgfq8ApDkeY0ABMcueweM=
go.opentelemetry.io/contrib/propagators/jaeger v1.44.0/go.mod h1:44kghcGX+BNxy9UTiWtd6VDt8Nd4EypGBkH2+v2Dqrc=
go.opentelemetry.io/contrib/samplers/jaegerremote v0.37.1 h1:pV2nZ1iE87X9ym+crkD9k15zTLbN08+IC4C7sk9sQYM=
go.opentelemetry.io/contrib/samplers/jaegerremote v0.37.1/go.mod h1:nvgrM8LaG2+5G7WxbtjEPiSkg87+d0/ltZZK70p7FVo=
go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU=
go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc=
go.opentelemetry.io/otel/exporters/jaeger v1.17.0 h1:D7UpUy2Xc2wsi1Ras6V40q806WM07rqoCWzXu7Sqy+4=
go.opentelemetry.io/otel/exporters/jaeger v1.17.0/go.mod h1:nPCqOnEH9rNLKqH/+rrUjiMzHJdV1BlpKcTwRTyKkKI=
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.20.0 h1:rydZ9sxbcFdm/oWrVyfLTjHIygMgv0bEeMd+3B/BvoM=
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.20.0/go.mod h1:earQ25dooT0Hhspq59DZ8YCC50jWfOlFEeWoxy/P444=
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.20.0 h1:owlhcJ3QO3X0YTDTCcDZ4V+6aVDkWbNmBoQ5NUp7Oww=
@@ -419,6 +576,8 @@ go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.44.0 h1:hqxVTu/GtBF+vJ
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.44.0/go.mod h1:z5fVEF4X5v0ESvlJqBrrFlBVoj5EQuefZpzsu7R+x5Q=
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.44.0 h1:bl2S7Ubua0Nms+D/gAmznQTd4dxxMA93aKbcpKqiTCs=
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.44.0/go.mod h1:L0hRV50XdVIODHUfWEqGRCXQvj2rV82STVo12FMFBU0=
go.opentelemetry.io/otel/exporters/zipkin v1.44.0 h1:zv7PRYGLrQHkdeZj0c5SNAZOJcw55XgaTezUkNpwA+w=
go.opentelemetry.io/otel/exporters/zipkin v1.44.0/go.mod h1:3+VZyCi6hFW+UuxFF+wSOvwsOwncfBpQfP7Qdb3JXKg=
go.opentelemetry.io/otel/log v0.20.0 h1:/5i0vuHxCLWUfChWG41K9wkM0jafruPw9NU1/RCJirs=
go.opentelemetry.io/otel/log v0.20.0/go.mod h1:wOcMcjsZpG8x7Bak7IhSi/lg8wscV2C1VdrKCLPlt0E=
go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc=
@@ -443,51 +602,66 @@ go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ=
go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ=
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/arch v0.27.0 h1:0WNVcR8u9yFz8j5FvdHpgwNp3FS5U4guYdzHwEiGjoU=
golang.org/x/arch v0.27.0/go.mod h1:0X+GdSIP+kL5wPmpK7sdkEVTt2XoYP0cSjQSbZBwOi8=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
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.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc=
golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988=
golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc=
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f h1:W3F4c+6OLc6H2lb//N1q4WpJkhzJCK5J6kUi1NTVXfM=
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f/go.mod h1:J1xhfL/vlindoeF/aINzNzt2Bket5bjo9sdOYzOsU80=
golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
golang.org/x/exp v0.0.0-20260611194520-c48552f49976 h1:X8Hz2ImujgbmetVuW+w2YkyZChE3cBpZi2P158rTG9M=
golang.org/x/exp v0.0.0-20260611194520-c48552f49976/go.mod h1:vnf4pv9iKZXY58sQE1L86zmNWJ4159e1RkcWiLCkeEY=
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/image v0.42.0 h1:1gSs6ehNWXLbkHBIPcWztk3D/6aIA/8hauiAYtlodVY=
golang.org/x/image v0.42.0/go.mod h1:rrpelvGFt+kLPAjPM4HeWPgrl0FtafueU//e5N0qk/Q=
golang.org/x/image v0.43.0 h1:FLxcP4ec2350nTfOC8ysKtqYSIFbk/QGjw1ZHNP4tsY=
golang.org/x/image v0.43.0/go.mod h1:rrpelvGFt+kLPAjPM4HeWPgrl0FtafueU//e5N0qk/Q=
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
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.10.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4=
golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ=
golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20191209160850-c0dbc17a3553/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200320220750-118fecf932d8/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200501053045-e0ff5e5a1de5/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20200513185701-a91f0712d120/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20200520182314-0ba52f642ac2/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
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.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k=
golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
@@ -497,23 +671,28 @@ golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/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.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.7.0/go.mod h1:P32HKFT3hSsZrRxla30E9HqToFYAQPCMs/zFMBUFqPY=
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
@@ -522,6 +701,7 @@ golang.org/x/term v0.28.0/go.mod h1:Sw/lC2IAUZ92udQNf3WodGtn4k/XoLyZoh8v/8uiwek=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/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=
@@ -535,26 +715,34 @@ golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
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.8.0/go.mod h1:JxBZ99ISMI5ViVkT1tr6tdNmXeTrcpVSD3vZ1RsRdN4=
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8=
golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0=
golang.org/x/tools v0.46.0 h1:7jTurBkPZu4moS/Uy4OQT1M+QBlsj3wejyZwsT8Z7rk=
golang.org/x/tools v0.46.0/go.mod h1:FrD85F8l+NWL+9XWBSyVSHO6Ne4jutsfIFba7AWQ5Ys=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa h1:Kjn0N0tCrDgiAFW+lGO4JZ3ck44CehvJQMAwj9QF0G8=
google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:q4lMZS6kskjT5HvCPrnnypcDPVJqT/f4nfxmkE7gryY=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa h1:mZHHdPZl0dbGHCflZgAq/Q468DWVFcU2whhB2KAo8fk=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/grpc v1.81.1 h1:VnnIIZ88UzOOKLukQi+ImGz8O1Wdp8nAGGnvOfEIWQQ=
google.golang.org/grpc v1.81.1/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I=
google.golang.org/genproto/googleapis/api v0.0.0-20260630182238-925bb5da69e7 h1:jQ9p21COKWjP3VwuFrNRiiOTMh3mPpN45R7SLrH/HUU=
google.golang.org/genproto/googleapis/api v0.0.0-20260630182238-925bb5da69e7/go.mod h1:KqHwBx2upmfa1XSi1WuRvC+2VGCLtooKkfmyvRbUmqA=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260630182238-925bb5da69e7 h1:eM/YSd5bBFagF51o1E745Ta7RwzpW0h+z+QDNZOgmQ8=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260630182238-925bb5da69e7/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/grpc v1.82.0 h1:vguDnZUPjE26w09A63VoxZPnvPjB5Riyc0mkXPFmAIU=
google.golang.org/grpc v1.82.0/go.mod h1:yzTZ1TB1Z3SG+LIYaI+WiE8D5+PZ3ArnrSp8zF3+/ZA=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.27.1/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v2 v2.2.7/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
@@ -564,34 +752,45 @@ gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gorm.io/driver/clickhouse v0.7.0 h1:BCrqvgONayvZRgtuA6hdya+eAW5P2QVagV3OlEp1vtA=
gorm.io/driver/clickhouse v0.7.0/go.mod h1:TmNo0wcVTsD4BBObiRnCahUgHJHjBIwuRejHwYt3JRs=
gorm.io/driver/mysql v1.5.7 h1:MndhOPYOfEp2rHKgkZIhJ16eVUIRf2HmzgoPmh7FCWo=
gorm.io/driver/mysql v1.5.7/go.mod h1:sEtPWMiqiN1N1cMXoXmBbd8C6/l+TESwriotuRRpkDM=
gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4=
gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo=
gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ=
gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8=
gorm.io/gorm v1.25.7/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8=
gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg=
gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
modernc.org/cc/v4 v4.27.3 h1:uNCgn37E5U09mTv1XgskEVUJ8ADKpmFMPxzGJ0TSo+U=
modernc.org/cc/v4 v4.27.3/go.mod h1:3YjcbCqhoTTHPycJDRl2WZKKFj0nwcOIPBfEZK0Hdk8=
modernc.org/ccgo/v4 v4.32.4 h1:L5OB8rpEX4ZsXEQwGozRfJyJSFHbbNVOoQ59DU9/KuU=
modernc.org/ccgo/v4 v4.32.4/go.mod h1:lY7f+fiTDHfcv6YlRgSkxYfhs+UvOEEzj49jAn2TOx0=
gorm.io/plugin/opentelemetry v0.1.16 h1:Kypj2YYAliJqkIczDZDde6P6sFMhKSlG5IpngMFQGpc=
gorm.io/plugin/opentelemetry v0.1.16/go.mod h1:P3RmTeZXT+9n0F1ccUqR5uuTvEXDxF8k2UpO7mTIB2Y=
k8s.io/utils v0.0.0-20260617174310-a95e086a2553 h1:hmGqDecjc8d7HVzWzRFl0QD9bYuYKbBEG7t8xwnVxfI=
k8s.io/utils v0.0.0-20260617174310-a95e086a2553/go.mod h1:xDxuJ0whA3d0I4mf/C4ppKHxXynQ+fxnkmQH0vTHnuk=
modernc.org/cc/v4 v4.29.0 h1:CXgwL8cvxmyzBQZzbSl/6xFtMCryb6u8IOqDci39cgc=
modernc.org/cc/v4 v4.29.0/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
modernc.org/ccgo/v4 v4.34.5 h1:hcwnthv2/LBl+mRLOYwnQA/LuW44Oln1NQlWppNaS1Q=
modernc.org/ccgo/v4 v4.34.5/go.mod h1:aow0HNkO30OSA/2NrtDXkis92ff8ZFiDOmDOPhqhF8U=
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
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.4 h1:2g65LGVSmFQrXeITAw97x7hCRvZFcyE1uDP+7Vng7JI=
modernc.org/gc/v3 v3.1.4/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.71.0 h1:bu0djXJGhqed3DnBzyzu3sY0fv432lesyz99ecEahyA=
modernc.org/libc v1.71.0/go.mod h1:tTU8DL8A+XLVkEY3x5E/tO7s2Q/q42EtnNWda/L5QhQ=
modernc.org/libc v1.73.5 h1:G34rN/cRqL+zOUnrbz9uPq/+OxJ8/vzQ2CQwTJ42Wmw=
modernc.org/libc v1.73.5/go.mod h1:+Aoyx4M0etg6GikzCrip1VtvAtUlMlo2Aq+GHwQSqOA=
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=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.48.2 h1:5CnW4uP8joZtA0LedVqLbZV5GD7F/0x91AXeSyjoh5c=
modernc.org/sqlite v1.48.2/go.mod h1:hWjRO6Tj/5Ik8ieqxQybiEOUXy0NJFNp2tpvVpKlvig=
modernc.org/sqlite v1.53.0 h1:20WG8N9q4ji/dEqGk4uiI0c6OPjSeLTNYGFCc3+7c1M=
modernc.org/sqlite v1.53.0/go.mod h1:xoEpOIpGrgT48H5iiyt/YXPCZPEzlfmfFwtk8Lklw8s=
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

@@ -0,0 +1,57 @@
package api
import (
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
)
// apiResponseDto is the full representation of an API including its permissions
type apiResponseDto struct {
ID string `json:"id"`
Name string `json:"name"`
Resource string `json:"resource"`
CreatedAt datatype.DateTime `json:"createdAt"`
Permissions []apiPermissionResponseDto `json:"permissions"`
}
type apiPermissionResponseDto struct {
ID string `json:"id"`
Key string `json:"key"`
Name string `json:"name"`
Description *string `json:"description,omitempty"`
}
// apiCreateDto is the payload for creating an API
// The resource identifier is only accepted here because changing it later would invalidate every token already minted for the API
type apiCreateDto struct {
Name string `json:"name" binding:"required,min=1,max=50" unorm:"nfc"`
Resource string `json:"resource" binding:"required,resource_uri,max=350" unorm:"nfc"`
}
// apiUpdateDto is the payload for updating an API
// The resource identifier is intentionally not updatable
type apiUpdateDto struct {
Name string `json:"name" binding:"required,min=1,max=50" unorm:"nfc"`
}
type apiPermissionInputDto struct {
Key string `json:"key" binding:"required,min=1,max=128" unorm:"nfc"`
Name string `json:"name" binding:"required,min=1,max=50" unorm:"nfc"`
Description *string `json:"description" binding:"omitempty,max=200"`
}
// apiPermissionsUpdateDto replaces the full permission set of an API
type apiPermissionsUpdateDto struct {
Permissions []apiPermissionInputDto `json:"permissions" binding:"omitempty,dive"`
}
// clientApiAccessDto is the set of API permissions a client is allowed to request, split by subject type
// User-delegated permissions may be requested on behalf of a signed-in user, client permissions may be obtained by the client itself through the client credentials grant
type clientApiAccessDto struct {
UserDelegatedPermissionIDs []string `json:"userDelegatedPermissionIds"`
ClientPermissionIDs []string `json:"clientPermissionIds"`
}
type clientApiAccessUpdateDto struct {
UserDelegatedPermissionIDs []string `json:"userDelegatedPermissionIds" binding:"omitempty,dive,required"`
ClientPermissionIDs []string `json:"clientPermissionIds" binding:"omitempty,dive,required"`
}

View File

@@ -0,0 +1,235 @@
package api
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/utils"
)
type handler struct {
service *Service
}
func newHandler(service *Service) *handler {
return &handler{service: service}
}
// list godoc
// @Summary List APIs
// @Description Get a paginated list of APIs with optional search and sorting
// @Tags APIs
// @Produce json
// @Param search query string false "Search term to filter APIs by name or resource"
// @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[apiResponseDto]
// @Router /api/apis [get]
func (h *handler) list(c *gin.Context) {
search := c.Query("search")
listRequestOptions := utils.ParseListRequestOptions(c)
apis, pagination, err := h.service.List(c.Request.Context(), search, listRequestOptions)
if err != nil {
_ = c.Error(err)
return
}
items := make([]apiResponseDto, len(apis))
for i, api := range apis {
var item apiResponseDto
if err := dto.MapStruct(api, &item); err != nil {
_ = c.Error(err)
return
}
item.Resource = api.Audience
items[i] = item
}
c.JSON(http.StatusOK, dto.Paginated[apiResponseDto]{
Data: items,
Pagination: pagination,
})
}
// get godoc
// @Summary Get API by ID
// @Description Retrieve a single API including its permissions
// @Tags APIs
// @Produce json
// @Param id path string true "API ID"
// @Success 200 {object} apiResponseDto
// @Router /api/apis/{id} [get]
func (h *handler) get(c *gin.Context) {
api, err := h.service.Get(c.Request.Context(), nil, c.Param("id"))
if err != nil {
_ = c.Error(err)
return
}
h.respond(c, http.StatusOK, api)
}
// create godoc
// @Summary Create API
// @Description Create a new API resource server
// @Tags APIs
// @Accept json
// @Produce json
// @Param api body apiCreateDto true "API information"
// @Success 201 {object} apiResponseDto "Created API"
// @Router /api/apis [post]
func (h *handler) create(c *gin.Context) {
var input apiCreateDto
if err := dto.ShouldBindWithNormalizedJSON(c, &input); err != nil {
_ = c.Error(err)
return
}
api, err := h.service.Create(c.Request.Context(), input)
if err != nil {
_ = c.Error(err)
return
}
h.respond(c, http.StatusCreated, api)
}
// update godoc
// @Summary Update API
// @Description Update an existing API by ID
// @Tags APIs
// @Accept json
// @Produce json
// @Param id path string true "API ID"
// @Param api body apiUpdateDto true "API information"
// @Success 200 {object} apiResponseDto "Updated API"
// @Router /api/apis/{id} [put]
func (h *handler) update(c *gin.Context) {
var input apiUpdateDto
if err := dto.ShouldBindWithNormalizedJSON(c, &input); err != nil {
_ = c.Error(err)
return
}
api, err := h.service.Update(c.Request.Context(), c.Param("id"), input)
if err != nil {
_ = c.Error(err)
return
}
h.respond(c, http.StatusOK, api)
}
// delete godoc
// @Summary Delete API
// @Description Delete an API by ID
// @Tags APIs
// @Param id path string true "API ID"
// @Success 204 "No Content"
// @Router /api/apis/{id} [delete]
func (h *handler) delete(c *gin.Context) {
if err := h.service.Delete(c.Request.Context(), c.Param("id")); err != nil {
_ = c.Error(err)
return
}
c.Status(http.StatusNoContent)
}
// updatePermissions godoc
// @Summary Update API permissions
// @Description Replace the full set of permissions for an API
// @Tags APIs
// @Accept json
// @Produce json
// @Param id path string true "API ID"
// @Param permissions body apiPermissionsUpdateDto true "Permissions to set"
// @Success 200 {object} apiResponseDto "Updated API"
// @Router /api/apis/{id}/permissions [put]
func (h *handler) updatePermissions(c *gin.Context) {
var input apiPermissionsUpdateDto
if err := dto.ShouldBindWithNormalizedJSON(c, &input); err != nil {
_ = c.Error(err)
return
}
api, err := h.service.UpdatePermissions(c.Request.Context(), c.Param("id"), input)
if err != nil {
_ = c.Error(err)
return
}
h.respond(c, http.StatusOK, api)
}
// getClientAccess godoc
// @Summary Get client API access
// @Description Get the API permissions an OIDC client is allowed to request, split into user-delegated and client (machine-to-machine) access
// @Tags APIs
// @Produce json
// @Param clientId path string true "OIDC Client ID"
// @Success 200 {object} clientApiAccessDto
// @Router /api/api-access/{clientId} [get]
func (h *handler) getClientAccess(c *gin.Context) {
access, err := h.service.GetClientAPIAccess(c.Request.Context(), c.Param("clientId"))
if err != nil {
_ = c.Error(err)
return
}
c.JSON(http.StatusOK, newClientApiAccessDto(access))
}
// updateClientAccess godoc
// @Summary Update client API access
// @Description Replace the API permissions an OIDC client is allowed to request, split into user-delegated and client (machine-to-machine) access
// @Tags APIs
// @Accept json
// @Produce json
// @Param clientId path string true "OIDC Client ID"
// @Param access body clientApiAccessUpdateDto true "Allowed permission IDs per subject type"
// @Success 200 {object} clientApiAccessDto
// @Router /api/api-access/{clientId} [put]
func (h *handler) updateClientAccess(c *gin.Context) {
var input clientApiAccessUpdateDto
err := c.ShouldBindJSON(&input)
if err != nil {
_ = c.Error(err)
return
}
applied, err := h.service.SetClientAPIAccess(c.Request.Context(), c.Param("clientId"), ClientAPIAccess(input))
if err != nil {
_ = c.Error(err)
return
}
c.JSON(http.StatusOK, newClientApiAccessDto(applied))
}
// newClientApiAccessDto always serializes both permission lists as arrays rather than null
func newClientApiAccessDto(access ClientAPIAccess) clientApiAccessDto {
dto := clientApiAccessDto(access)
if dto.UserDelegatedPermissionIDs == nil {
dto.UserDelegatedPermissionIDs = []string{}
}
if dto.ClientPermissionIDs == nil {
dto.ClientPermissionIDs = []string{}
}
return dto
}
func (h *handler) respond(c *gin.Context, status int, api API) {
var responseDto apiResponseDto
if err := dto.MapStruct(api, &responseDto); err != nil {
_ = c.Error(err)
return
}
responseDto.Resource = api.Audience
c.JSON(status, responseDto)
}

View File

@@ -0,0 +1,38 @@
package api
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/oidc"
)
type API struct {
model.Base
Name string `sortable:"true"`
Audience string `sortable:"true"`
UpdatedAt *datatype.DateTime
Permissions []Permission `gorm:"foreignKey:APIID;references:ID;constraint:OnDelete:CASCADE"`
}
type Permission struct {
model.Base
APIID string `gorm:"column:api_id"`
Key string `sortable:"true"`
Name string
Description *string
}
func (Permission) TableName() string { return "api_permissions" }
type OidcClientAllowedAPIPermission struct {
OidcClientID string
APIPermissionID string
SubjectType oidc.SubjectType
}
func (OidcClientAllowedAPIPermission) TableName() string {
return "oidc_clients_allowed_api_permissions"
}

View File

@@ -0,0 +1,78 @@
package api
import (
"context"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
"github.com/pocket-id/pocket-id/backend/internal/dto"
"github.com/pocket-id/pocket-id/backend/internal/oidc"
)
type Dependencies struct {
DB *gorm.DB
// Issuer is the OpenID Provider issuer URL, reserved so a custom API cannot claim it as its audience
Issuer string
}
type Module struct {
service *Service
handler *handler
}
func New(deps Dependencies) *Module {
service := newService(deps.DB, deps.Issuer)
return &Module{
service: service,
handler: newHandler(service),
}
}
// ClientAPIScopes implements the OIDC module's APIAccessProvider interface
func (m *Module) ClientAPIScopes(ctx context.Context, tx *gorm.DB, clientID string) (scopes []string, audiences []string, err error) {
return m.service.ClientAPIScopesAndAudiences(ctx, tx, clientID)
}
// AllowedScopesForAudience implements the OIDC module's APIAccessProvider interface
func (m *Module) AllowedScopesForAudience(ctx context.Context, tx *gorm.DB, clientID, audience string, subjectType oidc.SubjectType) (scopes []string, apiExists bool, err error) {
return m.service.AllowedScopesForAudience(ctx, tx, clientID, audience, subjectType)
}
// DescribePermissions implements the OIDC module's APIAccessProvider interface
func (m *Module) DescribePermissions(ctx context.Context, audience string, keys []string) ([]dto.ScopeInfoDto, error) {
permissions, err := m.service.DescribePermissions(ctx, audience, keys)
if err != nil {
return nil, err
}
infos := make([]dto.ScopeInfoDto, len(permissions))
for i, permission := range permissions {
description := ""
if permission.Description != nil {
description = *permission.Description
}
infos[i] = dto.ScopeInfoDto{Key: permission.Key, Name: permission.Name, Description: description}
}
return infos, nil
}
// RegisterRoutes mounts the admin CRUD endpoints
// adminAuth is passed in as a gin handler so the module does not import internal/middleware
func (m *Module) RegisterRoutes(apiGroup *gin.RouterGroup, adminAuth gin.HandlerFunc) {
apis := apiGroup.Group("/apis")
apis.Use(adminAuth)
apis.GET("", m.handler.list)
apis.POST("", m.handler.create)
apis.GET("/:id", m.handler.get)
apis.PUT("/:id", m.handler.update)
apis.DELETE("/:id", m.handler.delete)
apis.PUT("/:id/permissions", m.handler.updatePermissions)
// The per-client API-access allow-list lives on a separate path so it does not collide with the /apis/:id wildcard
access := apiGroup.Group("/api-access")
access.Use(adminAuth)
access.GET("/:clientId", m.handler.getClientAccess)
access.PUT("/:clientId", m.handler.updateClientAccess)
}

View File

@@ -0,0 +1,484 @@
package api
import (
"context"
"errors"
"fmt"
"strings"
"time"
"github.com/ory/fosite"
"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/oidc"
"github.com/pocket-id/pocket-id/backend/internal/utils"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
// isPermissionKeyReserved reports whether the key is a scope or claim name owned by Pocket ID's built-in identity layer
// A custom API permission must not reuse one, otherwise its scope string would collide with a standard OIDC scope or claim
func isPermissionKeyReserved(key string) bool {
switch strings.ToLower(key) {
case "openid", "profile", "email", "email_verified", "groups", "offline_access":
return true
default:
return false
}
}
// isValidPermissionKey reports whether the key consists only of RFC 6749 scope-token characters, which are printable ASCII without space, double-quote or backslash
// This keeps a key safe as a space-delimited value in the token scope claim and free of the control character used to qualify consent records
func isValidPermissionKey(key string) bool {
return fosite.IsValidScopeToken(key)
}
// Service holds the business logic for managing APIs and their permissions
type Service struct {
db *gorm.DB
issuer string
}
func newService(db *gorm.DB, issuer string) *Service {
return &Service{db: db, issuer: issuer}
}
// isIssuerAudience reports whether the audience refers to Pocket ID itself (the issuer)
// A custom API must not claim the issuer as its audience, otherwise its tokens would be indistinguishable from Pocket ID's own identity tokens
func isIssuerAudience(audience, issuer string) bool {
return issuer != "" && strings.ToLower(strings.TrimRight(audience, "/")) == issuer
}
func (s *Service) List(ctx context.Context, search string, listRequestOptions utils.ListRequestOptions) (apis []API, response utils.PaginationResponse, err error) {
query := s.db.
WithContext(ctx).
Preload("Permissions").
Model(&API{})
if listRequestOptions.Sort.Column == "resource" {
listRequestOptions.Sort.Column = "audience"
}
if search != "" {
like := "%" + search + "%"
query = query.Where("name LIKE ? OR audience LIKE ?", like, like)
}
response, err = utils.PaginateFilterAndSort(listRequestOptions, query, &apis)
return apis, response, err
}
// Get loads an API and its permissions
func (s *Service) Get(ctx context.Context, tx *gorm.DB, id string) (api API, err error) {
query := s.db.WithContext(ctx)
if tx != nil {
query = tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"})
}
err = query.
Preload("Permissions").
Where("id = ?", id).
First(&api).
Error
return api, err
}
func (s *Service) Create(ctx context.Context, input apiCreateDto) (api API, err error) {
// Reject the issuer as an audience so a custom API cannot impersonate Pocket ID's own identity tokens
if isIssuerAudience(input.Resource, s.issuer) {
return API{}, &common.ValidationError{Message: "the resource is reserved by Pocket ID and cannot be used for a custom API"}
}
api = API{
Name: input.Name,
Audience: input.Resource,
}
err = s.db.WithContext(ctx).Create(&api).Error
if err != nil {
if errors.Is(err, gorm.ErrDuplicatedKey) {
return API{}, &common.AlreadyInUseError{Property: "resource"}
}
return API{}, err
}
return api, nil
}
func (s *Service) Update(ctx context.Context, id string, input apiUpdateDto) (api API, err error) {
tx := s.db.Begin()
defer func() {
tx.Rollback()
}()
api, err = s.Get(ctx, tx, id)
if err != nil {
return API{}, err
}
api.Name = input.Name
api.UpdatedAt = new(datatype.DateTime(time.Now()))
err = tx.WithContext(ctx).Save(&api).Error
if err != nil {
return API{}, err
}
if err = tx.Commit().Error; err != nil {
return API{}, err
}
return api, nil
}
func (s *Service) Delete(ctx context.Context, id string) error {
tx := s.db.Begin()
defer func() {
tx.Rollback()
}()
api, err := s.Get(ctx, tx, id)
if err != nil {
return err
}
if err = s.deletePermissions(ctx, tx, collectIDs(api.Permissions)); err != nil {
return err
}
if err = tx.WithContext(ctx).Delete(&API{}, "id = ?", id).Error; err != nil {
return err
}
return tx.Commit().Error
}
// UpdatePermissions replaces the full permission set of an API, matching existing permissions by key
// Unchanged keys keep their grants, removed keys and their client grants are deleted, and new keys are inserted
func (s *Service) UpdatePermissions(ctx context.Context, id string, input apiPermissionsUpdateDto) (api API, err error) {
tx := s.db.Begin()
defer func() {
tx.Rollback()
}()
api, err = s.Get(ctx, tx, id)
if err != nil {
return API{}, err
}
// Reject keys with invalid characters, that collide with Pocket ID's reserved scopes and claims, or that repeat within the request before persisting anything
// A duplicate key would otherwise be silently coalesced last-wins into the map below, dropping a row behind a 200
seen := make(map[string]struct{}, len(input.Permissions))
for _, permission := range input.Permissions {
if !isValidPermissionKey(permission.Key) {
return API{}, &common.ValidationError{Message: fmt.Sprintf("the permission key %q contains invalid characters", permission.Key)}
}
if isPermissionKeyReserved(permission.Key) {
return API{}, &common.ValidationError{Message: fmt.Sprintf("the permission key %q is reserved by Pocket ID", permission.Key)}
}
_, ok := seen[permission.Key]
if ok {
return API{}, &common.ValidationError{Message: fmt.Sprintf("the permission key %q is listed more than once", permission.Key)}
}
seen[permission.Key] = struct{}{}
}
existing := make(map[string]Permission, len(api.Permissions))
for _, p := range api.Permissions {
existing[p.Key] = p
}
wanted := make(map[string]apiPermissionInputDto, len(input.Permissions))
var removedIDs []string
for _, in := range input.Permissions {
wanted[in.Key] = in
}
// Delete permissions whose key is no longer wanted
for key, p := range existing {
if _, ok := wanted[key]; !ok {
removedIDs = append(removedIDs, p.ID)
}
}
if err = s.deletePermissions(ctx, tx, removedIDs); err != nil {
return API{}, err
}
// Insert new keys and update the display fields of existing ones
for key, in := range wanted {
if cur, ok := existing[key]; ok {
err = tx.WithContext(ctx).
Model(&Permission{}).
Where("id = ?", cur.ID).
Updates(map[string]any{"name": in.Name, "description": in.Description}).
Error
if err != nil {
return API{}, err
}
continue
}
newPermission := Permission{
APIID: api.ID,
Key: in.Key,
Name: in.Name,
Description: in.Description,
}
if err = tx.WithContext(ctx).Create(&newPermission).Error; err != nil {
return API{}, err
}
}
err = tx.WithContext(ctx).
Model(&API{}).
Where("id = ?", api.ID).
Update("updated_at", new(datatype.DateTime(time.Now()))).
Error
if err != nil {
return API{}, err
}
api, err = s.Get(ctx, tx, id)
if err != nil {
return API{}, err
}
if err = tx.Commit().Error; err != nil {
return API{}, err
}
return api, nil
}
// ClientAPIAccess is the set of API permissions granted to a client, split by the subject the resulting tokens act for
// User-delegated permissions may be requested on behalf of a signed-in user, client permissions may be obtained by the client itself through the client credentials grant
type ClientAPIAccess struct {
UserDelegatedPermissionIDs []string
ClientPermissionIDs []string
}
// GetClientAPIAccess returns the API permissions a client is allowed to request, split by subject type
// Only custom-API permissions are tracked here because the identity scopes are freely requestable by every client
func (s *Service) GetClientAPIAccess(ctx context.Context, clientID string) (access ClientAPIAccess, err error) {
var rows []OidcClientAllowedAPIPermission
err = s.db.WithContext(ctx).
Where("oidc_client_id = ?", clientID).
Find(&rows).
Error
if err != nil {
return ClientAPIAccess{}, err
}
for _, row := range rows {
switch row.SubjectType {
case oidc.SubjectTypeClient:
access.ClientPermissionIDs = append(access.ClientPermissionIDs, row.APIPermissionID)
case oidc.SubjectTypeUser:
access.UserDelegatedPermissionIDs = append(access.UserDelegatedPermissionIDs, row.APIPermissionID)
default:
// Nop - ignore
}
}
return access, nil
}
// SetClientAPIAccess replaces the client's API-access grants for both subject types with the given permission IDs
// Unknown permission IDs are ignored
// It returns the access that was actually applied
func (s *Service) SetClientAPIAccess(ctx context.Context, clientID string, access ClientAPIAccess) (applied ClientAPIAccess, err error) {
tx := s.db.Begin()
defer func() {
tx.Rollback()
}()
// Ensure the client exists so callers get a 404 for an unknown client
var client model.OidcClient
if err = tx.WithContext(ctx).Select("id").Where("id = ?", clientID).First(&client).Error; err != nil {
return ClientAPIAccess{}, err
}
applied.UserDelegatedPermissionIDs, err = s.filterAssignablePermissionIDs(ctx, tx, access.UserDelegatedPermissionIDs)
if err != nil {
return ClientAPIAccess{}, err
}
applied.ClientPermissionIDs, err = s.filterAssignablePermissionIDs(ctx, tx, access.ClientPermissionIDs)
if err != nil {
return ClientAPIAccess{}, err
}
// Replace the grants for this client
err = tx.WithContext(ctx).
Where("oidc_client_id = ?", clientID).
Delete(&OidcClientAllowedAPIPermission{}).
Error
if err != nil {
return ClientAPIAccess{}, err
}
rows := make([]OidcClientAllowedAPIPermission, 0, len(applied.UserDelegatedPermissionIDs)+len(applied.ClientPermissionIDs))
for _, permissionID := range applied.UserDelegatedPermissionIDs {
rows = append(rows, OidcClientAllowedAPIPermission{OidcClientID: clientID, APIPermissionID: permissionID, SubjectType: oidc.SubjectTypeUser})
}
for _, permissionID := range applied.ClientPermissionIDs {
rows = append(rows, OidcClientAllowedAPIPermission{OidcClientID: clientID, APIPermissionID: permissionID, SubjectType: oidc.SubjectTypeClient})
}
if len(rows) > 0 {
if err = tx.WithContext(ctx).Create(&rows).Error; err != nil {
return ClientAPIAccess{}, err
}
}
if err = tx.Commit().Error; err != nil {
return ClientAPIAccess{}, err
}
return applied, nil
}
// ClientAPIScopesAndAudiences returns the permission keys a client may request and the distinct audiences of the custom APIs those permissions belong to, across both subject types
// The OIDC module uses this to widen fosite's scope and audience validation for the client; the per-flow subject-type enforcement happens when the resource is resolved
func (s *Service) ClientAPIScopesAndAudiences(ctx context.Context, tx *gorm.DB, clientID string) (scopes []string, audiences []string, err error) {
if tx == nil {
tx = s.db
}
var rows []struct {
Key string
Audience string
}
err = tx.WithContext(ctx).
Table("oidc_clients_allowed_api_permissions AS g").
Select("api_permissions.key AS key, apis.audience AS audience").
Joins("JOIN api_permissions ON api_permissions.id = g.api_permission_id").
Joins("JOIN apis ON apis.id = api_permissions.api_id").
Where("g.oidc_client_id = ?", clientID).
Scan(&rows).
Error
if err != nil {
return nil, nil, err
}
scopeSeen := make(map[string]struct{}, len(rows))
audienceSeen := make(map[string]struct{}, len(rows))
scopes = make([]string, 0, len(rows))
audiences = make([]string, 0, len(rows))
for _, row := range rows {
if _, ok := scopeSeen[row.Key]; !ok {
scopeSeen[row.Key] = struct{}{}
scopes = append(scopes, row.Key)
}
if _, ok := audienceSeen[row.Audience]; !ok {
audienceSeen[row.Audience] = struct{}{}
audiences = append(audiences, row.Audience)
}
}
return scopes, audiences, nil
}
// AllowedScopesForAudience returns the permission keys the client is allowed for the API identified by the given audience and subject type, plus whether such an API exists
func (s *Service) AllowedScopesForAudience(ctx context.Context, tx *gorm.DB, clientID, audience string, subjectType oidc.SubjectType) (scopes []string, apiExists bool, err error) {
if tx == nil {
tx = s.db
}
var api API
err = tx.WithContext(ctx).
Select("id").
Where("audience = ?", audience).
First(&api).
Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, false, nil
}
if err != nil {
return nil, false, err
}
err = tx.WithContext(ctx).
Table("api_permissions").
Select("api_permissions.key").
Joins("JOIN oidc_clients_allowed_api_permissions g ON g.api_permission_id = api_permissions.id AND g.oidc_client_id = ? AND g.subject_type = ?", clientID, subjectType).
Where("api_permissions.api_id = ?", api.ID).
Pluck("api_permissions.key", &scopes).
Error
if err != nil {
return nil, true, err
}
return scopes, true, nil
}
// DescribePermissions returns the permission rows of the API identified by the given audience whose key is in keys
// The consent screen uses these to show friendly names instead of raw scope keys
func (s *Service) DescribePermissions(ctx context.Context, audience string, keys []string) ([]Permission, error) {
if len(keys) == 0 {
return nil, nil
}
var permissions []Permission
err := s.db.WithContext(ctx).
Model(&Permission{}).
Joins("JOIN apis ON apis.id = api_permissions.api_id").
Where("apis.audience = ? AND api_permissions.key IN ?", audience, keys).
Find(&permissions).
Error
if err != nil {
return nil, err
}
return permissions, nil
}
// filterAssignablePermissionIDs returns the subset of the given permission IDs that exist
func (s *Service) filterAssignablePermissionIDs(ctx context.Context, tx *gorm.DB, permissionIDs []string) ([]string, error) {
if len(permissionIDs) == 0 {
return nil, nil
}
var valid []string
err := tx.WithContext(ctx).
Model(&Permission{}).
Where("id IN ?", permissionIDs).
Pluck("id", &valid).
Error
if err != nil {
return nil, err
}
return valid, nil
}
// deletePermissions removes permissions by ID along with any client allow-list grants that reference them
// The explicit grant delete keeps this correct even when the database does not enforce ON DELETE CASCADE at runtime
func (s *Service) deletePermissions(ctx context.Context, tx *gorm.DB, permissionIDs []string) error {
if tx == nil {
tx = s.db
}
if len(permissionIDs) == 0 {
return nil
}
err := tx.WithContext(ctx).
Where("api_permission_id IN ?", permissionIDs).
Delete(&OidcClientAllowedAPIPermission{}).
Error
if err != nil {
return err
}
return tx.WithContext(ctx).
Where("id IN ?", permissionIDs).
Delete(&Permission{}).
Error
}
func collectIDs(permissions []Permission) []string {
ids := make([]string, len(permissions))
for i, p := range permissions {
ids[i] = p.ID
}
return ids
}

View File

@@ -0,0 +1,283 @@
package api
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/pocket-id/pocket-id/backend/internal/common"
"github.com/pocket-id/pocket-id/backend/internal/model"
"github.com/pocket-id/pocket-id/backend/internal/oidc"
testutils "github.com/pocket-id/pocket-id/backend/internal/utils/testing"
)
func TestAPICrudAndPermissionDiff(t *testing.T) {
db := testutils.NewDatabaseForTest(t)
svc := New(Dependencies{DB: db}).service
created, err := svc.Create(t.Context(), apiCreateDto{Name: "Orders API", Resource: "https://api.orders.example.com"})
require.NoError(t, err)
assert.NotEmpty(t, created.ID)
// The resource is unique.
_, err = svc.Create(t.Context(), apiCreateDto{Name: "Dup", Resource: "https://api.orders.example.com"})
require.ErrorIs(t, err, &common.AlreadyInUseError{})
desc := "Read orders"
updated, err := svc.UpdatePermissions(t.Context(), created.ID, apiPermissionsUpdateDto{Permissions: []apiPermissionInputDto{
{Key: "read:orders", Name: "Read orders", Description: &desc},
{Key: "write:orders", Name: "Write orders"},
}})
require.NoError(t, err)
assert.Len(t, updated.Permissions, 2)
// Grant a client the read:orders permission for both subject types, then remove that permission
// and confirm the grants are cleaned up while write:orders (and its key) survives.
readPerm := findPermission(updated, "read:orders")
require.NotNil(t, readPerm)
require.NoError(t, db.Create(&model.OidcClient{Base: model.Base{ID: "client-1"}, Name: "Client 1"}).Error)
require.NoError(t, db.Create(&OidcClientAllowedAPIPermission{OidcClientID: "client-1", APIPermissionID: readPerm.ID, SubjectType: oidc.SubjectTypeUser}).Error)
require.NoError(t, db.Create(&OidcClientAllowedAPIPermission{OidcClientID: "client-1", APIPermissionID: readPerm.ID, SubjectType: oidc.SubjectTypeClient}).Error)
updated, err = svc.UpdatePermissions(t.Context(), created.ID, apiPermissionsUpdateDto{Permissions: []apiPermissionInputDto{
{Key: "write:orders", Name: "Write orders (renamed)"},
}})
require.NoError(t, err)
require.Len(t, updated.Permissions, 1)
assert.Equal(t, "write:orders", updated.Permissions[0].Key)
assert.Equal(t, "Write orders (renamed)", updated.Permissions[0].Name)
var grantCount int64
require.NoError(t, db.Model(&OidcClientAllowedAPIPermission{}).Where("api_permission_id = ?", readPerm.ID).Count(&grantCount).Error)
assert.Equal(t, int64(0), grantCount)
renamed, err := svc.Update(t.Context(), created.ID, apiUpdateDto{Name: "Orders"})
require.NoError(t, err)
assert.Equal(t, "Orders", renamed.Name)
require.NotNil(t, renamed.UpdatedAt)
require.NoError(t, svc.Delete(t.Context(), created.ID))
_, err = svc.Get(t.Context(), nil, created.ID)
require.Error(t, err)
}
func TestClientApiAccessAllowList(t *testing.T) {
db := testutils.NewDatabaseForTest(t)
svc := New(Dependencies{DB: db}).service
require.NoError(t, db.Create(&model.OidcClient{Base: model.Base{ID: "client-1"}, Name: "Client 1"}).Error)
orders, err := svc.Create(t.Context(), apiCreateDto{Name: "Orders", Resource: "https://api.orders.example.com"})
require.NoError(t, err)
orders, err = svc.UpdatePermissions(t.Context(), orders.ID, apiPermissionsUpdateDto{Permissions: []apiPermissionInputDto{
{Key: "read:orders", Name: "Read"},
{Key: "write:orders", Name: "Write"},
}})
require.NoError(t, err)
readID := findPermission(orders, "read:orders").ID
writeID := findPermission(orders, "write:orders").ID
// Unknown IDs are filtered out, and the subject types are stored independently.
applied, err := svc.SetClientAPIAccess(t.Context(), "client-1", ClientAPIAccess{
UserDelegatedPermissionIDs: []string{readID, "does-not-exist"},
ClientPermissionIDs: []string{writeID, "does-not-exist"},
})
require.NoError(t, err)
assert.ElementsMatch(t, []string{readID}, applied.UserDelegatedPermissionIDs)
assert.ElementsMatch(t, []string{writeID}, applied.ClientPermissionIDs)
got, err := svc.GetClientAPIAccess(t.Context(), "client-1")
require.NoError(t, err)
assert.ElementsMatch(t, []string{readID}, got.UserDelegatedPermissionIDs)
assert.ElementsMatch(t, []string{writeID}, got.ClientPermissionIDs)
// The same permission can be granted for both subject types, and both sets are fully replaced on each call.
_, err = svc.SetClientAPIAccess(t.Context(), "client-1", ClientAPIAccess{
UserDelegatedPermissionIDs: []string{readID, writeID},
ClientPermissionIDs: []string{readID},
})
require.NoError(t, err)
got, err = svc.GetClientAPIAccess(t.Context(), "client-1")
require.NoError(t, err)
assert.ElementsMatch(t, []string{readID, writeID}, got.UserDelegatedPermissionIDs)
assert.ElementsMatch(t, []string{readID}, got.ClientPermissionIDs)
// Clearing one subject type leaves the other untouched.
_, err = svc.SetClientAPIAccess(t.Context(), "client-1", ClientAPIAccess{ClientPermissionIDs: []string{readID}})
require.NoError(t, err)
got, err = svc.GetClientAPIAccess(t.Context(), "client-1")
require.NoError(t, err)
assert.Empty(t, got.UserDelegatedPermissionIDs)
assert.ElementsMatch(t, []string{readID}, got.ClientPermissionIDs)
// Clearing everything.
_, err = svc.SetClientAPIAccess(t.Context(), "client-1", ClientAPIAccess{})
require.NoError(t, err)
got, err = svc.GetClientAPIAccess(t.Context(), "client-1")
require.NoError(t, err)
assert.Empty(t, got.UserDelegatedPermissionIDs)
assert.Empty(t, got.ClientPermissionIDs)
// An unknown client is rejected (surfaces as 404 at the HTTP layer).
_, err = svc.SetClientAPIAccess(t.Context(), "nope", ClientAPIAccess{UserDelegatedPermissionIDs: []string{readID}})
require.Error(t, err)
}
// TestAllowedScopesForAudienceFiltersBySubjectType guards that the scopes resolved for a flow
// only come from the grants of that flow's subject type.
func TestAllowedScopesForAudienceFiltersBySubjectType(t *testing.T) {
db := testutils.NewDatabaseForTest(t)
svc := New(Dependencies{DB: db}).service
require.NoError(t, db.Create(&model.OidcClient{Base: model.Base{ID: "client-1"}, Name: "Client 1"}).Error)
orders, err := svc.Create(t.Context(), apiCreateDto{Name: "Orders", Resource: "https://api.orders.example.com"})
require.NoError(t, err)
orders, err = svc.UpdatePermissions(t.Context(), orders.ID, apiPermissionsUpdateDto{Permissions: []apiPermissionInputDto{
{Key: "read:orders", Name: "Read"},
{Key: "write:orders", Name: "Write"},
}})
require.NoError(t, err)
readID := findPermission(orders, "read:orders").ID
writeID := findPermission(orders, "write:orders").ID
_, err = svc.SetClientAPIAccess(t.Context(), "client-1", ClientAPIAccess{
UserDelegatedPermissionIDs: []string{readID},
ClientPermissionIDs: []string{writeID},
})
require.NoError(t, err)
userScopes, exists, err := svc.AllowedScopesForAudience(t.Context(), nil, "client-1", "https://api.orders.example.com", oidc.SubjectTypeUser)
require.NoError(t, err)
require.True(t, exists)
assert.ElementsMatch(t, []string{"read:orders"}, userScopes)
clientScopes, exists, err := svc.AllowedScopesForAudience(t.Context(), nil, "client-1", "https://api.orders.example.com", oidc.SubjectTypeClient)
require.NoError(t, err)
require.True(t, exists)
assert.ElementsMatch(t, []string{"write:orders"}, clientScopes)
// The fosite widening still sees the union of both subject types.
scopes, audiences, err := svc.ClientAPIScopesAndAudiences(t.Context(), nil, "client-1")
require.NoError(t, err)
assert.ElementsMatch(t, []string{"read:orders", "write:orders"}, scopes)
assert.ElementsMatch(t, []string{"https://api.orders.example.com"}, audiences)
}
func TestUpdatePermissionsRejectsReservedKeys(t *testing.T) {
db := testutils.NewDatabaseForTest(t)
svc := New(Dependencies{DB: db}).service
orders, err := svc.Create(t.Context(), apiCreateDto{Name: "Orders", Resource: "https://api.orders.example.com"})
require.NoError(t, err)
for _, key := range []string{"openid", "profile", "email", "email_verified", "groups", "offline_access", "Email"} {
_, err := svc.UpdatePermissions(t.Context(), orders.ID, apiPermissionsUpdateDto{Permissions: []apiPermissionInputDto{
{Key: key, Name: "Reserved"},
}})
require.Error(t, err, "key %q must be rejected", key)
var validationErr *common.ValidationError
require.ErrorAs(t, err, &validationErr)
}
}
func TestUpdatePermissionsRejectsDuplicateKeys(t *testing.T) {
db := testutils.NewDatabaseForTest(t)
svc := New(Dependencies{DB: db}).service
orders, err := svc.Create(t.Context(), apiCreateDto{Name: "Orders", Resource: "https://api.orders.example.com"})
require.NoError(t, err)
// Two rows with the same key must be rejected rather than silently coalesced last-wins
_, err = svc.UpdatePermissions(t.Context(), orders.ID, apiPermissionsUpdateDto{Permissions: []apiPermissionInputDto{
{Key: "read:orders", Name: "Read"},
{Key: "read:orders", Name: "Read again"},
}})
require.Error(t, err)
var validationErr *common.ValidationError
require.ErrorAs(t, err, &validationErr)
}
func TestUpdatePermissionsRejectsInvalidKeyCharacters(t *testing.T) {
db := testutils.NewDatabaseForTest(t)
svc := New(Dependencies{DB: db}).service
orders, err := svc.Create(t.Context(), apiCreateDto{Name: "Orders", Resource: "https://api.orders.example.com"})
require.NoError(t, err)
// A space corrupts the space-delimited scope claim, and the unit separator is the consent delimiter
for _, key := range []string{"read orders", "read\x1forders", "read\"orders", "bad\\key", "tab\tkey"} {
_, err := svc.UpdatePermissions(t.Context(), orders.ID, apiPermissionsUpdateDto{Permissions: []apiPermissionInputDto{
{Key: key, Name: "Invalid"},
}})
require.Error(t, err, "key %q must be rejected", key)
var validationErr *common.ValidationError
require.ErrorAs(t, err, &validationErr)
}
// A valid scope-token key is accepted
_, err = svc.UpdatePermissions(t.Context(), orders.ID, apiPermissionsUpdateDto{Permissions: []apiPermissionInputDto{
{Key: "read:orders", Name: "Read"},
}})
require.NoError(t, err)
}
func TestCreateRejectsIssuerResource(t *testing.T) {
db := testutils.NewDatabaseForTest(t)
const issuer = "https://id.example.com"
svc := New(Dependencies{DB: db, Issuer: issuer}).service
// The issuer itself, a trailing-slash variant, and a different-cased variant are all reserved
for _, resource := range []string{issuer, issuer + "/", "https://ID.example.com"} {
_, err := svc.Create(t.Context(), apiCreateDto{Name: "Reserved", Resource: resource})
require.Error(t, err, "resource %q must be rejected", resource)
var validationErr *common.ValidationError
require.ErrorAs(t, err, &validationErr)
}
// A normal resource is accepted
_, err := svc.Create(t.Context(), apiCreateDto{Name: "Orders", Resource: "https://api.orders.example.com"})
require.NoError(t, err)
}
func TestCreateAcceptsAbsoluteResourceURIs(t *testing.T) {
db := testutils.NewDatabaseForTest(t)
svc := New(Dependencies{DB: db}).service
for _, resource := range []string{"https://api.orders.example.com", "api://PocketID", "urn:my-app"} {
_, err := svc.Create(t.Context(), apiCreateDto{Name: "Orders", Resource: resource})
require.NoError(t, err, "resource %q must be accepted", resource)
}
}
func TestDescribePermissions(t *testing.T) {
db := testutils.NewDatabaseForTest(t)
svc := New(Dependencies{DB: db}).service
orders, err := svc.Create(t.Context(), apiCreateDto{Name: "Orders", Resource: "https://api.orders.example.com"})
require.NoError(t, err)
desc := "Read orders"
_, err = svc.UpdatePermissions(t.Context(), orders.ID, apiPermissionsUpdateDto{Permissions: []apiPermissionInputDto{
{Key: "read:orders", Name: "Read orders", Description: &desc},
{Key: "write:orders", Name: "Write orders"},
}})
require.NoError(t, err)
infos, err := svc.DescribePermissions(t.Context(), "https://api.orders.example.com", []string{"read:orders", "unknown"})
require.NoError(t, err)
require.Len(t, infos, 1)
assert.Equal(t, "read:orders", infos[0].Key)
assert.Equal(t, "Read orders", infos[0].Name)
require.NotNil(t, infos[0].Description)
assert.Equal(t, "Read orders", *infos[0].Description)
}
func findPermission(api API, key string) *Permission {
for i := range api.Permissions {
if api.Permissions[i].Key == key {
return &api.Permissions[i]
}
}
return nil
}

View File

@@ -1,20 +1,20 @@
package dto
package apikey
import (
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
)
type ApiKeyCreateDto struct {
type apiKeyCreateDto struct {
Name string `json:"name" binding:"required,min=3,max=50" unorm:"nfc"`
Description *string `json:"description" unorm:"nfc"`
ExpiresAt datatype.DateTime `json:"expiresAt" binding:"required"`
}
type ApiKeyRenewDto struct {
type apiKeyRenewDto struct {
ExpiresAt datatype.DateTime `json:"expiresAt" binding:"required"`
}
type ApiKeyDto struct {
type apiKeyDto struct {
ID string `json:"id"`
Name string `json:"name"`
Description *string `json:"description"`
@@ -24,7 +24,7 @@ type ApiKeyDto struct {
ExpirationEmailSent bool `json:"expirationEmailSent"`
}
type ApiKeyResponseDto struct {
ApiKey ApiKeyDto `json:"apiKey"`
type apiKeyResponseDto struct {
ApiKey apiKeyDto `json:"apiKey"`
Token string `json:"token"`
}

View File

@@ -0,0 +1,139 @@
package apikey
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/utils"
)
type handler struct {
service *Service
}
func newHandler(service *Service) *handler {
return &handler{service: service}
}
// list godoc
// @Summary List API keys
// @Description Get a paginated list of API keys belonging to the current user
// @Tags API Keys
// @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[apiKeyDto]
// @Router /api/api-keys [get]
func (h *handler) list(c *gin.Context) {
listRequestOptions := utils.ParseListRequestOptions(c)
userID := c.GetString("userID")
apiKeys, pagination, err := h.service.ListApiKeys(c.Request.Context(), userID, listRequestOptions)
if err != nil {
_ = c.Error(err)
return
}
var apiKeysDto []apiKeyDto
if err := dto.MapStructList(apiKeys, &apiKeysDto); err != nil {
_ = c.Error(err)
return
}
c.JSON(http.StatusOK, dto.Paginated[apiKeyDto]{
Data: apiKeysDto,
Pagination: pagination,
})
}
// create godoc
// @Summary Create API key
// @Description Create a new API key for the current user
// @Tags API Keys
// @Param api_key body apiKeyCreateDto true "API key information"
// @Success 201 {object} apiKeyResponseDto "Created API key with token"
// @Router /api/api-keys [post]
func (h *handler) create(c *gin.Context) {
userID := c.GetString("userID")
var input apiKeyCreateDto
if err := dto.ShouldBindWithNormalizedJSON(c, &input); err != nil {
_ = c.Error(err)
return
}
apiKey, token, err := h.service.CreateApiKey(c.Request.Context(), userID, input)
if err != nil {
_ = c.Error(err)
return
}
var responseDto apiKeyDto
if err := dto.MapStruct(apiKey, &responseDto); err != nil {
_ = c.Error(err)
return
}
c.JSON(http.StatusCreated, apiKeyResponseDto{
ApiKey: responseDto,
Token: token,
})
}
// renew 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} apiKeyResponseDto "Renewed API key with new token"
// @Router /api/api-keys/{id}/renew [post]
func (h *handler) renew(c *gin.Context) {
userID := c.GetString("userID")
apiKeyID := c.Param("id")
var input apiKeyRenewDto
if err := dto.ShouldBindWithNormalizedJSON(c, &input); err != nil {
_ = c.Error(err)
return
}
apiKey, token, err := h.service.RenewApiKey(c.Request.Context(), userID, apiKeyID, input.ExpiresAt.ToTime())
if err != nil {
_ = c.Error(err)
return
}
var responseDto apiKeyDto
if err := dto.MapStruct(apiKey, &responseDto); err != nil {
_ = c.Error(err)
return
}
c.JSON(http.StatusOK, apiKeyResponseDto{
ApiKey: responseDto,
Token: token,
})
}
// revoke godoc
// @Summary Revoke API key
// @Description Revoke (delete) an existing API key by ID
// @Tags API Keys
// @Param id path string true "API Key ID"
// @Success 204 "No Content"
// @Router /api/api-keys/{id} [delete]
func (h *handler) revoke(c *gin.Context) {
userID := c.GetString("userID")
apiKeyID := c.Param("id")
if err := h.service.RevokeApiKey(c.Request.Context(), userID, apiKeyID); err != nil {
_ = c.Error(err)
return
}
c.Status(http.StatusNoContent)
}

View File

@@ -1,9 +1,13 @@
package model
package apikey
import datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
import (
"github.com/pocket-id/pocket-id/backend/internal/model"
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
)
// ApiKey is a personal access token a user can use to authenticate against the API
type ApiKey struct {
Base
model.Base
Name string `sortable:"true"`
Key string
@@ -13,5 +17,5 @@ type ApiKey struct {
ExpirationEmailSent bool
UserID string
User User
User model.User
}

View File

@@ -0,0 +1,58 @@
package apikey
import (
"context"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
"github.com/pocket-id/pocket-id/backend/internal/model"
)
type Dependencies struct {
DB *gorm.DB
StaticApiKey string
}
type Module struct {
service *Service
handler *handler
}
func New(ctx context.Context, deps Dependencies) (*Module, error) {
service, err := newService(ctx, deps.DB, deps.StaticApiKey)
if err != nil {
return nil, err
}
return &Module{
service: service,
handler: newHandler(service),
}, nil
}
// RegisterRoutes mounts the API key management endpoints
// authWithoutApiKey disables API key authentication so an API key cannot be used to mint or renew further API keys
func (m *Module) RegisterRoutes(apiGroup *gin.RouterGroup, auth, authWithoutApiKey gin.HandlerFunc) {
group := apiGroup.Group("/api-keys")
group.GET("", auth, m.handler.list)
group.POST("", authWithoutApiKey, m.handler.create)
group.POST("/:id/renew", authWithoutApiKey, m.handler.renew)
group.DELETE("/:id", auth, m.handler.revoke)
}
// ValidateApiKey resolves the user that owns the given raw API key
// It is used by the authentication middleware
func (m *Module) ValidateApiKey(ctx context.Context, apiKey string) (model.User, error) {
return m.service.ValidateApiKey(ctx, apiKey)
}
// ListExpiringApiKeys returns API keys expiring within the given number of days that have not been notified yet
func (m *Module) ListExpiringApiKeys(ctx context.Context, daysAhead int) ([]ApiKey, error) {
return m.service.ListExpiringApiKeys(ctx, daysAhead)
}
// MarkExpirationEmailSent records that the expiration notification email was sent for the given API key
func (m *Module) MarkExpirationEmailSent(ctx context.Context, apiKeyID string) error {
return m.service.MarkExpirationEmailSent(ctx, apiKeyID)
}

View File

@@ -1,33 +1,32 @@
package service
package apikey
import (
"context"
"errors"
"fmt"
"time"
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
"github.com/pocket-id/pocket-id/backend/internal/utils/email"
"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"
"github.com/pocket-id/pocket-id/backend/internal/utils"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"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"
)
const staticApiKeyUserID = "00000000-0000-0000-0000-000000000000"
type ApiKeyService struct {
// Service holds the business logic for managing user API keys
type Service struct {
db *gorm.DB
emailService *EmailService
staticApiKey string
}
func NewApiKeyService(ctx context.Context, db *gorm.DB, emailService *EmailService) (*ApiKeyService, error) {
s := &ApiKeyService{db: db, emailService: emailService}
func newService(ctx context.Context, db *gorm.DB, staticApiKey string) (*Service, error) {
s := &Service{
db: db,
staticApiKey: staticApiKey,
}
if common.EnvConfig.StaticApiKey == "" {
if staticApiKey == "" {
err := s.deleteStaticApiKeyUser(ctx)
if err != nil {
return nil, err
@@ -35,16 +34,15 @@ func NewApiKeyService(ctx context.Context, db *gorm.DB, emailService *EmailServi
}
return s, nil
}
func (s *ApiKeyService) ListApiKeys(ctx context.Context, userID string, listRequestOptions utils.ListRequestOptions) ([]model.ApiKey, utils.PaginationResponse, error) {
func (s *Service) ListApiKeys(ctx context.Context, userID string, listRequestOptions utils.ListRequestOptions) ([]ApiKey, utils.PaginationResponse, error) {
query := s.db.
WithContext(ctx).
Where("user_id = ?", userID).
Model(&model.ApiKey{})
Model(&ApiKey{})
var apiKeys []model.ApiKey
var apiKeys []ApiKey
pagination, err := utils.PaginateFilterAndSort(listRequestOptions, query, &apiKeys)
if err != nil {
return nil, utils.PaginationResponse{}, err
@@ -53,19 +51,19 @@ func (s *ApiKeyService) ListApiKeys(ctx context.Context, userID string, listRequ
return apiKeys, pagination, nil
}
func (s *ApiKeyService) CreateApiKey(ctx context.Context, userID string, input dto.ApiKeyCreateDto) (model.ApiKey, string, error) {
func (s *Service) CreateApiKey(ctx context.Context, userID string, input apiKeyCreateDto) (ApiKey, string, error) {
// Check if expiration is in the future
if !input.ExpiresAt.ToTime().After(time.Now()) {
return model.ApiKey{}, "", &common.APIKeyExpirationDateError{}
return ApiKey{}, "", &common.APIKeyExpirationDateError{}
}
// Generate a secure random API key
token, err := utils.GenerateRandomAlphanumericString(32)
if err != nil {
return model.ApiKey{}, "", err
return ApiKey{}, "", err
}
apiKey := model.ApiKey{
apiKey := ApiKey{
Name: input.Name,
Key: utils.CreateSha256Hash(token), // Hash the token for storage
Description: input.Description,
@@ -79,48 +77,48 @@ func (s *ApiKeyService) CreateApiKey(ctx context.Context, userID string, input d
Error
if err != nil {
if errors.Is(err, gorm.ErrDuplicatedKey) {
return model.ApiKey{}, "", &common.AlreadyInUseError{Property: "API key name"}
return ApiKey{}, "", &common.AlreadyInUseError{Property: "API key name"}
}
return model.ApiKey{}, "", err
return ApiKey{}, "", err
}
// Return the raw token only once - it cannot be retrieved later
return apiKey, token, nil
}
func (s *ApiKeyService) RenewApiKey(ctx context.Context, userID, apiKeyID string, expiration time.Time) (model.ApiKey, string, error) {
func (s *Service) RenewApiKey(ctx context.Context, userID, apiKeyID string, expiration time.Time) (ApiKey, string, error) {
// Check if expiration is in the future
if !expiration.After(time.Now()) {
return model.ApiKey{}, "", &common.APIKeyExpirationDateError{}
return ApiKey{}, "", &common.APIKeyExpirationDateError{}
}
tx := s.db.Begin()
defer tx.Rollback()
var apiKey model.ApiKey
var apiKey ApiKey
err := tx.
WithContext(ctx).
Model(&model.ApiKey{}).
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 ApiKey{}, "", &common.APIKeyNotFoundError{}
}
return model.ApiKey{}, "", err
return ApiKey{}, "", err
}
// Only allow renewal if the key has already expired
if apiKey.ExpiresAt.ToTime().After(time.Now()) {
return model.ApiKey{}, "", &common.APIKeyNotExpiredError{}
return ApiKey{}, "", &common.APIKeyNotExpiredError{}
}
// Generate a secure random API key
token, err := utils.GenerateRandomAlphanumericString(32)
if err != nil {
return model.ApiKey{}, "", err
return ApiKey{}, "", err
}
apiKey.Key = utils.CreateSha256Hash(token)
@@ -128,18 +126,18 @@ func (s *ApiKeyService) RenewApiKey(ctx context.Context, userID, apiKeyID string
err = tx.WithContext(ctx).Save(&apiKey).Error
if err != nil {
return model.ApiKey{}, "", err
return ApiKey{}, "", err
}
if err := tx.Commit().Error; err != nil {
return model.ApiKey{}, "", err
return ApiKey{}, "", err
}
return apiKey, token, nil
}
func (s *ApiKeyService) RevokeApiKey(ctx context.Context, userID, apiKeyID string) error {
var apiKey model.ApiKey
func (s *Service) RevokeApiKey(ctx context.Context, userID, apiKeyID string) error {
var apiKey ApiKey
err := s.db.
WithContext(ctx).
Where("id = ? AND user_id = ?", apiKeyID, userID).
@@ -155,25 +153,25 @@ func (s *ApiKeyService) RevokeApiKey(ctx context.Context, userID, apiKeyID strin
return nil
}
func (s *ApiKeyService) ValidateApiKey(ctx context.Context, apiKey string) (model.User, error) {
func (s *Service) ValidateApiKey(ctx context.Context, apiKey string) (model.User, error) {
if apiKey == "" {
return model.User{}, &common.NoAPIKeyProvidedError{}
}
if common.EnvConfig.StaticApiKey != "" && apiKey == common.EnvConfig.StaticApiKey {
if s.staticApiKey != "" && apiKey == s.staticApiKey {
return s.initStaticApiKeyUser(ctx)
}
now := time.Now()
hashedKey := utils.CreateSha256Hash(apiKey)
var key model.ApiKey
var key ApiKey
err := s.db.
WithContext(ctx).
Model(&model.ApiKey{}).
Model(&ApiKey{}).
Clauses(clause.Returning{}).
Where("key = ? AND expires_at > ?", hashedKey, datatype.DateTime(now)).
Updates(&model.ApiKey{
Updates(&ApiKey{
LastUsedAt: new(datatype.DateTime(now)),
}).
Preload("User").
@@ -190,8 +188,8 @@ func (s *ApiKeyService) ValidateApiKey(ctx context.Context, apiKey string) (mode
return key.User, nil
}
func (s *ApiKeyService) ListExpiringApiKeys(ctx context.Context, daysAhead int) ([]model.ApiKey, error) {
var keys []model.ApiKey
func (s *Service) ListExpiringApiKeys(ctx context.Context, daysAhead int) ([]ApiKey, error) {
var keys []ApiKey
now := time.Now()
cutoff := now.AddDate(0, 0, daysAhead)
@@ -205,40 +203,19 @@ func (s *ApiKeyService) ListExpiringApiKeys(ctx context.Context, daysAhead int)
return keys, err
}
func (s *ApiKeyService) SendApiKeyExpiringSoonEmail(ctx context.Context, apiKey model.ApiKey) error {
if apiKey.User.Email == nil {
return &common.UserEmailNotSetError{}
}
err := SendEmail(ctx, s.emailService, email.Address{
Name: apiKey.User.FullName(),
Email: *apiKey.User.Email,
}, ApiKeyExpiringSoonTemplate, &ApiKeyExpiringSoonTemplateData{
ApiKeyName: apiKey.Name,
ExpiresAt: apiKey.ExpiresAt.ToTime(),
Name: apiKey.User.FirstName,
})
if err != nil {
return fmt.Errorf("error sending notification email: %w", err)
}
// Mark the API key as having had an expiration email sent
err = s.db.WithContext(ctx).
Model(&model.ApiKey{}).
Where("id = ?", apiKey.ID).
// MarkExpirationEmailSent records that the expiration notification email was sent for the given API key
func (s *Service) MarkExpirationEmailSent(ctx context.Context, apiKeyID string) error {
return s.db.WithContext(ctx).
Model(&ApiKey{}).
Where("id = ?", apiKeyID).
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) {
func (s *Service) initStaticApiKeyUser(ctx context.Context) (user model.User, err error) {
err = s.db.
WithContext(ctx).
First(&user, "id = ?", staticApiKeyUserID).
First(&user, "id = ?", common.StaticApiKeyUserID).
Error
if err == nil {
@@ -256,7 +233,7 @@ func (s *ApiKeyService) initStaticApiKeyUser(ctx context.Context) (user model.Us
user = model.User{
Base: model.Base{
ID: staticApiKeyUserID,
ID: common.StaticApiKeyUserID,
},
FirstName: "Static API User",
Username: "static-api-user-" + usernameSuffix,
@@ -272,9 +249,9 @@ func (s *ApiKeyService) initStaticApiKeyUser(ctx context.Context) (user model.Us
return user, err
}
func (s *ApiKeyService) deleteStaticApiKeyUser(ctx context.Context) error {
func (s *Service) deleteStaticApiKeyUser(ctx context.Context) error {
return s.db.
WithContext(ctx).
Delete(&model.User{}, "id = ?", staticApiKeyUserID).
Delete(&model.User{}, "id = ?", common.StaticApiKeyUserID).
Error
}

View File

@@ -0,0 +1,162 @@
package bootstrap
import (
"database/sql"
"errors"
"fmt"
"log/slog"
"net"
"net/http"
"time"
"github.com/italypaleale/francis/builtin/ratelimit"
"github.com/italypaleale/francis/components/postgres"
"github.com/italypaleale/francis/host/local"
"github.com/jackc/pgx/v5/pgxpool"
"gorm.io/gorm"
"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/middleware"
"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/crypto"
)
type NewActorsOpts struct {
SQLite *sql.DB
Postgres *pgxpool.Pool
EnvConfig *common.EnvConfigSchema
AppConfig *service.AppConfigService
HttpClient *http.Client
DB *gorm.DB
FileStorage storage.FileStorage
}
func NewActors(o NewActorsOpts) (*local.Host, map[string]*ratelimit.RateLimitService, error) {
log := slog.Default()
// Derive a PSK from the global encryption key
// The runtime PSK derives the cluster CA used for host-to-host mTLS
psk, err := o.getPSK()
if err != nil {
return nil, nil, fmt.Errorf("failed to derive PSK: %w", err)
}
// Options for the host
opts := []local.HostOption{
local.WithAddress(net.JoinHostPort(o.EnvConfig.ActorsHost, o.EnvConfig.ActorsPort)),
local.WithLogger(log.With("scope", "actor-host")),
local.WithRuntimePSKs(psk),
local.WithShutdownGracePeriod(10 * time.Second),
}
// Add all cron jobs
cronjobs, err := o.getCronJobs()
if err != nil {
return nil, nil, err
}
opts = append(opts, cronjobs...)
// Add the rate limiters
rateLimiters, rateLimiterOpts, err := o.getRateLimiters()
if err != nil {
return nil, nil, err
}
opts = append(opts, rateLimiterOpts...)
// Add the database connection
providerOpt, err := o.getProvider()
if err != nil {
return nil, nil, err
}
opts = append(opts, providerOpt)
// Create a new actor host
h, err := local.NewHost(opts...)
if err != nil {
return nil, nil, fmt.Errorf("failed to create actor host: %w", err)
}
// Bind a service for each rate limiter so the middleware can invoke them
rateLimitServices := make(map[string]*ratelimit.RateLimitService, len(rateLimiters))
for name, rl := range rateLimiters {
rateLimitServices[name] = rl.Service(h.Service())
}
return h, rateLimitServices, nil
}
// Derive a PSK from the global encryption key
func (o *NewActorsOpts) getPSK() ([]byte, error) {
// Note: changing the key derivation or the seed is a breaking change
return crypto.DeriveKey(o.EnvConfig.EncryptionKey, "pocketid/actors-psk")
}
func (o *NewActorsOpts) getProvider() (local.HostOption, error) {
switch {
case o.Postgres != nil && o.SQLite != nil:
return nil, errors.New("cannot have both Postgres and SQLite connections")
case o.Postgres != nil:
return local.WithPostgresProvider(postgres.PostgresProviderOptions{
DB: o.Postgres,
}), nil
case o.SQLite != nil:
return local.WithSQLiteProvider(local.SQLiteProviderOptions{
DB: o.SQLite,
}), nil
default:
return nil, errors.New("one of Postgres and SQLite must be set")
}
}
func (o *NewActorsOpts) getCronJobs() (opts []local.HostOption, err error) {
// In test mode, we do not register anything
if common.EnvConfig.AppEnv == "test" {
return opts, nil
}
// Register the analytics job
analyticsJob, err := job.GetAnalyticsJob(o.AppConfig, o.HttpClient)
if err != nil {
return nil, fmt.Errorf("failed to get analytics cron job: %w", err)
}
if analyticsJob != nil {
// This could be nil if analytics are disabled
opts = append(opts, local.WithBuiltInActor(analyticsJob))
}
// Register the file cleanup jobs
fileCleanupJobs, err := job.GetFileCleanupJobs(o.DB, o.FileStorage)
if err != nil {
return nil, fmt.Errorf("failed to get file cleanup cron jobs: %w", err)
}
for _, j := range fileCleanupJobs {
opts = append(opts, local.WithBuiltInActor(j))
}
return opts, nil
}
// getRateLimiters creates a built-in rate-limit actor for each middleware policy and returns both the created actors (keyed by policy name) and the host options to register them
// Unlike cron jobs, rate limiters keep no durable state, so they are registered in every environment
func (o *NewActorsOpts) getRateLimiters() (actors map[string]*ratelimit.RateLimit, opts []local.HostOption, err error) {
policies := middleware.RateLimitPolicies()
actors = make(map[string]*ratelimit.RateLimit, len(policies))
for _, p := range policies {
rl, err := ratelimit.New(
p.Name,
ratelimit.WithRate(p.Rate),
ratelimit.WithPer(p.Per),
ratelimit.WithBurst(p.Burst),
)
if err != nil {
return nil, nil, fmt.Errorf("error creating rate limiter %q: %w", p.Name, err)
}
actors[p.Name] = rl
opts = append(opts, local.WithBuiltInActor(rl))
}
return actors, opts, nil
}

View File

@@ -0,0 +1,26 @@
package bootstrap
import (
"encoding/hex"
"testing"
"github.com/stretchr/testify/require"
"github.com/pocket-id/pocket-id/backend/internal/common"
)
func TestNewActorsOptsGetPSKUsesStableValue(t *testing.T) {
opts := NewActorsOpts{
EnvConfig: &common.EnvConfigSchema{
EncryptionKey: []byte("test-encryption-key"),
},
}
expectedHex := "651300d35d48998d0fa66ac89091bcde8ed0fd0aa35fbb849f068410c64807e9"
expected, err := hex.DecodeString(expectedHex)
require.NoError(t, err)
actual, err := opts.getPSK()
require.NoError(t, err)
require.Equal(t, expected, actual)
}

View File

@@ -8,48 +8,57 @@ import (
"time"
_ "github.com/golang-migrate/migrate/v4/source/file"
"github.com/italypaleale/go-kit/servicerunner"
"gorm.io/gorm"
"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"
)
func Bootstrap(ctx context.Context) error {
var shutdownFns []utils.Service
defer func() { //nolint:contextcheck
// Invoke all shutdown functions on exit
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := utils.NewServiceRunner(shutdownFns...).Run(shutdownCtx); err != nil {
slog.Error("Error during graceful shutdown", "error", err)
}
}()
// List of services to run
services := make([]servicerunner.Service, 0, 3)
shutdowns := &shutdownManager{
fns: make([]servicerunner.Service, 0, 4),
}
// Initialize the observability stack, including the logger, distributed tracing, and metrics
shutdownFns, httpClient, err := initObservability(ctx, common.EnvConfig.MetricsEnabled, common.EnvConfig.TracingEnabled)
shutdownFns, httpClient, err := initObservability(ctx)
if err != nil {
return fmt.Errorf("failed to initialize OpenTelemetry: %w", err)
}
shutdowns.Add(shutdownFns...)
slog.InfoContext(ctx, "Pocket ID is starting")
db, err := NewDatabase()
// Init database
db, pg, err := NewDatabase(ctx)
if err != nil {
return fmt.Errorf("failed to initialize database: %w", err)
}
if pg != nil {
defer func() {
// Close the database connection pool only after the shutdown functions have run: some of them (e.g. releasing the application lock) still need to query the database.
pg.Close()
}()
}
// Init storage
fileStorage, err := InitStorage(ctx, db)
if err != nil {
return fmt.Errorf("failed to initialize file storage (backend: %s): %w", common.EnvConfig.FileBackend, err)
}
// Init application images
imageExtensions, err := initApplicationImages(ctx, fileStorage)
if err != nil {
return fmt.Errorf("failed to initialize application images: %w", err)
}
// Init the scheduler
scheduler, err := job.NewScheduler()
if err != nil {
return fmt.Errorf("failed to create job scheduler: %w", err)
@@ -60,7 +69,9 @@ func Bootstrap(ctx context.Context) error {
if err != nil {
return fmt.Errorf("failed to initialize services: %w", err)
}
services = append(services, svc.appLockService.RunRenewal)
// Acquire the lock from the app lock service
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")
@@ -74,40 +85,64 @@ func Bootstrap(ctx context.Context) error {
case <-time.After(time.Until(waitUntil)):
}
shutdownFn := func(shutdownCtx context.Context) error {
shutdowns.Add(func(shutdownCtx context.Context) error {
sErr := svc.appLockService.Release(shutdownCtx)
if sErr != nil {
return fmt.Errorf("failed to release application lock: %w", sErr)
}
return nil
}
shutdownFns = append(shutdownFns, shutdownFn)
})
// Register scheduled jobs
err = registerScheduledJobs(ctx, db, svc, httpClient, scheduler)
// Init the actors
actorsOpts := NewActorsOpts{
Postgres: pg,
EnvConfig: &common.EnvConfig,
AppConfig: svc.appConfigService,
HttpClient: httpClient,
DB: db,
FileStorage: fileStorage,
}
if pg == nil {
actorsOpts.SQLite, err = db.DB()
if err != nil {
return fmt.Errorf("failed to get *sql.DB connection from Gorm: %w", err)
}
}
actors, rateLimitServices, err := NewActors(actorsOpts)
if err != nil {
return fmt.Errorf("failed to register scheduled jobs: %w", err)
return fmt.Errorf("failed to initialize actors: %w", err)
}
services = append(services, actors.Run)
// Init the router
router, err := initRouter(db, svc)
if err != nil {
return fmt.Errorf("failed to initialize router: %w", err)
}
// Run all background services
// This call blocks until the context is canceled
services := []utils.Service{svc.appLockService.RunRenewal, router}
// Register scheduled jobs, only in non-test mode
if common.EnvConfig.AppEnv != "test" {
err = registerScheduledJobs(ctx, db, svc, scheduler)
if err != nil {
return fmt.Errorf("failed to register scheduled jobs: %w", err)
}
services = append(services, scheduler.Run)
}
err = utils.NewServiceRunner(services...).Run(ctx)
// Init the router
// The rate-limit middleware invokes the actor host with each request's own context, so the setup context is intentionally not threaded through the router
//nolint:contextcheck
router, err := initRouter(db, svc, rateLimitServices)
if err != nil {
return fmt.Errorf("failed to initialize router: %w", err)
}
services = append(services, router)
// Run all background services
// This call blocks until the context is canceled
err = servicerunner.NewServiceRunner(services...).Run(ctx)
if err != nil {
return fmt.Errorf("failed to run services: %w", err)
}
// Run all shutdown functions
shutdowns.Run(ctx)
return nil
}
@@ -138,3 +173,30 @@ func InitStorage(ctx context.Context, db *gorm.DB) (fileStorage storage.FileStor
return fileStorage, nil
}
type shutdownManager struct {
fns []servicerunner.Service
}
func (s *shutdownManager) Add(fns ...servicerunner.Service) {
for _, fn := range fns {
if fn == nil {
continue
}
s.fns = append(s.fns, fn)
}
}
func (s *shutdownManager) Run(ctx context.Context) {
// Cleanup functions are one-shot and must each run to completion independently, so we set WaitAll to true
sr := servicerunner.NewServiceRunner(s.fns...)
sr.WaitAll = true
shutdownCtx, shutdownCancel := context.WithTimeout(context.WithoutCancel(ctx), 5*time.Second)
defer shutdownCancel()
err := sr.Run(shutdownCtx)
if err != nil {
slog.ErrorContext(ctx, "Error shutting down services", slog.Any("error", err))
}
}

View File

@@ -1,74 +1,76 @@
package bootstrap
import (
"context"
"database/sql"
"errors"
"fmt"
"log/slog"
"net/url"
"os"
"path/filepath"
"strings"
"time"
"github.com/glebarez/sqlite"
_ "github.com/golang-migrate/migrate/v4/source/github"
sqlitekit "github.com/italypaleale/go-sql-utils/sqlite"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/jackc/pgx/v5/stdlib"
"github.com/libtnb/sqlite"
slogGorm "github.com/orandin/slog-gorm"
"gorm.io/driver/postgres"
"gorm.io/gorm"
gormLogger "gorm.io/gorm/logger"
tracingGorm "gorm.io/plugin/opentelemetry/tracing"
"github.com/pocket-id/pocket-id/backend/internal/common"
"github.com/pocket-id/pocket-id/backend/internal/utils"
sqliteutil "github.com/pocket-id/pocket-id/backend/internal/utils/sqlite"
)
func NewDatabase() (db *gorm.DB, err error) {
db, err = ConnectDatabase()
func NewDatabase(ctx context.Context) (db *gorm.DB, pg *pgxpool.Pool, err error) {
db, pg, err = ConnectDatabase(ctx)
if err != nil {
return nil, fmt.Errorf("failed to connect to database: %w", err)
return nil, nil, fmt.Errorf("failed to connect to database: %w", err)
}
sqlDb, err := db.DB()
if err != nil {
return nil, fmt.Errorf("failed to get sql.DB: %w", err)
return nil, nil, fmt.Errorf("failed to get sql.DB: %w", err)
}
// Run migrations
err = utils.MigrateDatabase(sqlDb)
err = utils.MigrateDatabase(ctx, sqlDb)
if err != nil {
return nil, fmt.Errorf("failed to run migrations: %w", err)
return nil, nil, fmt.Errorf("failed to run migrations: %w", err)
}
return db, nil
return db, pg, nil
}
func ConnectDatabase() (db *gorm.DB, err error) {
var (
dialector gorm.Dialector
sqliteNetworkFilesystem bool
)
//nolint:gocognit
func ConnectDatabase(ctx context.Context) (db *gorm.DB, pg *pgxpool.Pool, err error) {
var dialector gorm.Dialector
// Choose the correct database provider
var onConnFn func(conn *sql.DB)
switch common.EnvConfig.DbProvider {
case common.DbProviderSqlite:
if common.EnvConfig.DbConnectionString == "" {
return nil, errors.New("missing required env var 'DB_CONNECTION_STRING' for SQLite database")
return nil, nil, errors.New("missing required env var 'DB_CONNECTION_STRING' for SQLite database")
}
sqliteutil.RegisterSqliteFunctions()
connString, dbPath, isMemoryDB, err := parseSqliteConnectionString(common.EnvConfig.DbConnectionString)
connString, dbPath, isMemoryDB, err := sqlitekit.ParseConnectionString(common.EnvConfig.DbConnectionString, slog.Default())
if err != nil {
return nil, err
return nil, nil, err
}
if !isMemoryDB {
if err := ensureSqliteDatabaseDir(dbPath); err != nil {
return nil, err
err = sqlitekit.EnsureDatabaseDir(dbPath)
if err != nil {
return nil, nil, err
}
sqliteNetworkFilesystem, err = utils.IsNetworkedFileSystem(filepath.Dir(dbPath))
var sqliteNetworkFilesystem bool
sqliteNetworkFilesystem, err = sqlitekit.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))
@@ -78,9 +80,9 @@ func ConnectDatabase() (db *gorm.DB, err error) {
}
// Before we connect, also make sure that there's a temporary folder for SQLite to write its data
err = ensureSqliteTempDir(filepath.Dir(dbPath))
err = sqlitekit.EnsureTempDir(filepath.Dir(dbPath), slog.Default())
if err != nil {
return nil, err
return nil, nil, err
}
if isMemoryDB {
@@ -94,13 +96,33 @@ func ConnectDatabase() (db *gorm.DB, err error) {
dialector = sqlite.Open(connString)
case common.DbProviderPostgres:
if common.EnvConfig.DbConnectionString == "" {
return nil, errors.New("missing required env var 'DB_CONNECTION_STRING' for Postgres database")
return nil, nil, errors.New("missing required env var 'DB_CONNECTION_STRING' for Postgres database")
}
dialector = postgres.Open(common.EnvConfig.DbConnectionString)
// We need a pgxpool object for francis, so we open this as a pgxpool...
pg, err = pgxpool.New(ctx, common.EnvConfig.DbConnectionString)
if err != nil {
return nil, nil, fmt.Errorf("failed to create Postgres pool: %w", err)
}
// ...test it with a ping...
pingCtx, pingCancel := context.WithTimeout(ctx, 10*time.Second)
defer pingCancel()
err = pg.Ping(pingCtx)
if err != nil {
pg.Close()
return nil, nil, fmt.Errorf("failed to ping Postgres database: %w", err)
}
// ...then create the dialector by adapting it to *sql.DB
dialector = postgres.New(postgres.Config{
Conn: stdlib.OpenDBFromPool(pg),
})
default:
return nil, fmt.Errorf("unsupported database provider: %s", common.EnvConfig.DbProvider)
return nil, nil, fmt.Errorf("unsupported database provider: %s", common.EnvConfig.DbProvider)
}
// Try connecting up to 3 times
for i := 1; i <= 3; i++ {
db, err = gorm.Open(dialector, &gorm.Config{
TranslateError: true,
@@ -109,268 +131,40 @@ func ConnectDatabase() (db *gorm.DB, err error) {
if err == nil {
slog.Info("Connected to database", slog.String("provider", string(common.EnvConfig.DbProvider)))
// Configure tracing and metrics
err = db.Use(tracingGorm.NewPlugin())
if err != nil {
return nil, nil, fmt.Errorf("failed to configure tracing for DB: %w", err)
}
// Invoke the onConnFn callback if any
if onConnFn != nil {
conn, err := db.DB()
if err != nil {
slog.Warn("Failed to get database connection, will retry in 3s", slog.Int("attempt", i), slog.String("provider", string(common.EnvConfig.DbProvider)), slog.Any("error", err))
time.Sleep(3 * time.Second)
if pg != nil {
pg.Close()
}
return nil, nil, fmt.Errorf("failed to get database connection for onConnFn callback: %w", err)
}
onConnFn(conn)
}
return db, nil
return db, pg, nil
}
// If we're here, the connection failed
slog.Warn("Failed to connect to database, will retry in 3s", slog.Int("attempt", i), slog.String("provider", string(common.EnvConfig.DbProvider)), slog.Any("error", err))
time.Sleep(3 * time.Second)
}
slog.Error("Failed to connect to database after 3 attempts", slog.String("provider", string(common.EnvConfig.DbProvider)), slog.Any("error", err))
return nil, err
}
func parseSqliteConnectionString(connString string) (parsedConnString string, dbPath string, isMemoryDB bool, err error) {
if !strings.HasPrefix(connString, "file:") {
connString = "file:" + connString
if pg != nil {
pg.Close()
}
// Check if we're using an in-memory database
isMemoryDB = isSqliteInMemory(connString)
// Parse the connection string
connStringUrl, err := url.Parse(connString)
if err != nil {
return "", "", false, fmt.Errorf("failed to parse SQLite connection string: %w", err)
}
// Convert options for the old SQLite driver to the new one
convertSqlitePragmaArgs(connStringUrl)
// Add the default and required params
err = addSqliteDefaultParameters(connStringUrl, isMemoryDB)
if err != nil {
return "", "", false, fmt.Errorf("invalid SQLite connection string: %w", err)
}
// Get the absolute path to the database
// Here, we know for a fact that the ? is present
parsedConnString = connStringUrl.String()
idx := strings.IndexRune(parsedConnString, '?')
dbPath, err = filepath.Abs(parsedConnString[len("file:"):idx])
if err != nil {
return "", "", false, fmt.Errorf("failed to determine absolute path to the database: %w", err)
}
return parsedConnString, dbPath, isMemoryDB, nil
}
// The official C implementation of SQLite allows some additional properties in the connection string
// that are not supported in the in the modernc.org/sqlite driver, and which must be passed as PRAGMA args instead.
// To ensure that people can use similar args as in the C driver, which was also used by Pocket ID
// previously (via github.com/mattn/go-sqlite3), we are converting some options.
// Note this function updates connStringUrl.
func convertSqlitePragmaArgs(connStringUrl *url.URL) {
// Reference: https://github.com/mattn/go-sqlite3?tab=readme-ov-file#connection-string
// This only includes a subset of options, excluding those that are not relevant to us
qs := make(url.Values, len(connStringUrl.Query()))
for k, v := range connStringUrl.Query() {
switch strings.ToLower(k) {
case "_auto_vacuum", "_vacuum":
qs.Add("_pragma", "auto_vacuum("+v[0]+")")
case "_busy_timeout", "_timeout":
qs.Add("_pragma", "busy_timeout("+v[0]+")")
case "_case_sensitive_like", "_cslike":
qs.Add("_pragma", "case_sensitive_like("+v[0]+")")
case "_foreign_keys", "_fk":
qs.Add("_pragma", "foreign_keys("+v[0]+")")
case "_locking_mode", "_locking":
qs.Add("_pragma", "locking_mode("+v[0]+")")
case "_secure_delete":
qs.Add("_pragma", "secure_delete("+v[0]+")")
case "_synchronous", "_sync":
qs.Add("_pragma", "synchronous("+v[0]+")")
default:
// Pass other query-string args as-is
qs[k] = v
}
}
// Update the connStringUrl object
connStringUrl.RawQuery = qs.Encode()
}
// Adds the default (and some required) parameters to the SQLite connection string.
// Note this function updates connStringUrl.
func addSqliteDefaultParameters(connStringUrl *url.URL, isMemoryDB bool) error {
// This function include code adapted from https://github.com/dapr/components-contrib/blob/v1.14.6/
// Copyright (C) 2023 The Dapr Authors
// License: Apache2
const defaultBusyTimeout = 2500 * time.Millisecond
// Get the "query string" from the connection string if present
qs := connStringUrl.Query()
if len(qs) == 0 {
qs = make(url.Values, 2)
}
// Check if the database is read-only or immutable
isReadOnly := false
if len(qs["mode"]) > 0 {
// Keep the first value only
qs["mode"] = []string{
strings.ToLower(qs["mode"][0]),
}
if qs["mode"][0] == "ro" {
isReadOnly = true
}
}
if len(qs["immutable"]) > 0 {
// Keep the first value only
qs["immutable"] = []string{
strings.ToLower(qs["immutable"][0]),
}
if qs["immutable"][0] == "1" {
isReadOnly = true
}
}
// We do not want to override a _txlock if set, but we'll show a warning if it's not "immediate"
if len(qs["_txlock"]) > 0 {
// Keep the first value only
qs["_txlock"] = []string{
strings.ToLower(qs["_txlock"][0]),
}
if qs["_txlock"][0] != "immediate" {
slog.Warn("SQLite connection is being created with a _txlock different from the recommended value 'immediate'")
}
} else {
qs["_txlock"] = []string{"immediate"}
}
// Add pragma values
var hasBusyTimeout, hasJournalMode bool
if len(qs["_pragma"]) == 0 {
qs["_pragma"] = make([]string, 0, 3)
} else {
for _, p := range qs["_pragma"] {
p = strings.ToLower(p)
switch {
case strings.HasPrefix(p, "busy_timeout"):
hasBusyTimeout = true
case strings.HasPrefix(p, "journal_mode"):
hasJournalMode = true
case strings.HasPrefix(p, "foreign_keys"):
return errors.New("found forbidden option '_pragma=foreign_keys' in the connection string")
}
}
}
if !hasBusyTimeout {
qs["_pragma"] = append(qs["_pragma"], fmt.Sprintf("busy_timeout(%d)", defaultBusyTimeout.Milliseconds()))
}
if !hasJournalMode {
switch {
case isMemoryDB:
// For in-memory databases, set the journal to MEMORY, the only allowed option besides OFF (which would make transactions ineffective)
qs["_pragma"] = append(qs["_pragma"], "journal_mode(MEMORY)")
case isReadOnly:
// Set the journaling mode to "DELETE" (the default) if the database is read-only
qs["_pragma"] = append(qs["_pragma"], "journal_mode(DELETE)")
default:
// Enable WAL
qs["_pragma"] = append(qs["_pragma"], "journal_mode(WAL)")
}
}
// Forcefully enable foreign keys
qs["_pragma"] = append(qs["_pragma"], "foreign_keys(1)")
// Update the connStringUrl object
connStringUrl.RawQuery = qs.Encode()
return nil
}
// isSqliteInMemory returns true if the connection string is for an in-memory database.
func isSqliteInMemory(connString string) bool {
lc := strings.ToLower(connString)
// First way to define an in-memory database is to use ":memory:" or "file::memory:" as connection string
if strings.HasPrefix(lc, ":memory:") || strings.HasPrefix(lc, "file::memory:") {
return true
}
// Another way is to pass "mode=memory" in the "query string"
idx := strings.IndexRune(lc, '?')
if idx < 0 {
return false
}
qs, _ := url.ParseQuery(lc[(idx + 1):])
return len(qs["mode"]) > 0 && qs["mode"][0] == "memory"
}
// ensureSqliteDatabaseDir creates the parent directory for the SQLite database file if it doesn't exist yet
func ensureSqliteDatabaseDir(dbPath string) error {
dir := filepath.Dir(dbPath)
info, err := os.Stat(dir)
switch {
case err == nil:
if !info.IsDir() {
return fmt.Errorf("SQLite database directory '%s' is not a directory", dir)
}
return nil
case os.IsNotExist(err):
if err := os.MkdirAll(dir, 0700); err != nil {
return fmt.Errorf("failed to create SQLite database directory '%s': %w", dir, err)
}
return nil
default:
return fmt.Errorf("failed to check SQLite database directory '%s': %w", dir, err)
}
}
// ensureSqliteTempDir ensures that SQLite has a directory where it can write temporary files if needed
// The default directory may not be writable when using a container with a read-only root file system
// See: https://www.sqlite.org/tempfiles.html
func ensureSqliteTempDir(dbPath string) error {
// Per docs, SQLite tries these folders in order (excluding those that aren't applicable to us):
//
// - The SQLITE_TMPDIR environment variable
// - The TMPDIR environment variable
// - /var/tmp
// - /usr/tmp
// - /tmp
//
// Source: https://www.sqlite.org/tempfiles.html#temporary_file_storage_locations
//
// First, let's check if SQLITE_TMPDIR or TMPDIR are set, in which case we trust the user has taken care of the problem already
if os.Getenv("SQLITE_TMPDIR") != "" || os.Getenv("TMPDIR") != "" {
return nil
}
// Now, let's check if /var/tmp, /usr/tmp, or /tmp exist and are writable
for _, dir := range []string{"/var/tmp", "/usr/tmp", "/tmp"} {
ok, err := utils.IsWritableDir(dir)
if err != nil {
return fmt.Errorf("failed to check if %s is writable: %w", dir, err)
}
if ok {
// We found a folder that's writable
return nil
}
}
// If we're here, there's no temporary directory that's writable (not unusual for containers with a read-only root file system), so we set SQLITE_TMPDIR to the folder where the SQLite database is set
err := os.Setenv("SQLITE_TMPDIR", dbPath)
if err != nil {
return fmt.Errorf("failed to set SQLITE_TMPDIR environmental variable: %w", err)
}
slog.Debug("Set SQLITE_TMPDIR to the database directory", "path", dbPath)
return nil
return nil, nil, err
}
func getGormLogger() gormLogger.Interface {

View File

@@ -1,349 +0,0 @@
package bootstrap
import (
"net/url"
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestIsSqliteInMemory(t *testing.T) {
tests := []struct {
name string
connStr string
expected bool
}{
{
name: "memory database with :memory:",
connStr: ":memory:",
expected: true,
},
{
name: "memory database with file::memory:",
connStr: "file::memory:",
expected: true,
},
{
name: "memory database with :MEMORY: (uppercase)",
connStr: ":MEMORY:",
expected: true,
},
{
name: "memory database with FILE::MEMORY: (uppercase)",
connStr: "FILE::MEMORY:",
expected: true,
},
{
name: "memory database with mixed case",
connStr: ":Memory:",
expected: true,
},
{
name: "has mode=memory",
connStr: "file:data?mode=memory",
expected: true,
},
{
name: "file database",
connStr: "data.db",
expected: false,
},
{
name: "file database with path",
connStr: "/path/to/data.db",
expected: false,
},
{
name: "file database with file: prefix",
connStr: "file:data.db",
expected: false,
},
{
name: "empty string",
connStr: "",
expected: false,
},
{
name: "string containing memory but not at start",
connStr: "data:memory:.db",
expected: false,
},
{
name: "has mode=ro",
connStr: "file:data?mode=ro",
expected: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := isSqliteInMemory(tt.connStr)
assert.Equal(t, tt.expected, result)
})
}
}
func TestEnsureSqliteDatabaseDir(t *testing.T) {
t.Run("creates missing directory", func(t *testing.T) {
tempDir := t.TempDir()
dbPath := filepath.Join(tempDir, "nested", "pocket-id.db")
err := ensureSqliteDatabaseDir(dbPath)
require.NoError(t, err)
info, err := os.Stat(filepath.Dir(dbPath))
require.NoError(t, err)
assert.True(t, info.IsDir())
})
t.Run("fails when parent is file", func(t *testing.T) {
tempDir := t.TempDir()
filePath := filepath.Join(tempDir, "file.txt")
require.NoError(t, os.WriteFile(filePath, []byte("test"), 0o600))
err := ensureSqliteDatabaseDir(filepath.Join(filePath, "data.db"))
require.Error(t, err)
})
}
func TestConvertSqlitePragmaArgs(t *testing.T) {
tests := []struct {
name string
input string
expected string
}{
{
name: "basic file path",
input: "file:test.db",
expected: "file:test.db",
},
{
name: "converts _busy_timeout to pragma",
input: "file:test.db?_busy_timeout=5000",
expected: "file:test.db?_pragma=busy_timeout%285000%29",
},
{
name: "converts _timeout to pragma",
input: "file:test.db?_timeout=5000",
expected: "file:test.db?_pragma=busy_timeout%285000%29",
},
{
name: "converts _foreign_keys to pragma",
input: "file:test.db?_foreign_keys=1",
expected: "file:test.db?_pragma=foreign_keys%281%29",
},
{
name: "converts _fk to pragma",
input: "file:test.db?_fk=1",
expected: "file:test.db?_pragma=foreign_keys%281%29",
},
{
name: "converts _synchronous to pragma",
input: "file:test.db?_synchronous=NORMAL",
expected: "file:test.db?_pragma=synchronous%28NORMAL%29",
},
{
name: "converts _sync to pragma",
input: "file:test.db?_sync=NORMAL",
expected: "file:test.db?_pragma=synchronous%28NORMAL%29",
},
{
name: "converts _auto_vacuum to pragma",
input: "file:test.db?_auto_vacuum=FULL",
expected: "file:test.db?_pragma=auto_vacuum%28FULL%29",
},
{
name: "converts _vacuum to pragma",
input: "file:test.db?_vacuum=FULL",
expected: "file:test.db?_pragma=auto_vacuum%28FULL%29",
},
{
name: "converts _case_sensitive_like to pragma",
input: "file:test.db?_case_sensitive_like=1",
expected: "file:test.db?_pragma=case_sensitive_like%281%29",
},
{
name: "converts _cslike to pragma",
input: "file:test.db?_cslike=1",
expected: "file:test.db?_pragma=case_sensitive_like%281%29",
},
{
name: "converts _locking_mode to pragma",
input: "file:test.db?_locking_mode=EXCLUSIVE",
expected: "file:test.db?_pragma=locking_mode%28EXCLUSIVE%29",
},
{
name: "converts _locking to pragma",
input: "file:test.db?_locking=EXCLUSIVE",
expected: "file:test.db?_pragma=locking_mode%28EXCLUSIVE%29",
},
{
name: "converts _secure_delete to pragma",
input: "file:test.db?_secure_delete=1",
expected: "file:test.db?_pragma=secure_delete%281%29",
},
{
name: "preserves unrecognized parameters",
input: "file:test.db?mode=rw&cache=shared",
expected: "file:test.db?cache=shared&mode=rw",
},
{
name: "handles multiple parameters",
input: "file:test.db?_fk=1&mode=rw&_timeout=5000",
expected: "file:test.db?_pragma=foreign_keys%281%29&_pragma=busy_timeout%285000%29&mode=rw",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
resultURL, _ := url.Parse(tt.input)
convertSqlitePragmaArgs(resultURL)
// Parse both URLs to compare components independently
expectedURL, err := url.Parse(tt.expected)
require.NoError(t, err)
// Compare scheme and path components
compareQueryStrings(t, expectedURL, resultURL)
})
}
}
func TestAddSqliteDefaultParameters(t *testing.T) {
tests := []struct {
name string
input string
isMemoryDB bool
expected string
expectError bool
}{
{
name: "basic file database",
input: "file:test.db",
isMemoryDB: false,
expected: "file:test.db?_pragma=busy_timeout%282500%29&_pragma=foreign_keys%281%29&_pragma=journal_mode%28WAL%29&_txlock=immediate",
},
{
name: "in-memory database",
input: "file::memory:",
isMemoryDB: true,
expected: "file::memory:?_pragma=busy_timeout%282500%29&_pragma=foreign_keys%281%29&_pragma=journal_mode%28MEMORY%29&_txlock=immediate",
},
{
name: "read-only database with mode=ro",
input: "file:test.db?mode=ro",
isMemoryDB: false,
expected: "file:test.db?_pragma=busy_timeout%282500%29&_pragma=foreign_keys%281%29&_pragma=journal_mode%28DELETE%29&_txlock=immediate&mode=ro",
},
{
name: "immutable database",
input: "file:test.db?immutable=1",
isMemoryDB: false,
expected: "file:test.db?_pragma=busy_timeout%282500%29&_pragma=foreign_keys%281%29&_pragma=journal_mode%28DELETE%29&_txlock=immediate&immutable=1",
},
{
name: "database with existing _txlock",
input: "file:test.db?_txlock=deferred",
isMemoryDB: false,
expected: "file:test.db?_pragma=busy_timeout%282500%29&_pragma=foreign_keys%281%29&_pragma=journal_mode%28WAL%29&_txlock=deferred",
},
{
name: "database with existing busy_timeout pragma",
input: "file:test.db?_pragma=busy_timeout%285000%29",
isMemoryDB: false,
expected: "file:test.db?_pragma=busy_timeout%285000%29&_pragma=foreign_keys%281%29&_pragma=journal_mode%28WAL%29&_txlock=immediate",
},
{
name: "database with existing journal_mode pragma",
input: "file:test.db?_pragma=journal_mode%28DELETE%29",
isMemoryDB: false,
expected: "file:test.db?_pragma=busy_timeout%282500%29&_pragma=foreign_keys%281%29&_pragma=journal_mode%28DELETE%29&_txlock=immediate",
},
{
name: "database with forbidden foreign_keys pragma",
input: "file:test.db?_pragma=foreign_keys%280%29",
isMemoryDB: false,
expectError: true,
},
{
name: "database with multiple existing pragmas",
input: "file:test.db?_pragma=busy_timeout%283000%29&_pragma=journal_mode%28TRUNCATE%29&_pragma=synchronous%28NORMAL%29",
isMemoryDB: false,
expected: "file:test.db?_pragma=busy_timeout%283000%29&_pragma=foreign_keys%281%29&_pragma=journal_mode%28TRUNCATE%29&_pragma=synchronous%28NORMAL%29&_txlock=immediate",
},
{
name: "database with mode=rw (not read-only)",
input: "file:test.db?mode=rw",
isMemoryDB: false,
expected: "file:test.db?_pragma=busy_timeout%282500%29&_pragma=foreign_keys%281%29&_pragma=journal_mode%28WAL%29&_txlock=immediate&mode=rw",
},
{
name: "database with immutable=0 (not immutable)",
input: "file:test.db?immutable=0",
isMemoryDB: false,
expected: "file:test.db?_pragma=busy_timeout%282500%29&_pragma=foreign_keys%281%29&_pragma=journal_mode%28WAL%29&_txlock=immediate&immutable=0",
},
{
name: "database with mixed case mode=RO",
input: "file:test.db?mode=RO",
isMemoryDB: false,
expected: "file:test.db?_pragma=busy_timeout%282500%29&_pragma=foreign_keys%281%29&_pragma=journal_mode%28DELETE%29&_txlock=immediate&mode=ro",
},
{
name: "database with mixed case immutable=1",
input: "file:test.db?immutable=1",
isMemoryDB: false,
expected: "file:test.db?_pragma=busy_timeout%282500%29&_pragma=foreign_keys%281%29&_pragma=journal_mode%28DELETE%29&_txlock=immediate&immutable=1",
},
{
name: "complex database configuration",
input: "file:test.db?cache=shared&mode=rwc&_txlock=immediate&_pragma=synchronous%28FULL%29",
isMemoryDB: false,
expected: "file:test.db?_pragma=busy_timeout%282500%29&_pragma=foreign_keys%281%29&_pragma=journal_mode%28WAL%29&_pragma=synchronous%28FULL%29&_txlock=immediate&cache=shared&mode=rwc",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
resultURL, err := url.Parse(tt.input)
require.NoError(t, err)
err = addSqliteDefaultParameters(resultURL, tt.isMemoryDB)
if tt.expectError {
require.Error(t, err)
return
}
require.NoError(t, err)
expectedURL, err := url.Parse(tt.expected)
require.NoError(t, err)
compareQueryStrings(t, expectedURL, resultURL)
})
}
}
func compareQueryStrings(t *testing.T, expectedURL *url.URL, resultURL *url.URL) {
t.Helper()
// Compare scheme and path components
assert.Equal(t, expectedURL.Scheme, resultURL.Scheme)
assert.Equal(t, expectedURL.Path, resultURL.Path)
// Compare query parameters regardless of order
expectedQuery := expectedURL.Query()
resultQuery := resultURL.Query()
assert.Len(t, expectedQuery, len(resultQuery))
for key, expectedValues := range expectedQuery {
resultValues, ok := resultQuery[key]
_ = assert.True(t, ok) &&
assert.ElementsMatch(t, expectedValues, resultValues)
}
}

View File

@@ -9,7 +9,7 @@ import (
"time"
sloggin "github.com/gin-contrib/slog"
"github.com/italypaleale/go-kit/servicerunner"
"github.com/lmittmann/tint"
"github.com/mattn/go-isatty"
"go.opentelemetry.io/contrib/bridges/otelslog"
@@ -17,17 +17,14 @@ import (
"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
"go.opentelemetry.io/otel"
globallog "go.opentelemetry.io/otel/log/global"
metricnoop "go.opentelemetry.io/otel/metric/noop"
"go.opentelemetry.io/otel/propagation"
sdklog "go.opentelemetry.io/otel/sdk/log"
"go.opentelemetry.io/otel/sdk/metric"
"go.opentelemetry.io/otel/sdk/resource"
sdktrace "go.opentelemetry.io/otel/sdk/trace"
semconv "go.opentelemetry.io/otel/semconv/v1.30.0"
tracenoop "go.opentelemetry.io/otel/trace/noop"
"github.com/pocket-id/pocket-id/backend/internal/common"
"github.com/pocket-id/pocket-id/backend/internal/utils"
)
func defaultResource() (*resource.Resource, error) {
@@ -40,13 +37,13 @@ func defaultResource() (*resource.Resource, error) {
)
}
func initObservability(ctx context.Context, metrics, traces bool) (shutdownFns []utils.Service, httpClient *http.Client, err error) {
func initObservability(ctx context.Context) (shutdownFns []servicerunner.Service, httpClient *http.Client, err error) {
resource, err := defaultResource()
if err != nil {
return nil, nil, fmt.Errorf("failed to create OpenTelemetry resource: %w", err)
}
shutdownFns = make([]utils.Service, 0, 2)
shutdownFns = make([]servicerunner.Service, 0, 3)
httpClient = &http.Client{}
defaultTransport, ok := http.DefaultTransport.(*http.Transport)
@@ -57,13 +54,15 @@ func initObservability(ctx context.Context, metrics, traces bool) (shutdownFns [
httpClient.Transport = defaultTransport.Clone()
// Logging
err = initOtelLogging(ctx, resource)
loggingShutdownFn, err := initOtelLogging(ctx, resource)
if err != nil {
return nil, nil, err
} else if loggingShutdownFn != nil {
shutdownFns = append(shutdownFns, loggingShutdownFn)
}
// Tracing
tracingShutdownFn, err := initOtelTracing(ctx, traces, resource, httpClient)
tracingShutdownFn, err := initOtelTracing(ctx, resource, httpClient)
if err != nil {
return nil, nil, err
} else if tracingShutdownFn != nil {
@@ -71,7 +70,7 @@ func initObservability(ctx context.Context, metrics, traces bool) (shutdownFns [
}
// Metrics
metricsShutdownFn, err := initOtelMetrics(ctx, metrics, resource)
metricsShutdownFn, err := initOtelMetrics(ctx, resource)
if err != nil {
return nil, nil, err
} else if metricsShutdownFn != nil {
@@ -81,19 +80,19 @@ func initObservability(ctx context.Context, metrics, traces bool) (shutdownFns [
return shutdownFns, httpClient, nil
}
func initOtelLogging(ctx context.Context, resource *resource.Resource) error {
func initOtelLogging(ctx context.Context, resource *resource.Resource) (shutdownFn servicerunner.Service, err error) {
// If the env var OTEL_LOGS_EXPORTER is empty, we set it to "none", for autoexport to work
if os.Getenv("OTEL_LOGS_EXPORTER") == "" {
os.Setenv("OTEL_LOGS_EXPORTER", "none")
}
exp, err := autoexport.NewLogExporter(ctx)
if err != nil {
return fmt.Errorf("failed to initialize OpenTelemetry log exporter: %w", err)
return nil, fmt.Errorf("failed to initialize OpenTelemetry log exporter: %w", err)
}
level, _ := sloggin.ParseLevel(common.EnvConfig.LogLevel)
// Create the handler
// Create the console handler
var handler slog.Handler
if common.EnvConfig.LogJSON {
handler = slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
@@ -107,21 +106,35 @@ func initOtelLogging(ctx context.Context, resource *resource.Resource) error {
})
}
// Create the logger provider
provider := sdklog.NewLoggerProvider(
sdklog.WithProcessor(
sdklog.NewBatchProcessor(exp),
),
sdklog.WithResource(resource),
)
// When log export is enabled, also send logs to OpenTelemetry through a batch processor.
// When it's disabled (the exporter is a no-op), we skip the OTel handler entirely so we don't convert and buffer every log record just to drop it.
if !autoexport.IsNoneLogExporter(exp) {
// Create the logger provider
provider := sdklog.NewLoggerProvider(
sdklog.WithProcessor(
sdklog.NewBatchProcessor(exp),
),
sdklog.WithResource(resource),
)
// Set the logger provider globally
globallog.SetLoggerProvider(provider)
// Set the logger provider globally
globallog.SetLoggerProvider(provider)
handler = slog.NewMultiHandler(
handler,
otelslog.NewHandler(common.Name, otelslog.WithLoggerProvider(provider)),
)
handler = slog.NewMultiHandler(
handler,
otelslog.NewHandler(common.Name, otelslog.WithLoggerProvider(provider)),
)
shutdownFn = func(shutdownCtx context.Context) error { //nolint:contextcheck
lpCtx, lpCancel := context.WithTimeout(shutdownCtx, 10*time.Second)
defer lpCancel()
shutdownErr := provider.Shutdown(lpCtx)
if shutdownErr != nil {
return fmt.Errorf("failed to gracefully shut down logs exporter: %w", shutdownErr)
}
return nil
}
}
// Set the default slog to send logs to OTel and add the app name
log := slog.New(handler).
@@ -129,13 +142,13 @@ func initOtelLogging(ctx context.Context, resource *resource.Resource) error {
With(slog.String("version", common.Version))
slog.SetDefault(log)
return nil
return shutdownFn, nil
}
func initOtelTracing(ctx context.Context, traces bool, resource *resource.Resource, httpClient *http.Client) (shutdownFn utils.Service, err error) {
if !traces {
otel.SetTracerProvider(tracenoop.NewTracerProvider())
return nil, nil
func initOtelTracing(ctx context.Context, resource *resource.Resource, httpClient *http.Client) (shutdownFn servicerunner.Service, err error) {
// If the env var OTEL_TRACES_EXPORTER is empty, we set it to "none"
if os.Getenv("OTEL_TRACES_EXPORTER") == "" {
os.Setenv("OTEL_TRACES_EXPORTER", "none")
}
tr, err := autoexport.NewSpanExporter(ctx)
@@ -171,10 +184,10 @@ func initOtelTracing(ctx context.Context, traces bool, resource *resource.Resour
return shutdownFn, nil
}
func initOtelMetrics(ctx context.Context, metrics bool, resource *resource.Resource) (shutdownFn utils.Service, err error) {
if !metrics {
otel.SetMeterProvider(metricnoop.NewMeterProvider())
return nil, nil
func initOtelMetrics(ctx context.Context, resource *resource.Resource) (shutdownFn servicerunner.Service, err error) {
// If the env var OTEL_METRICS_EXPORTER is empty, we set it to "none"
if os.Getenv("OTEL_METRICS_EXPORTER") == "" {
os.Setenv("OTEL_METRICS_EXPORTER", "none")
}
mr, err := autoexport.NewMetricReader(ctx)

View File

@@ -17,27 +17,28 @@ import (
"github.com/fsnotify/fsnotify"
sloggin "github.com/gin-contrib/slog"
"github.com/gin-gonic/gin"
"github.com/italypaleale/francis/builtin/ratelimit"
"github.com/italypaleale/go-kit/servicerunner"
"go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin"
"golang.org/x/time/rate"
"gorm.io/gorm"
"github.com/pocket-id/pocket-id/backend/frontend"
"github.com/pocket-id/pocket-id/backend/internal/common"
"github.com/pocket-id/pocket-id/backend/internal/controller"
"github.com/pocket-id/pocket-id/backend/internal/middleware"
"github.com/pocket-id/pocket-id/backend/internal/utils"
"github.com/pocket-id/pocket-id/backend/internal/tracing"
"github.com/pocket-id/pocket-id/backend/internal/utils/systemd"
)
// This is used to register additional controllers for tests
var registerTestControllers []func(apiGroup *gin.RouterGroup, db *gorm.DB, svc *services)
func initRouter(db *gorm.DB, svc *services) (utils.Service, error) {
func initRouter(db *gorm.DB, svc *services, rateLimitServices map[string]*ratelimit.RateLimitService) (servicerunner.Service, error) {
r, err := initEngine()
if err != nil {
return nil, err
}
err = registerRoutes(r, db, svc)
err = registerRoutes(r, db, svc, rateLimitServices)
if err != nil {
return nil, err
}
@@ -94,8 +95,24 @@ func configureEngine(r *gin.Engine) {
r.TrustedPlatform = common.EnvConfig.TrustedPlatform
}
if common.EnvConfig.TracingEnabled {
r.Use(otelgin.Middleware(common.Name))
r.Use(otelgin.Middleware(
common.Name,
otelgin.WithFilter(shouldTraceRequest)),
)
}
// shouldTraceRequest reports whether an incoming request should be traced.
// It traces only requests handled by real backend routes (the API, the OIDC/OAuth endpoints, and the well-known documents).
// Everything else falls through to the frontend NoRoute handler, which serves the SPA shell and static assets; tracing those would produce noisy, unparented spans named just "GET" with an empty http.route.
func shouldTraceRequest(r *http.Request) bool {
p := r.URL.Path
switch {
case strings.HasPrefix(p, "/api/"),
strings.HasPrefix(p, "/.well-known/"),
p == "/authorize":
return true
default:
return false
}
}
@@ -107,9 +124,9 @@ func registerGlobalMiddleware(r *gin.Engine) {
r.Use(middleware.NewErrorHandlerMiddleware().Add())
}
func registerRoutes(r *gin.Engine, db *gorm.DB, svc *services) error {
func registerRoutes(r *gin.Engine, db *gorm.DB, svc *services, rateLimitServices map[string]*ratelimit.RateLimitService) error {
err := frontend.RegisterFrontend(r, svc.oidcService)
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 {
@@ -117,32 +134,53 @@ func registerRoutes(r *gin.Engine, db *gorm.DB, svc *services) error {
}
// Initialize middleware for specific routes
authMiddleware := middleware.NewAuthMiddleware(svc.apiKeyService, svc.userService, svc.jwtService)
authMiddleware := middleware.NewAuthMiddleware(svc.apiKeyModule, svc.userService, svc.jwtService)
fileSizeLimitMiddleware := middleware.NewFileSizeLimitMiddleware()
apiRateLimitMiddleware := middleware.NewRateLimitMiddleware().Add(rate.Every(time.Second), 100)
rateLimitMiddleware := middleware.NewRateLimitMiddleware(rateLimitServices)
apiRateLimitMiddleware := rateLimitMiddleware.Add(middleware.RateLimitAPI)
apiGroup := r.Group("/api", apiRateLimitMiddleware)
controller.NewApiKeyController(apiGroup, authMiddleware, svc.apiKeyService)
controller.NewWebauthnController(apiGroup, authMiddleware, middleware.NewRateLimitMiddleware(), svc.webauthnService, svc.appConfigService)
controller.NewOidcController(apiGroup, authMiddleware, fileSizeLimitMiddleware, svc.oidcService, svc.jwtService)
controller.NewUserController(apiGroup, authMiddleware, middleware.NewRateLimitMiddleware(), svc.userService, svc.oneTimeAccessService, svc.webauthnService, svc.appConfigService)
baseGroup := r.Group("/", apiRateLimitMiddleware)
svc.apiKeyModule.RegisterRoutes(apiGroup,
authMiddleware.WithAdminNotRequired().Add(),
authMiddleware.WithAdminNotRequired().WithApiKeyAuthDisabled().Add(),
)
svc.webauthnModule.RegisterRoutes(apiGroup,
authMiddleware.WithAdminNotRequired().Add(),
rateLimitMiddleware.Add(middleware.RateLimitWebauthnLogin),
rateLimitMiddleware.Add(middleware.RateLimitWebauthnReauthenticate),
)
controller.NewOidcController(apiGroup, authMiddleware, fileSizeLimitMiddleware, svc.oidcService)
controller.NewUserController(apiGroup, authMiddleware, rateLimitMiddleware, svc.userService, svc.oneTimeAccessService, svc.webauthnModule, 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)
svc.apiModule.RegisterRoutes(apiGroup, authMiddleware.Add())
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)
svc.userSignUpModule.RegisterRoutes(apiGroup,
authMiddleware.Add(),
rateLimitMiddleware.Add(middleware.RateLimitSignup),
)
optionalBrowserAuth := authMiddleware.WithAdminNotRequired().WithSuccessOptional().WithApiKeyAuthDisabled().Add()
browserAuth := authMiddleware.WithAdminNotRequired().WithApiKeyAuthDisabled().Add()
svc.oidcModule.RegisterRoutes(baseGroup, apiGroup, optionalBrowserAuth, browserAuth)
registerTestRoutes(apiGroup, db, svc)
baseGroup := r.Group("/", apiRateLimitMiddleware)
controller.NewWellKnownController(baseGroup, svc.jwtService)
// These are not rate-limited.
controller.NewHealthzController(r)
// Receives OTLP trace payloads from the browser SPA (POST /internal/telemetry/traces) and forwards them to the collector, when trace export is enabled.
// Outside /api, so it's unauthenticated and not traced, but it is rate-limited.
tracing.NewTelemetryController(r, rateLimitMiddleware.Add(middleware.RateLimitInternal))
return nil
}

View File

@@ -3,14 +3,13 @@ package bootstrap
import (
"context"
"fmt"
"net/http"
"gorm.io/gorm"
"github.com/pocket-id/pocket-id/backend/internal/job"
)
func registerScheduledJobs(ctx context.Context, db *gorm.DB, svc *services, httpClient *http.Client, scheduler *job.Scheduler) error {
func registerScheduledJobs(ctx context.Context, db *gorm.DB, svc *services, scheduler *job.Scheduler) error {
err := scheduler.RegisterLdapJobs(ctx, svc.ldapService, svc.appConfigService)
if err != nil {
return fmt.Errorf("failed to register LDAP jobs in scheduler: %w", err)
@@ -23,18 +22,10 @@ func registerScheduledJobs(ctx context.Context, db *gorm.DB, svc *services, http
if err != nil {
return fmt.Errorf("failed to register DB cleanup jobs in scheduler: %w", err)
}
err = scheduler.RegisterFileCleanupJobs(ctx, db, svc.fileStorage)
if err != nil {
return fmt.Errorf("failed to register file cleanup jobs in scheduler: %w", err)
}
err = scheduler.RegisterApiKeyExpiryJob(ctx, svc.apiKeyService, svc.appConfigService)
err = scheduler.RegisterApiKeyExpiryJob(ctx, svc.apiKeyModule, svc.appConfigService, svc.emailService)
if err != nil {
return fmt.Errorf("failed to register API key expiration jobs in scheduler: %w", err)
}
err = scheduler.RegisterAnalyticsJob(ctx, svc.appConfigService, httpClient)
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)

View File

@@ -5,11 +5,17 @@ import (
"fmt"
"net/http"
"github.com/pocket-id/pocket-id/backend/internal/apikey"
"github.com/pocket-id/pocket-id/backend/internal/job"
"gorm.io/gorm"
"github.com/pocket-id/pocket-id/backend/internal/api"
"github.com/pocket-id/pocket-id/backend/internal/common"
"github.com/pocket-id/pocket-id/backend/internal/oidc"
"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/usersignup"
"github.com/pocket-id/pocket-id/backend/internal/webauthn"
)
type services struct {
@@ -19,19 +25,22 @@ type services struct {
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
apiKeyModule *apikey.Module
oidcModule *oidc.Module
webauthnModule *webauthn.Module
userSignUpModule *usersignup.Module
apiModule *api.Module
}
// Initializes all services
@@ -60,14 +69,40 @@ func initServices(ctx context.Context, db *gorm.DB, httpClient *http.Client, ima
}
svc.customClaimService = service.NewCustomClaimService(db)
svc.webauthnService, err = service.NewWebAuthnService(db, svc.jwtService, svc.auditLogService, svc.appConfigService)
svc.webauthnModule, err = webauthn.New(webauthn.Dependencies{
DB: db,
AppURL: common.EnvConfig.AppURL,
Signer: svc.jwtService,
AuditLog: svc.auditLogService,
AppConfig: svc.appConfigService,
})
if err != nil {
return nil, fmt.Errorf("failed to create WebAuthn service: %w", err)
return nil, fmt.Errorf("failed to create WebAuthn module: %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.apiModule = api.New(api.Dependencies{DB: db, Issuer: common.EnvConfig.AppURL})
svc.oidcModule, err = oidc.New(ctx, oidc.Dependencies{
DB: db,
HTTPClient: httpClient,
Config: oidc.Config{
BaseURL: common.EnvConfig.AppURL,
TokenBaseURL: common.EnvConfig.AppURL,
Secret: common.EnvConfig.EncryptionKey,
},
Signer: svc.jwtService,
CustomClaims: svc.customClaimService,
Reauth: svc.webauthnModule,
AuditLog: svc.auditLogService,
APIAccess: svc.apiModule,
})
if err != nil {
return nil, fmt.Errorf("failed to create OIDC module: %w", err)
}
svc.oidcService, err = service.NewOidcService(db, svc.jwtService, svc.appConfigService, svc.oidcModule.Preview, svc.scimService, httpClient, fileStorage)
if err != nil {
return nil, fmt.Errorf("failed to create OIDC service: %w", err)
}
@@ -76,12 +111,21 @@ func initServices(ctx context.Context, db *gorm.DB, httpClient *http.Client, ima
svc.userService = service.NewUserService(db, svc.jwtService, svc.auditLogService, svc.emailService, svc.appConfigService, svc.customClaimService, svc.appImagesService, svc.scimService, fileStorage)
svc.ldapService = service.NewLdapService(db, httpClient, svc.appConfigService, svc.userService, svc.userGroupService, fileStorage)
svc.apiKeyService, err = service.NewApiKeyService(ctx, db, svc.emailService)
svc.apiKeyModule, err = apikey.New(ctx, apikey.Dependencies{
DB: db,
StaticApiKey: common.EnvConfig.StaticApiKey,
})
if err != nil {
return nil, fmt.Errorf("failed to create API key service: %w", err)
return nil, fmt.Errorf("failed to create API key module: %w", err)
}
svc.userSignUpService = service.NewUserSignupService(db, svc.jwtService, svc.auditLogService, svc.appConfigService, svc.userService)
svc.userSignUpModule = usersignup.New(usersignup.Dependencies{
DB: db,
Signer: svc.jwtService,
AuditLog: svc.auditLogService,
AppConfig: svc.appConfigService,
UserCreator: svc.userService,
})
svc.oneTimeAccessService = service.NewOneTimeAccessService(db, svc.userService, svc.jwtService, svc.auditLogService, svc.emailService, svc.appConfigService)
svc.versionService = service.NewVersionService(httpClient)

View File

@@ -30,7 +30,7 @@ func init() {
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()
db, _, err := bootstrap.NewDatabase(cmd.Context())
if err != nil {
return err
}

View File

@@ -33,7 +33,7 @@ func init() {
// runExport orchestrates the export flow
func runExport(ctx context.Context, flags exportFlags) error {
db, err := bootstrap.NewDatabase()
db, _, err := bootstrap.NewDatabase(ctx)
if err != nil {
return fmt.Errorf("failed to connect to database: %w", err)
}

View File

@@ -73,7 +73,7 @@ func runImport(ctx context.Context, flags importFlags) error {
}
defer zipReader.Close()
db, err := bootstrap.ConnectDatabase()
db, _, err := bootstrap.ConnectDatabase(ctx)
if err != nil {
return err
}

View File

@@ -31,7 +31,7 @@ func init() {
Use: "key-rotate",
Short: "Generates a new token signing key and replaces the current one",
RunE: func(cmd *cobra.Command, args []string) error {
db, err := bootstrap.NewDatabase()
db, _, err := bootstrap.NewDatabase(cmd.Context())
if err != nil {
return err
}

View File

@@ -24,7 +24,7 @@ var oneTimeAccessTokenCmd = &cobra.Command{
userArg := args[0]
// Connect to the database
db, err := bootstrap.NewDatabase()
db, _, err := bootstrap.NewDatabase(cmd.Context())
if err != nil {
return err
}

View File

@@ -5,10 +5,10 @@ import (
"log/slog"
"os"
"github.com/italypaleale/go-kit/signals"
"github.com/spf13/cobra"
"github.com/pocket-id/pocket-id/backend/internal/bootstrap"
"github.com/pocket-id/pocket-id/backend/internal/utils/signals"
)
var rootCmd = &cobra.Command{

View File

@@ -0,0 +1,8 @@
package common
// AuthenticationMethodsClaim is the JWT claim ("amr") used to identify how the user
// authenticated. It is shared between the session JWTs and the OIDC tokens.
const AuthenticationMethodsClaim = "amr"
// TokenTypeClaim is the JWT claim ("type") used to identify the type of token.
const TokenTypeClaim = "type"

View File

@@ -78,10 +78,11 @@ type EnvConfigSchema struct {
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"`
ActorsPort string `env:"ACTORS_PORT"`
ActorsHost string `env:"ACTORS_HOST" options:"toLower"`
LogLevel string `env:"LOG_LEVEL" options:"toLower"`
LogJSON bool `env:"LOG_JSON"`
}
var EnvConfig = defaultConfig()
@@ -104,6 +105,8 @@ func defaultConfig() EnvConfigSchema {
AppURL: AppUrl,
Port: "1411",
Host: "0.0.0.0",
ActorsPort: "1414",
ActorsHost: "0.0.0.0",
GeoLiteDBPath: "data/GeoLite2-City.mmdb",
GeoLiteDBUrl: MaxMindGeoLiteCityUrl,
}
@@ -138,39 +141,53 @@ func ValidateEnvConfig(config *EnvConfigSchema) error {
return nil
}
if _, err := sloggin.ParseLevel(config.LogLevel); err != nil {
_, err := sloggin.ParseLevel(config.LogLevel)
if err != nil {
return errors.New("invalid LOG_LEVEL value. Must be 'debug', 'info', 'warn' or 'error'")
}
// Check required properties
if len(config.EncryptionKey) < 16 {
return errors.New("ENCRYPTION_KEY must be at least 16 bytes long")
}
prepareDbConfig(config)
if err := validateAppURLs(config); err != nil {
return err
}
if err := validateFileBackend(config); err != nil {
return err
}
if config.SystemdSocket && config.UnixSocket != "" {
return errors.New("SYSTEMD_SOCKET and UNIX_SOCKET are mutually exclusive")
}
if err := validateLocalIPv6Ranges(config.LocalIPv6Ranges); err != nil {
return err
}
if config.AuditLogRetentionDays <= 0 {
return errors.New("AUDIT_LOG_RETENTION_DAYS must be greater than 0")
}
if config.StaticApiKey != "" && len(config.StaticApiKey) < 16 {
return errors.New("STATIC_API_KEY must be at least 16 characters long")
return errors.New("when set, STATIC_API_KEY must be at least 16 characters long")
}
return validateTLSConfig(config)
// Prepare the DB config
prepareDbConfig(config)
// Validate other required options
err = validateAppURLs(config)
if err != nil {
return err
}
err = validateFileBackend(config)
if err != nil {
return err
}
err = validateLocalIPv6Ranges(config.LocalIPv6Ranges)
if err != nil {
return err
}
err = validateTLSConfig(config)
if err != nil {
return err
}
return nil
}
func prepareDbConfig(config *EnvConfigSchema) {

View File

@@ -123,8 +123,6 @@ func TestParseEnvConfig(t *testing.T) {
err := parseAndValidateEnvConfig(t)
require.NoError(t, err)
assert.True(t, EnvConfig.UiConfigDisabled)
assert.True(t, EnvConfig.MetricsEnabled)
assert.False(t, EnvConfig.TracingEnabled)
assert.True(t, EnvConfig.TrustProxy)
assert.False(t, EnvConfig.AnalyticsDisabled)
})
@@ -175,6 +173,8 @@ func TestParseEnvConfig(t *testing.T) {
t.Setenv("UNIX_SOCKET", "/tmp/app.sock")
t.Setenv("MAXMIND_LICENSE_KEY", "test-license")
t.Setenv("GEOLITE_DB_PATH", "/custom/geolite.mmdb")
t.Setenv("ACTORS_PORT", "9999")
t.Setenv("ACTORS_HOST", "LOCALHOST")
err := parseAndValidateEnvConfig(t)
require.NoError(t, err)
@@ -182,6 +182,8 @@ func TestParseEnvConfig(t *testing.T) {
assert.Equal(t, "/custom/uploads", EnvConfig.UploadPath)
assert.Equal(t, "8080", EnvConfig.Port)
assert.Equal(t, "localhost", EnvConfig.Host) // lowercased
assert.Equal(t, "9999", EnvConfig.ActorsPort)
assert.Equal(t, "localhost", EnvConfig.ActorsHost) // lowercased
})
t.Run("should normalize file backend and default upload path", func(t *testing.T) {

View File

@@ -62,43 +62,6 @@ type OidcMissingAuthorizationError struct{}
func (e OidcMissingAuthorizationError) Error() string { return "missing authorization" }
func (e OidcMissingAuthorizationError) HttpStatusCode() int { return http.StatusForbidden }
type OidcGrantTypeNotSupportedError struct{}
func (e OidcGrantTypeNotSupportedError) Error() string { return "grant type not supported" }
func (e OidcGrantTypeNotSupportedError) HttpStatusCode() int { return http.StatusBadRequest }
type OidcMissingClientCredentialsError struct{}
func (e OidcMissingClientCredentialsError) Error() string { return "client id or secret not provided" }
func (e OidcMissingClientCredentialsError) HttpStatusCode() int { return http.StatusBadRequest }
type OidcClientSecretInvalidError struct{}
func (e OidcClientSecretInvalidError) Error() string { return "invalid client secret" }
func (e OidcClientSecretInvalidError) HttpStatusCode() int { return http.StatusUnauthorized }
type OidcClientAssertionInvalidError struct{}
func (e OidcClientAssertionInvalidError) Error() string { return "invalid client assertion" }
func (e OidcClientAssertionInvalidError) HttpStatusCode() int { return http.StatusUnauthorized }
type OidcInvalidAuthorizationCodeError struct{}
func (e OidcInvalidAuthorizationCodeError) Error() string { return "invalid authorization code" }
func (e OidcInvalidAuthorizationCodeError) HttpStatusCode() int { return http.StatusBadRequest }
type OidcClientNotFoundError struct{}
func (e OidcClientNotFoundError) Error() string { return "client not found" }
func (e OidcClientNotFoundError) HttpStatusCode() int { return http.StatusNotFound }
type OidcMissingCallbackURLError struct{}
func (e OidcMissingCallbackURLError) Error() string {
return "unable to detect callback url, it might be necessary for an admin to fix this"
}
func (e OidcMissingCallbackURLError) HttpStatusCode() int { return http.StatusBadRequest }
type OidcInvalidCallbackURLError struct{}
func (e OidcInvalidCallbackURLError) Error() string {
@@ -125,11 +88,6 @@ type NotSignedInError struct{}
func (e NotSignedInError) Error() string { return "You are not signed in" }
func (e NotSignedInError) HttpStatusCode() int { return http.StatusUnauthorized }
type MissingAccessToken struct{}
func (e MissingAccessToken) Error() string { return "Missing access token" }
func (e MissingAccessToken) HttpStatusCode() int { return http.StatusUnauthorized }
type MissingPermissionError struct{}
func (e MissingPermissionError) Error() string {
@@ -152,11 +110,6 @@ 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 { return "Client id or secret not provided" }
func (e ClientIdOrSecretNotProvidedError) HttpStatusCode() int { return http.StatusBadRequest }
type WrongFileTypeError struct {
ExpectedFileType string
}
@@ -189,16 +142,6 @@ func (e DuplicateClaimError) Error() string {
}
func (e DuplicateClaimError) HttpStatusCode() int { return http.StatusBadRequest }
type OidcInvalidCodeVerifierError struct{}
func (e OidcInvalidCodeVerifierError) Error() string { return "Invalid code verifier" }
func (e OidcInvalidCodeVerifierError) HttpStatusCode() int { return http.StatusBadRequest }
type OidcMissingCodeChallengeError struct{}
func (e OidcMissingCodeChallengeError) Error() string { return "Missing code challenge" }
func (e OidcMissingCodeChallengeError) HttpStatusCode() int { return http.StatusBadRequest }
type LdapUserUpdateError struct{}
func (e LdapUserUpdateError) Error() string { return "LDAP users can't be updated" }
@@ -221,13 +164,6 @@ func (e OidcClientIdNotMatchingError) Error() string {
}
func (e OidcClientIdNotMatchingError) HttpStatusCode() int { return http.StatusBadRequest }
type OidcNoCallbackURLError struct{}
func (e OidcNoCallbackURLError) Error() string {
return "No callback URL provided"
}
func (e OidcNoCallbackURLError) HttpStatusCode() int { return http.StatusBadRequest }
type UiConfigDisabledError struct{}
func (e UiConfigDisabledError) Error() string {
@@ -279,21 +215,6 @@ func (e APIKeyAuthNotAllowedError) Error() string {
}
func (e APIKeyAuthNotAllowedError) HttpStatusCode() int { return http.StatusForbidden }
type OidcInvalidRefreshTokenError struct{}
func (e OidcInvalidRefreshTokenError) Error() string { return "refresh token is invalid or expired" }
func (e OidcInvalidRefreshTokenError) HttpStatusCode() int { return http.StatusBadRequest }
type OidcMissingRefreshTokenError struct{}
func (e OidcMissingRefreshTokenError) Error() string { return "refresh token is required" }
func (e OidcMissingRefreshTokenError) HttpStatusCode() int { return http.StatusBadRequest }
type OidcMissingAuthorizationCodeError struct{}
func (e OidcMissingAuthorizationCodeError) Error() string { return "authorization code is required" }
func (e OidcMissingAuthorizationCodeError) HttpStatusCode() int { return http.StatusBadRequest }
type UserDisabledError struct{}
func (e UserDisabledError) Error() string { return "User account is disabled" }
@@ -315,16 +236,6 @@ type OidcInvalidDeviceCodeError struct{}
func (e OidcInvalidDeviceCodeError) Error() string { return "invalid device code" }
func (e OidcInvalidDeviceCodeError) HttpStatusCode() int { return http.StatusBadRequest }
type OidcSlowDownError struct{}
func (e OidcSlowDownError) Error() string { return "polling too frequently" }
func (e OidcSlowDownError) HttpStatusCode() int { return http.StatusTooManyRequests }
type OidcAuthorizationPendingError struct{}
func (e OidcAuthorizationPendingError) Error() string { return "authorization is still pending" }
func (e OidcAuthorizationPendingError) HttpStatusCode() int { return http.StatusBadRequest }
type ReauthenticationRequiredError struct{}
func (e ReauthenticationRequiredError) Error() string { return "reauthentication required" }
@@ -354,22 +265,6 @@ func (e ImageNotFoundError) Error() string { return "Image not found" }
func (e ImageNotFoundError) HttpStatusCode() int { return http.StatusNotFound }
type OidcPARNotSupportedForPublicClientsError struct{}
func (e OidcPARNotSupportedForPublicClientsError) Error() string {
return "pushed authorization requests are not supported for public clients"
}
func (e OidcPARNotSupportedForPublicClientsError) HttpStatusCode() int {
return http.StatusBadRequest
}
type OidcInvalidRequestURIError struct{}
func (e OidcInvalidRequestURIError) Error() string {
return "invalid or expired request_uri"
}
func (e OidcInvalidRequestURIError) HttpStatusCode() int { return http.StatusBadRequest }
type OidcPARRequiredError struct{}
func (e OidcPARRequiredError) Error() string {
@@ -382,35 +277,3 @@ type InvalidEmailVerificationTokenError struct{}
func (e InvalidEmailVerificationTokenError) Error() string { return "Invalid email verification token" }
func (e InvalidEmailVerificationTokenError) HttpStatusCode() int { return http.StatusBadRequest }
// OIDC prompt parameter errors - used for redirect error responses
type OidcLoginRequiredError struct{}
func (e OidcLoginRequiredError) Error() string { return "login_required" }
func (e OidcLoginRequiredError) HttpStatusCode() int { return http.StatusBadRequest }
type OidcConsentRequiredError struct{}
func (e OidcConsentRequiredError) Error() string { return "consent_required" }
func (e OidcConsentRequiredError) HttpStatusCode() int { return http.StatusBadRequest }
type OidcInteractionRequiredError struct{}
func (e OidcInteractionRequiredError) Error() string { return "interaction_required" }
func (e OidcInteractionRequiredError) HttpStatusCode() int { return http.StatusBadRequest }
type OidcInvalidRequestError struct{ description string }
func NewOidcInvalidRequestError(description string) *OidcInvalidRequestError {
return &OidcInvalidRequestError{description: description}
}
func (e OidcInvalidRequestError) Error() string { return "invalid_request" }
func (e OidcInvalidRequestError) HttpStatusCode() int { return http.StatusBadRequest }
func (e OidcInvalidRequestError) Description() string { return e.description }
type OidcAccountSelectionRequiredError struct{}
func (e OidcAccountSelectionRequiredError) Error() string { return "account_selection_required" }
func (e OidcAccountSelectionRequiredError) HttpStatusCode() int { return http.StatusBadRequest }

View File

@@ -0,0 +1,5 @@
package common
// StaticApiKeyUserID is the fixed ID of the synthetic admin user that the static API key authenticates as
// It is excluded from real-user counts such as the initial-admin-setup check
const StaticApiKeyUserID = "00000000-0000-0000-0000-000000000000"

View File

@@ -1,156 +0,0 @@
package controller
import (
"net/http"
"github.com/pocket-id/pocket-id/backend/internal/utils"
"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"
)
// swag init -g cmd/main.go -o ./docs/swagger --parseDependency
// ApiKeyController manages API keys for authenticated users
type ApiKeyController struct {
apiKeyService *service.ApiKeyService
}
// NewApiKeyController creates a new controller for API key management
// @Summary API key management controller
// @Description Initializes API endpoints for managing API keys
// @Tags API Keys
func NewApiKeyController(group *gin.RouterGroup, authMiddleware *middleware.AuthMiddleware, apiKeyService *service.ApiKeyService) {
uc := &ApiKeyController{apiKeyService: apiKeyService}
apiKeyGroup := group.Group("/api-keys")
{
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)
}
}
// listApiKeysHandler godoc
// @Summary List API keys
// @Description Get a paginated list of API keys belonging to the current user
// @Tags API Keys
// @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.ApiKeyDto]
// @Router /api/api-keys [get]
func (c *ApiKeyController) listApiKeysHandler(ctx *gin.Context) {
listRequestOptions := utils.ParseListRequestOptions(ctx)
userID := ctx.GetString("userID")
apiKeys, pagination, err := c.apiKeyService.ListApiKeys(ctx.Request.Context(), userID, listRequestOptions)
if err != nil {
_ = ctx.Error(err)
return
}
var apiKeysDto []dto.ApiKeyDto
if err := dto.MapStructList(apiKeys, &apiKeysDto); err != nil {
_ = ctx.Error(err)
return
}
ctx.JSON(http.StatusOK, dto.Paginated[dto.ApiKeyDto]{
Data: apiKeysDto,
Pagination: pagination,
})
}
// createApiKeyHandler godoc
// @Summary Create API key
// @Description Create a new API key for the current user
// @Tags API Keys
// @Param api_key body dto.ApiKeyCreateDto true "API key information"
// @Success 201 {object} dto.ApiKeyResponseDto "Created API key with token"
// @Router /api/api-keys [post]
func (c *ApiKeyController) createApiKeyHandler(ctx *gin.Context) {
userID := ctx.GetString("userID")
var input dto.ApiKeyCreateDto
if err := dto.ShouldBindWithNormalizedJSON(ctx, &input); err != nil {
_ = ctx.Error(err)
return
}
apiKey, token, err := c.apiKeyService.CreateApiKey(ctx.Request.Context(), userID, input)
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.StatusCreated, dto.ApiKeyResponseDto{
ApiKey: apiKeyDto,
Token: token,
})
}
// 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
// @Tags API Keys
// @Param id path string true "API Key ID"
// @Success 204 "No Content"
// @Router /api/api-keys/{id} [delete]
func (c *ApiKeyController) revokeApiKeyHandler(ctx *gin.Context) {
userID := ctx.GetString("userID")
apiKeyID := ctx.Param("id")
if err := c.apiKeyService.RevokeApiKey(ctx.Request.Context(), userID, apiKeyID); err != nil {
_ = ctx.Error(err)
return
}
ctx.Status(http.StatusNoContent)
}

View File

@@ -9,6 +9,7 @@ import (
"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/tracing"
)
// NewAppConfigController creates a new controller for application configuration endpoints
@@ -66,6 +67,13 @@ func (acc *AppConfigController) listAppConfigHandler(c *gin.Context) {
Type: "boolean",
})
// Manually add tracingEnabled, derived from the OTel environment, so the frontend only exports traces when the backend can forward them to a collector
configVariablesDto = append(configVariablesDto, dto.PublicAppConfigVariableDto{
Key: "tracingEnabled",
Value: strconv.FormatBool(tracing.FrontendTracingEnabled()),
Type: "boolean",
})
c.JSON(http.StatusOK, configVariablesDto)
}

View File

@@ -14,6 +14,7 @@ func NewTestController(group *gin.RouterGroup, testService *service.TestService)
testController := &TestController{TestService: testService}
group.POST("/test/reset", testController.resetAndSeedHandler)
group.POST("/test/accesstoken", testController.signAccessToken)
group.POST("/test/refreshtoken", testController.signRefreshToken)
group.GET("/externalidp/jwks.json", testController.externalIdPJWKS)
@@ -108,6 +109,27 @@ func (tc *TestController) externalIdPSignToken(c *gin.Context) {
c.Writer.WriteString(token)
}
func (tc *TestController) signAccessToken(c *gin.Context) {
var input struct {
UserID string `json:"user"`
ClientID string `json:"client"`
Expired bool `json:"expired"`
}
err := c.ShouldBindJSON(&input)
if err != nil {
_ = c.Error(err)
return
}
token, err := tc.TestService.SignAccessToken(c.Request.Context(), input.UserID, input.ClientID, input.Expired)
if err != nil {
_ = c.Error(err)
return
}
c.Writer.WriteString(token)
}
func (tc *TestController) signRefreshToken(c *gin.Context) {
var input struct {
UserID string `json:"user"`
@@ -120,7 +142,7 @@ func (tc *TestController) signRefreshToken(c *gin.Context) {
return
}
token, err := tc.TestService.SignRefreshToken(input.UserID, input.ClientID, input.RefreshToken)
token, err := tc.TestService.SignRefreshToken(c.Request.Context(), input.UserID, input.ClientID, input.RefreshToken)
if err != nil {
_ = c.Error(err)
return

View File

@@ -1,11 +1,7 @@
package controller
import (
"context"
"errors"
"log/slog"
"net/http"
"net/url"
"strconv"
"strings"
"time"
@@ -17,33 +13,17 @@ import (
"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"
"github.com/pocket-id/pocket-id/backend/internal/utils/cookie"
)
// NewOidcController creates a new controller for OIDC related endpoints
// @Summary OIDC controller
// @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) {
func NewOidcController(group *gin.RouterGroup, authMiddleware *middleware.AuthMiddleware, fileSizeLimitMiddleware *middleware.FileSizeLimitMiddleware, oidcService *service.OidcService) {
oc := &OidcController{
oidcService: oidcService,
jwtService: jwtService,
createTokens: oidcService.CreateTokens,
oidcService: oidcService,
}
group.POST("/oidc/authorize", authMiddleware.WithAdminNotRequired().Add(), oc.authorizeHandler)
group.POST("/oidc/authorize/callback-url", oc.authorizeCallbackURLHandler)
group.POST("/oidc/authorization-required", authMiddleware.WithAdminNotRequired().Add(), oc.authorizationConfirmationRequiredHandler)
group.GET("/oidc/par-request-info", authMiddleware.WithAdminNotRequired().Add(), oc.parRequestInfoHandler)
group.POST("/oidc/token", oc.createTokensHandler)
group.POST("/oidc/par", oc.pushedAuthorizationRequestHandler)
group.GET("/oidc/userinfo", oc.userInfoHandler)
group.POST("/oidc/userinfo", oc.userInfoHandler)
group.POST("/oidc/end-session", authMiddleware.WithAdminNotRequired().WithSuccessOptional().Add(), oc.EndSessionHandler)
group.GET("/oidc/end-session", authMiddleware.WithAdminNotRequired().WithSuccessOptional().Add(), oc.EndSessionHandler)
group.POST("/oidc/introspect", oc.introspectTokenHandler)
group.GET("/oidc/clients", authMiddleware.Add(), oc.listClientsHandler)
group.POST("/oidc/clients", authMiddleware.Add(), oc.createClientHandler)
group.GET("/oidc/clients/:id", authMiddleware.Add(), oc.getClientHandler)
@@ -60,10 +40,6 @@ func NewOidcController(group *gin.RouterGroup, authMiddleware *middleware.AuthMi
group.GET("/oidc/clients/:id/preview/:userId", authMiddleware.Add(), oc.getClientPreviewHandler)
group.POST("/oidc/device/authorize", oc.deviceAuthorizationHandler)
group.POST("/oidc/device/verify", authMiddleware.WithAdminNotRequired().Add(), oc.verifyDeviceCodeHandler)
group.GET("/oidc/device/info", authMiddleware.WithAdminNotRequired().Add(), oc.getDeviceCodeInfoHandler)
group.GET("/oidc/users/me/authorized-clients", authMiddleware.WithAdminNotRequired().Add(), oc.listOwnAuthorizedClientsHandler)
group.GET("/oidc/users/:id/authorized-clients", authMiddleware.Add(), oc.listAuthorizedClientsHandler)
@@ -76,429 +52,7 @@ func NewOidcController(group *gin.RouterGroup, authMiddleware *middleware.AuthMi
}
type OidcController struct {
oidcService *service.OidcService
jwtService *service.JwtService
createTokens func(context.Context, dto.OidcCreateTokensDto) (service.CreatedTokens, error)
}
// authorizeHandler godoc
// @Summary Authorize OIDC client
// @Description Start the OIDC authorization process for a client
// @Tags OIDC
// @Accept json
// @Produce json
// @Param request body dto.AuthorizeOidcClientRequestDto true "Authorization request parameters"
// @Success 200 {object} dto.AuthorizeOidcClientResponseDto "Authorization code and callback URL"
// @Router /api/oidc/authorize [post]
func (oc *OidcController) authorizeHandler(c *gin.Context) {
var input dto.AuthorizeOidcClientRequestDto
err := c.ShouldBindJSON(&input)
if err != nil {
_ = c.Error(err)
return
}
code, callbackURL, err := oc.oidcService.Authorize(
c.Request.Context(),
input,
c.GetString("userID"),
c.GetString("authenticationMethod"),
c.ClientIP(),
c.Request.UserAgent(),
)
if err != nil {
// Check if this is a prompt-related error that should be returned as a redirect error
if isOidcPromptError(err) {
c.JSON(http.StatusOK, gin.H{
"error": err.Error(),
"requiresRedirect": true,
"callbackURL": callbackURL,
})
return
}
_ = c.Error(err)
return
}
response := dto.AuthorizeOidcClientResponseDto{
Code: code,
CallbackURL: callbackURL,
Issuer: common.EnvConfig.AppURL,
}
c.JSON(http.StatusOK, response)
}
// authorizeCallbackURLHandler godoc
// @Summary Resolve a validated callback URL for an OIDC authorization request
// @Description Resolves the redirect URI against the client's configured callback URLs without consuming PAR records.
// @Tags OIDC
// @Accept json
// @Produce json
// @Param request body dto.AuthorizeOidcClientCallbackRequestDto true "Authorization callback parameters"
// @Success 200 {object} dto.AuthorizeOidcClientCallbackResponseDto "Resolved callback URL"
// @Router /api/oidc/authorize/callback-url [post]
func (oc *OidcController) authorizeCallbackURLHandler(c *gin.Context) {
var input dto.AuthorizeOidcClientCallbackRequestDto
if err := c.ShouldBindJSON(&input); err != nil {
_ = c.Error(err)
return
}
callbackURL, err := oc.oidcService.ResolveAuthorizeCallbackURL(
c.Request.Context(),
input.ClientID,
input.CallbackURL,
input.RequestURI,
)
if err != nil {
_ = c.Error(err)
return
}
c.JSON(http.StatusOK, dto.AuthorizeOidcClientCallbackResponseDto{CallbackURL: callbackURL})
}
// isOidcPromptError checks if an error is a prompt-related OIDC error that should trigger a redirect
func isOidcPromptError(err error) bool {
var loginReq *common.OidcLoginRequiredError
var consentReq *common.OidcConsentRequiredError
var interactionReq *common.OidcInteractionRequiredError
var accountSelectionReq *common.OidcAccountSelectionRequiredError
return errors.As(err, &loginReq) ||
errors.As(err, &consentReq) ||
errors.As(err, &interactionReq) ||
errors.As(err, &accountSelectionReq)
}
// authorizationConfirmationRequiredHandler godoc
// @Summary Check if authorization confirmation is required
// @Description Check if the user needs to confirm authorization for the client
// @Tags OIDC
// @Accept json
// @Produce json
// @Param request body dto.AuthorizationRequiredDto true "Authorization check parameters"
// @Success 200 {object} object "{ \"authorizationRequired\": true/false }"
// @Router /api/oidc/authorization-required [post]
func (oc *OidcController) authorizationConfirmationRequiredHandler(c *gin.Context) {
var input dto.AuthorizationRequiredDto
if err := c.ShouldBindJSON(&input); err != nil {
_ = c.Error(err)
return
}
authorizationRequired, scope, err := oc.oidcService.AuthorizationRequired(c.Request.Context(), input.ClientID, c.GetString("userID"), input.Scope, input.RequestURI)
if err != nil {
_ = c.Error(err)
return
}
c.JSON(http.StatusOK, gin.H{"authorizationRequired": authorizationRequired, "scope": scope})
}
// parRequestInfoHandler godoc
// @Summary Resolve stored authorization request parameters
// @Description Resolve the parameters of a stored request_uri request so the consent page can render the requested scope and build the final redirect. Does not consume the request.
// @Tags OIDC
// @Produce json
// @Param client_id query string true "Client ID"
// @Param request_uri query string true "Request URI returned from the PAR endpoint"
// @Success 200 {object} dto.OidcAuthorizeRequestInfoDto "Resolved authorization request parameters"
// @Router /api/oidc/par-request-info [get]
func (oc *OidcController) parRequestInfoHandler(c *gin.Context) {
clientID := c.Query("client_id")
requestURI := c.Query("request_uri")
if clientID == "" {
_ = c.Error(&common.ValidationError{Message: "client_id is required"})
return
}
if requestURI == "" {
_ = c.Error(&common.ValidationError{Message: "request_uri is required"})
return
}
info, err := oc.oidcService.GetPushedAuthorizationRequest(c.Request.Context(), clientID, requestURI)
if err != nil {
_ = c.Error(err)
return
}
var infoDto dto.OidcAuthorizeRequestInfoDto
err = dto.MapStruct(info.Parameters, &infoDto)
if err != nil {
_ = c.Error(err)
return
}
c.JSON(http.StatusOK, infoDto)
}
// createTokensHandler godoc
// @Summary Create OIDC tokens
// @Description Exchange authorization code or refresh token for access tokens
// @Tags OIDC
// @Produce json
// @Param client_id formData string false "Client ID (if not using Basic Auth)"
// @Param client_secret formData string false "Client secret (if not using Basic Auth or client assertions)"
// @Param code formData string false "Authorization code (required for 'authorization_code' grant)"
// @Param grant_type formData string true "Grant type ('authorization_code' or 'refresh_token')"
// @Param code_verifier formData string false "PKCE code verifier (for authorization_code with PKCE)"
// @Param refresh_token formData string false "Refresh token (required for 'refresh_token' grant)"
// @Param client_assertion formData string false "Client assertion type (for 'authorization_code' grant when using client assertions)"
// @Param client_assertion_type formData string false "Client assertion type (for 'authorization_code' grant when using client assertions)"
// @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 {
_ = c.Error(err)
return
}
// Validate that code is provided for authorization_code grant type
if input.GrantType == service.GrantTypeAuthorizationCode && input.Code == "" {
_ = c.Error(&common.OidcMissingAuthorizationCodeError{})
return
}
// Validate that refresh_token is provided for refresh_token grant type
if input.GrantType == service.GrantTypeRefreshToken && input.RefreshToken == "" {
_ = c.Error(&common.OidcMissingRefreshTokenError{})
return
}
// Check if the client ID / secret are passed in the Authorization header (RFC 6749)
parseBasicAuth(c.Request, &input.ClientSecret, &input.ClientID)
tokens, err := oc.createTokens(c.Request.Context(), input)
switch {
case errors.Is(err, &common.OidcAuthorizationPendingError{}):
c.JSON(http.StatusBadRequest, gin.H{
"error": "authorization_pending",
})
return
case errors.Is(err, &common.OidcSlowDownError{}):
c.JSON(http.StatusBadRequest, gin.H{
"error": "slow_down",
})
return
case err != nil:
_ = c.Error(err)
return
}
c.JSON(http.StatusOK, dto.OidcTokenResponseDto{
AccessToken: tokens.AccessToken,
TokenType: "Bearer",
ExpiresIn: int(tokens.ExpiresIn.Seconds()),
IdToken: tokens.IdToken, // May be empty
RefreshToken: tokens.RefreshToken, // May be empty
})
}
// pushedAuthorizationRequestHandler godoc
// @Summary Pushed Authorization Request (PAR)
// @Description RFC 9126: Push authorization request parameters and receive a request_uri. Only confidential clients may use this endpoint.
// @Tags OIDC
// @Accept application/x-www-form-urlencoded
// @Produce json
// @Success 201 {object} dto.OidcPARResponseDto
// @Router /api/oidc/par [post]
func (oc *OidcController) pushedAuthorizationRequestHandler(c *gin.Context) {
// Per RFC 9126, parameters MUST be passed in the request body
c.Request.URL.RawQuery = ""
var input dto.OidcPARRequestDto
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)
}
creds := service.ClientAuthCredentials{
ClientID: input.ClientID,
ClientSecret: input.ClientSecret,
ClientAssertion: input.ClientAssertion,
ClientAssertionType: input.ClientAssertionType,
}
requestURI, expiresIn, err := oc.oidcService.CreatePushedAuthorizationRequest(c.Request.Context(), creds, input)
if err != nil {
_ = c.Error(err)
return
}
// RFC 9126 §2.2 requires HTTP 201 Created for successful PAR responses
c.JSON(http.StatusCreated, dto.OidcPARResponseDto{
RequestURI: requestURI,
ExpiresIn: expiresIn,
})
}
// userInfoHandler godoc
// @Summary Get user information
// @Description Get user information based on the access token
// @Tags OIDC
// @Accept json
// @Produce json
// @Success 200 {object} object "User claims based on requested scopes"
// @Security OAuth2AccessToken
// @Router /api/oidc/userinfo [get]
func (oc *OidcController) userInfoHandler(c *gin.Context) {
_, authToken, ok := strings.Cut(c.GetHeader("Authorization"), " ")
if !ok || authToken == "" {
_ = c.Error(&common.MissingAccessToken{})
return
}
token, err := oc.jwtService.VerifyOAuthAccessToken(authToken)
if err != nil {
_ = c.Error(err)
return
}
userID, ok := token.Subject()
if !ok {
_ = c.Error(&common.TokenInvalidError{})
return
}
clientID, ok := token.Audience()
if !ok || len(clientID) != 1 {
_ = c.Error(&common.TokenInvalidError{})
return
}
claims, err := oc.oidcService.GetUserClaimsForClient(c.Request.Context(), userID, clientID[0])
if err != nil {
_ = c.Error(err)
return
}
c.JSON(http.StatusOK, claims)
}
// EndSessionHandler godoc
// @Summary End OIDC session
// @Description End user session and handle OIDC logout
// @Tags OIDC
// @Accept application/x-www-form-urlencoded
// @Param id_token_hint query string false "ID token"
// @Param post_logout_redirect_uri query string false "URL to redirect to after logout"
// @Param state query string false "State parameter to include in the redirect"
// @Success 302 "Redirect to post-logout URL or application logout page"
// @Router /api/oidc/end-session [get]
func (oc *OidcController) EndSessionHandler(c *gin.Context) {
var input dto.OidcLogoutDto
// Bind query parameters to the struct
switch c.Request.Method {
case http.MethodGet:
if err := c.ShouldBindQuery(&input); err != nil {
_ = c.Error(err)
return
}
case http.MethodPost:
// Bind form parameters to the struct
if err := c.ShouldBind(&input); err != nil {
_ = c.Error(err)
return
}
}
callbackURL, err := oc.oidcService.ValidateEndSession(c.Request.Context(), input, c.GetString("userID"))
if err != nil {
// If the validation fails, the user has to confirm the logout manually and doesn't get redirected
slog.WarnContext(c.Request.Context(), "Error getting logout callback URL, the user has to confirm the logout manually", "error", err)
c.Redirect(http.StatusFound, common.EnvConfig.AppURL+"/logout")
return
}
// The validation was successful, so we can log out and redirect the user to the callback URL without confirmation
cookie.AddAccessTokenCookie(c, 0, "")
// Callback URL can be empty if none is configured
if callbackURL == "" {
c.Redirect(http.StatusFound, common.EnvConfig.AppURL+"/logout")
return
}
logoutCallbackURL, _ := url.Parse(callbackURL)
if input.State != "" {
q := logoutCallbackURL.Query()
q.Set("state", input.State)
logoutCallbackURL.RawQuery = q.Encode()
}
c.Redirect(http.StatusFound, logoutCallbackURL.String())
}
// EndSessionHandler godoc (POST method)
// @Summary End OIDC session (POST method)
// @Description End user session and handle OIDC logout using POST
// @Tags OIDC
// @Accept application/x-www-form-urlencoded
// @Produce html
// @Param id_token_hint formData string false "ID token"
// @Param post_logout_redirect_uri formData string false "URL to redirect to after logout"
// @Param state formData string false "State parameter to include in the redirect"
// @Success 302 "Redirect to post-logout URL or application logout page"
// @Router /api/oidc/end-session [post]
func (oc *OidcController) EndSessionHandlerPost(c *gin.Context) {
// Implementation is the same as GET
}
// introspectToken godoc
// @Summary Introspect OIDC tokens
// @Description Pass an access_token to verify if it is considered valid.
// @Tags OIDC
// @Produce json
// @Param token formData string true "The token to be introspected."
// @Success 200 {object} dto.OidcIntrospectionResponseDto "Response with the introspection result."
// @Router /api/oidc/introspect [post]
func (oc *OidcController) introspectTokenHandler(c *gin.Context) {
var input dto.OidcIntrospectDto
if err := c.ShouldBind(&input); err != nil {
_ = c.Error(err)
return
}
// Client id and secret have to be passed over the Authorization header. This kind of
// authentication allows us to keep the endpoint protected (since it could be used to
// find valid tokens) while still allowing it to be used by an application that is
// supposed to interact with our IdP (since that needs to have a client_id
// and client_secret anyway).
var (
creds service.ClientAuthCredentials
ok bool
)
creds.ClientID, creds.ClientSecret, ok = utils.OAuthClientBasicAuth(c.Request)
if !ok {
// If there's no basic auth, check if we have a bearer token (used as client assertion)
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
}
}
response, err := oc.oidcService.IntrospectToken(c.Request.Context(), creds, input.Token)
if err != nil {
_ = c.Error(err)
return
}
c.JSON(http.StatusOK, response)
oidcService *service.OidcService
}
// getClientMetaDataHandler godoc
@@ -810,46 +364,6 @@ func (oc *OidcController) updateAllowedUserGroupsHandler(c *gin.Context) {
c.JSON(http.StatusOK, oidcClientDto)
}
// This method can modify the value of clientSecret and clientID
func parseBasicAuth(r *http.Request, clientSecret *string, clientID *string) {
// Client id and secret can also be passed via the Authorization header (RFC 6749, section 2.3.1 "client_secret_basic")
// When PKCE is used, some libraries send client_id in the body for the code_verifier binding while keeping the secret only in the Authorization header
// We therefore fall back to Basic auth whenever the secret is missing, not only when both fields are empty
if *clientSecret == "" {
basicID, basicSecret, ok := utils.OAuthClientBasicAuth(r)
if ok {
if *clientID == "" {
*clientID = basicID
}
*clientSecret = basicSecret
}
}
}
func (oc *OidcController) deviceAuthorizationHandler(c *gin.Context) {
// Per RFC 8628 (OAuth 2.0 Device Authorization Grant), parameters for the device authorization request MUST be sent in the body of the POST request
// Gin's "ShouldBind" by default reads from the query string too, so we need to reset all query string args before invoking ShouldBind
c.Request.URL.RawQuery = ""
var input dto.OidcDeviceAuthorizationRequestDto
err := c.ShouldBind(&input)
if err != nil {
_ = c.Error(err)
return
}
// Check if the client ID / secret are passed in the Authorization header (RFC 6749)
parseBasicAuth(c.Request, &input.ClientSecret, &input.ClientID)
response, err := oc.oidcService.CreateDeviceAuthorization(c.Request.Context(), input)
if err != nil {
_ = c.Error(err)
return
}
c.JSON(http.StatusOK, response)
}
// listOwnAuthorizedClientsHandler godoc
// @Summary List authorized clients for current user
// @Description Get a paginated list of OIDC clients that the current user has authorized
@@ -951,49 +465,6 @@ func (oc *OidcController) listOwnAccessibleClientsHandler(c *gin.Context) {
})
}
func (oc *OidcController) verifyDeviceCodeHandler(c *gin.Context) {
userCode := c.Query("code")
if userCode == "" {
_ = c.Error(&common.ValidationError{Message: "code is required"})
return
}
// Get IP address and user agent from the request context
ipAddress := c.ClientIP()
userAgent := c.Request.UserAgent()
err := oc.oidcService.VerifyDeviceCode(
c.Request.Context(),
userCode,
c.GetString("userID"),
c.GetString("authenticationMethod"),
ipAddress,
userAgent)
if err != nil {
_ = c.Error(err)
return
}
c.Status(http.StatusNoContent)
}
func (oc *OidcController) getDeviceCodeInfoHandler(c *gin.Context) {
userCode := c.Query("code")
if userCode == "" {
_ = c.Error(&common.ValidationError{Message: "code is required"})
return
}
deviceCodeInfo, err := oc.oidcService.GetDeviceCodeInfo(c.Request.Context(), userCode, c.GetString("userID"))
if err != nil {
_ = c.Error(err)
return
}
c.JSON(http.StatusOK, deviceCodeInfo)
}
// getClientPreviewHandler godoc
// @Summary Preview OIDC client data for user
// @Description Get a preview of the OIDC data (ID token, access token, userinfo) that would be sent to the client for a specific user

View File

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

@@ -12,7 +12,7 @@ import (
"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"
"github.com/pocket-id/pocket-id/backend/internal/webauthn"
)
const defaultOneTimeAccessTokenDuration = 15 * time.Minute
@@ -21,7 +21,7 @@ const defaultOneTimeAccessTokenDuration = 15 * time.Minute
// @Summary User management controller
// @Description Initializes all user-related API endpoints
// @Tags Users
func NewUserController(group *gin.RouterGroup, authMiddleware *middleware.AuthMiddleware, rateLimitMiddleware *middleware.RateLimitMiddleware, userService *service.UserService, oneTimeAccessService *service.OneTimeAccessService, webAuthnService *service.WebAuthnService, appConfigService *service.AppConfigService) {
func NewUserController(group *gin.RouterGroup, authMiddleware *middleware.AuthMiddleware, rateLimitMiddleware *middleware.RateLimitMiddleware, userService *service.UserService, oneTimeAccessService *service.OneTimeAccessService, webAuthnService *webauthn.Module, appConfigService *service.AppConfigService) {
uc := UserController{
userService: userService,
oneTimeAccessService: oneTimeAccessService,
@@ -50,20 +50,20 @@ func NewUserController(group *gin.RouterGroup, authMiddleware *middleware.AuthMi
group.POST("/users/me/one-time-access-token", authMiddleware.WithAdminNotRequired().Add(), uc.createOwnOneTimeAccessTokenHandler)
group.POST("/users/:id/one-time-access-token", authMiddleware.Add(), uc.createAdminOneTimeAccessTokenHandler)
group.POST("/users/:id/one-time-access-email", authMiddleware.Add(), uc.RequestOneTimeAccessEmailAsAdminHandler)
group.POST("/one-time-access-token/:token", rateLimitMiddleware.Add(rate.Every(10*time.Second), 5), uc.exchangeOneTimeAccessTokenHandler)
group.POST("/one-time-access-email", rateLimitMiddleware.Add(rate.Every(10*time.Minute), 3), uc.RequestOneTimeAccessEmailAsUnauthenticatedUserHandler)
group.POST("/one-time-access-token/:token", rateLimitMiddleware.Add(middleware.RateLimitOneTimeAccessToken), uc.exchangeOneTimeAccessTokenHandler)
group.POST("/one-time-access-email", rateLimitMiddleware.Add(middleware.RateLimitOneTimeAccessEmail), uc.RequestOneTimeAccessEmailAsUnauthenticatedUserHandler)
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("/users/me/send-email-verification", rateLimitMiddleware.Add(middleware.RateLimitSendEmailVerification), authMiddleware.WithAdminNotRequired().Add(), uc.sendEmailVerificationHandler)
group.POST("/users/me/verify-email", rateLimitMiddleware.Add(middleware.RateLimitVerifyEmail), authMiddleware.WithAdminNotRequired().Add(), uc.verifyEmailHandler)
}
type UserController struct {
userService *service.UserService
oneTimeAccessService *service.OneTimeAccessService
webAuthnService *service.WebAuthnService
webAuthnService *webauthn.Module
appConfigService *service.AppConfigService
}
@@ -508,8 +508,15 @@ 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) {
loginCode := c.Param("token")
// reject invalid length login codes
if len(loginCode) != 6 && len(loginCode) != 16 {
_ = c.Error(&common.TokenInvalidOrExpiredError{})
return
}
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.oneTimeAccessService.ExchangeOneTimeAccessToken(c.Request.Context(), loginCode, deviceToken, c.ClientIP(), c.Request.UserAgent())
if err != nil {
_ = c.Error(err)
return

View File

@@ -1,207 +0,0 @@
package controller
import (
"net/http"
"time"
"github.com/go-webauthn/webauthn/protocol"
"github.com/pocket-id/pocket-id/backend/internal/common"
"github.com/pocket-id/pocket-id/backend/internal/dto"
"github.com/pocket-id/pocket-id/backend/internal/middleware"
"github.com/pocket-id/pocket-id/backend/internal/utils/cookie"
"github.com/gin-gonic/gin"
"github.com/pocket-id/pocket-id/backend/internal/service"
"golang.org/x/time/rate"
)
func NewWebauthnController(group *gin.RouterGroup, authMiddleware *middleware.AuthMiddleware, rateLimitMiddleware *middleware.RateLimitMiddleware, webauthnService *service.WebAuthnService, appConfigService *service.AppConfigService) {
wc := &WebauthnController{webAuthnService: webauthnService, appConfigService: appConfigService}
group.GET("/webauthn/register/start", authMiddleware.WithAdminNotRequired().Add(), wc.beginRegistrationHandler)
group.POST("/webauthn/register/finish", authMiddleware.WithAdminNotRequired().Add(), wc.verifyRegistrationHandler)
group.GET("/webauthn/login/start", wc.beginLoginHandler)
group.POST("/webauthn/login/finish", rateLimitMiddleware.Add(rate.Every(10*time.Second), 5), wc.verifyLoginHandler)
group.POST("/webauthn/logout", authMiddleware.WithAdminNotRequired().Add(), wc.logoutHandler)
group.POST("/webauthn/reauthenticate", authMiddleware.WithAdminNotRequired().Add(), rateLimitMiddleware.Add(rate.Every(10*time.Second), 5), wc.reauthenticateHandler)
group.GET("/webauthn/credentials", authMiddleware.WithAdminNotRequired().Add(), wc.listCredentialsHandler)
group.PATCH("/webauthn/credentials/:id", authMiddleware.WithAdminNotRequired().Add(), wc.updateCredentialHandler)
group.DELETE("/webauthn/credentials/:id", authMiddleware.WithAdminNotRequired().Add(), wc.deleteCredentialHandler)
}
type WebauthnController struct {
webAuthnService *service.WebAuthnService
appConfigService *service.AppConfigService
}
func (wc *WebauthnController) beginRegistrationHandler(c *gin.Context) {
userID := c.GetString("userID")
options, err := wc.webAuthnService.BeginRegistration(c.Request.Context(), userID)
if err != nil {
_ = c.Error(err)
return
}
cookie.AddSessionIdCookie(c, int(options.Timeout.Seconds()), options.SessionID)
c.JSON(http.StatusOK, options.Response)
}
func (wc *WebauthnController) verifyRegistrationHandler(c *gin.Context) {
sessionID, err := c.Cookie(cookie.SessionIdCookieName)
if err != nil {
_ = c.Error(&common.MissingSessionIdError{})
return
}
userID := c.GetString("userID")
credential, err := wc.webAuthnService.VerifyRegistration(c.Request.Context(), sessionID, userID, c.Request, c.ClientIP())
if err != nil {
_ = c.Error(err)
return
}
var credentialDto dto.WebauthnCredentialDto
if err := dto.MapStruct(credential, &credentialDto); err != nil {
_ = c.Error(err)
return
}
c.JSON(http.StatusOK, credentialDto)
}
func (wc *WebauthnController) beginLoginHandler(c *gin.Context) {
options, err := wc.webAuthnService.BeginLogin(c.Request.Context())
if err != nil {
_ = c.Error(err)
return
}
cookie.AddSessionIdCookie(c, int(options.Timeout.Seconds()), options.SessionID)
c.JSON(http.StatusOK, options.Response)
}
func (wc *WebauthnController) verifyLoginHandler(c *gin.Context) {
sessionID, err := c.Cookie(cookie.SessionIdCookieName)
if err != nil {
_ = c.Error(&common.MissingSessionIdError{})
return
}
credentialAssertionData, err := protocol.ParseCredentialRequestResponseBody(c.Request.Body)
if err != nil {
_ = c.Error(err)
return
}
user, token, err := wc.webAuthnService.VerifyLogin(c.Request.Context(), sessionID, credentialAssertionData, 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(wc.appConfigService.GetDbConfig().SessionDuration.AsDurationMinutes().Seconds())
cookie.AddAccessTokenCookie(c, maxAge, token)
c.JSON(http.StatusOK, userDto)
}
func (wc *WebauthnController) listCredentialsHandler(c *gin.Context) {
userID := c.GetString("userID")
credentials, err := wc.webAuthnService.ListCredentials(c.Request.Context(), userID)
if err != nil {
_ = c.Error(err)
return
}
var credentialDtos []dto.WebauthnCredentialDto
if err := dto.MapStructList(credentials, &credentialDtos); err != nil {
_ = c.Error(err)
return
}
c.JSON(http.StatusOK, credentialDtos)
}
func (wc *WebauthnController) deleteCredentialHandler(c *gin.Context) {
userID := c.GetString("userID")
credentialID := c.Param("id")
clientIP := c.ClientIP()
userAgent := c.Request.UserAgent()
err := wc.webAuthnService.DeleteCredential(c.Request.Context(), userID, credentialID, clientIP, userAgent, userID)
if err != nil {
_ = c.Error(err)
return
}
c.Status(http.StatusNoContent)
}
func (wc *WebauthnController) updateCredentialHandler(c *gin.Context) {
userID := c.GetString("userID")
credentialID := c.Param("id")
var input dto.WebauthnCredentialUpdateDto
if err := c.ShouldBindJSON(&input); err != nil {
_ = c.Error(err)
return
}
credential, err := wc.webAuthnService.UpdateCredential(c.Request.Context(), userID, credentialID, input.Name)
if err != nil {
_ = c.Error(err)
return
}
var credentialDto dto.WebauthnCredentialDto
if err := dto.MapStruct(credential, &credentialDto); err != nil {
_ = c.Error(err)
return
}
c.JSON(http.StatusOK, credentialDto)
}
func (wc *WebauthnController) logoutHandler(c *gin.Context) {
cookie.AddAccessTokenCookie(c, 0, "")
c.Status(http.StatusNoContent)
}
func (wc *WebauthnController) reauthenticateHandler(c *gin.Context) {
sessionID, err := c.Cookie(cookie.SessionIdCookieName)
if err != nil {
_ = c.Error(&common.MissingSessionIdError{})
return
}
var token string
// Try to create a reauthentication token with WebAuthn
credentialAssertionData, err := protocol.ParseCredentialRequestResponseBody(c.Request.Body)
if err == nil {
token, err = wc.webAuthnService.CreateReauthenticationTokenWithWebauthn(c.Request.Context(), sessionID, credentialAssertionData)
if err != nil {
_ = c.Error(err)
return
}
} else {
// If WebAuthn fails, try to create a reauthentication token with the access token
accessToken, _ := c.Cookie(cookie.AccessTokenCookieName)
token, err = wc.webAuthnService.CreateReauthenticationTokenWithAccessToken(c.Request.Context(), accessToken)
if err != nil {
_ = c.Error(err)
return
}
}
c.JSON(http.StatusOK, gin.H{"reauthenticationToken": token})
}

View File

@@ -84,15 +84,18 @@ func (wkc *WellKnownController) computeOIDCConfiguration() ([]byte, error) {
"device_authorization_endpoint": appUrl + "/api/oidc/device/authorize",
"jwks_uri": internalAppUrl + "/.well-known/jwks.json",
"grant_types_supported": []string{service.GrantTypeAuthorizationCode, service.GrantTypeRefreshToken, service.GrantTypeDeviceCode, service.GrantTypeClientCredentials},
"scopes_supported": []string{"openid", "profile", "email", "groups"},
"claims_supported": []string{"sub", "given_name", "family_name", "name", "email", "email_verified", "preferred_username", "picture", "groups"},
"scopes_supported": []string{"openid", "profile", "email", "groups", "offline_access"},
"claims_supported": []string{"sub", "given_name", "family_name", "name", "display_name", "email", "email_verified", "preferred_username", "picture", "groups", "auth_time", "amr"},
"response_types_supported": []string{"code", "id_token"},
"subject_types_supported": []string{"public"},
"id_token_signing_alg_values_supported": []string{alg.String()},
"authorization_response_iss_parameter_supported": true,
"code_challenge_methods_supported": []string{"plain", "S256"},
"request_parameter_supported": true,
"request_uri_parameter_supported": false,
"request_object_signing_alg_values_supported": []string{"none"},
"prompt_values_supported": []string{"none", "login", "consent", "select_account"},
"token_endpoint_auth_methods_supported": []string{"client_secret_basic", "client_secret_post", "private_key_jwt", "none"},
"token_endpoint_auth_methods_supported": []string{"client_secret_basic", "client_secret_post", "none"},
"pushed_authorization_request_endpoint": internalAppUrl + "/api/oidc/par",
"require_pushed_authorization_requests": false,
}

View File

@@ -1,7 +1,6 @@
package dto
import (
"net/http"
"reflect"
"github.com/gin-gonic/gin"
@@ -73,22 +72,3 @@ loop:
func ShouldBindWithNormalizedJSON(ctx *gin.Context, obj any) error {
return ctx.ShouldBindWith(obj, binding.JSON)
}
type NormalizerJSONBinding struct{}
func (NormalizerJSONBinding) Name() string {
return "json"
}
func (NormalizerJSONBinding) Bind(req *http.Request, obj any) error {
// Use the default JSON binder
err := binding.JSON.Bind(req, obj)
if err != nil {
return err
}
// Perform normalization
Normalize(obj)
return nil
}

View File

@@ -5,6 +5,7 @@ import datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
type OidcClientMetaDataDto struct {
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
HasLogo bool `json:"hasLogo"`
HasDarkLogo bool `json:"hasDarkLogo"`
LaunchURL *string `json:"launchURL"`
@@ -18,8 +19,10 @@ type OidcClientDto struct {
IsPublic bool `json:"isPublic"`
PkceEnabled bool `json:"pkceEnabled"`
RequiresPushedAuthorizationRequests bool `json:"requiresPushedAuthorizationRequests"`
SkipConsent bool `json:"skipConsent"`
Credentials OidcClientCredentialsDto `json:"credentials"`
IsGroupRestricted bool `json:"isGroupRestricted"`
PkceSupported bool `json:"pkceSupported,omitempty"`
}
type OidcClientWithAllowedUserGroupsDto struct {
@@ -34,12 +37,14 @@ type OidcClientWithAllowedGroupsCountDto struct {
type OidcClientUpdateDto struct {
Name string `json:"name" binding:"required,max=50" unorm:"nfc"`
Description string `json:"description" binding:"omitempty,max=150" unorm:"nfc"`
CallbackURLs []string `json:"callbackURLs" binding:"omitempty,dive,callback_url_pattern"`
LogoutCallbackURLs []string `json:"logoutCallbackURLs" binding:"omitempty,dive,callback_url_pattern"`
IsPublic bool `json:"isPublic"`
PkceEnabled bool `json:"pkceEnabled"`
RequiresReauthentication bool `json:"requiresReauthentication"`
RequiresPushedAuthorizationRequests bool `json:"requiresPushedAuthorizationRequests"`
SkipConsent bool `json:"skipConsent"`
Credentials OidcClientCredentialsDto `json:"credentials"`
LaunchURL *string `json:"launchURL" binding:"omitempty,url"`
HasLogo bool `json:"hasLogo"`
@@ -59,93 +64,11 @@ type OidcClientCredentialsDto struct {
}
type OidcClientFederatedIdentityDto struct {
Issuer string `json:"issuer"`
Subject string `json:"subject,omitempty"`
Audience string `json:"audience,omitempty"`
JWKS string `json:"jwks,omitempty"`
}
type AuthorizeOidcClientRequestDto struct {
ClientID string `json:"clientID" binding:"required"`
Scope string `json:"scope" binding:"required_without=RequestURI"`
CallbackURL string `json:"callbackURL"`
Nonce string `json:"nonce"`
CodeChallenge string `json:"codeChallenge"`
CodeChallengeMethod string `json:"codeChallengeMethod"`
ReauthenticationToken string `json:"reauthenticationToken"`
Prompt string `json:"prompt"`
ResponseMode string `json:"responseMode" binding:"omitempty,response_mode"`
RequestURI string `json:"requestURI"`
}
type OidcPARRequestDto struct {
ClientID string `form:"client_id"`
ClientSecret string `form:"client_secret"`
ClientAssertion string `form:"client_assertion"`
ClientAssertionType string `form:"client_assertion_type"`
ResponseType string `form:"response_type" binding:"required"`
Scope string `form:"scope" binding:"required"`
RedirectURI string `form:"redirect_uri"`
State string `form:"state"`
Nonce string `form:"nonce"`
CodeChallenge string `form:"code_challenge"`
CodeChallengeMethod string `form:"code_challenge_method"`
Prompt string `form:"prompt"`
ResponseMode string `form:"response_mode" binding:"omitempty,response_mode"`
}
type OidcPARResponseDto struct {
RequestURI string `json:"request_uri"`
ExpiresIn int `json:"expires_in"`
}
type AuthorizeOidcClientResponseDto struct {
Code string `json:"code"`
CallbackURL string `json:"callbackURL"`
Issuer string `json:"issuer"`
}
type AuthorizeOidcClientCallbackRequestDto struct {
ClientID string `json:"clientID" binding:"required"`
CallbackURL string `json:"callbackURL"`
RequestURI string `json:"requestURI"`
}
type AuthorizeOidcClientCallbackResponseDto struct {
CallbackURL string `json:"callbackURL"`
}
type AuthorizationRequiredDto struct {
ClientID string `json:"clientID" binding:"required"`
Scope string `json:"scope"`
RequestURI string `json:"requestURI"`
}
type OidcAuthorizeRequestInfoDto struct {
Scope string `json:"scope"`
RedirectURI string `json:"redirectURI"`
State string `json:"state,omitempty"`
Nonce string `json:"nonce,omitempty"`
ResponseMode string `json:"responseMode,omitempty"`
Prompt string `json:"prompt,omitempty"`
}
type OidcCreateTokensDto struct {
GrantType string `form:"grant_type" binding:"required"`
Code string `form:"code"`
DeviceCode string `form:"device_code"`
ClientID string `form:"client_id"`
ClientSecret string `form:"client_secret"`
CodeVerifier string `form:"code_verifier"`
RefreshToken string `form:"refresh_token"`
ClientAssertion string `form:"client_assertion"`
ClientAssertionType string `form:"client_assertion_type"`
Resource string `form:"resource"`
}
type OidcIntrospectDto struct {
Token string `form:"token" binding:"required"`
ClientID string `form:"client_id"`
Issuer string `json:"issuer"`
Subject string `json:"subject,omitempty"`
Audience string `json:"audience,omitempty"`
JWKS string `json:"jwks,omitempty"`
ReplayProtection bool `json:"replayProtection"`
}
type OidcUpdateAllowedUserGroupsDto struct {
@@ -159,36 +82,6 @@ type OidcLogoutDto struct {
State string `form:"state"`
}
type OidcTokenResponseDto struct {
AccessToken string `json:"access_token"`
TokenType string `json:"token_type"`
IdToken string `json:"id_token,omitempty"`
RefreshToken string `json:"refresh_token,omitempty"`
ExpiresIn int `json:"expires_in"`
}
type OidcIntrospectionResponseDto struct {
Active bool `json:"active"`
TokenType string `json:"token_type,omitempty"`
Scope string `json:"scope,omitempty"`
Expiration int64 `json:"exp,omitempty"`
IssuedAt int64 `json:"iat,omitempty"`
NotBefore int64 `json:"nbf,omitempty"`
Subject string `json:"sub,omitempty"`
Audience []string `json:"aud,omitempty"`
Issuer string `json:"iss,omitempty"`
Identifier string `json:"jti,omitempty"`
}
type OidcDeviceAuthorizationRequestDto struct {
ClientID string `form:"client_id" binding:"required"`
Scope string `form:"scope" binding:"required"`
ClientSecret string `form:"client_secret"`
ClientAssertion string `form:"client_assertion"`
ClientAssertionType string `form:"client_assertion_type"`
Nonce string `form:"nonce"`
}
type OidcDeviceAuthorizationResponseDto struct {
DeviceCode string `json:"device_code"`
UserCode string `json:"user_code"`
@@ -196,20 +89,20 @@ type OidcDeviceAuthorizationResponseDto struct {
VerificationURIComplete string `json:"verification_uri_complete"`
ExpiresIn int `json:"expires_in"`
Interval int `json:"interval"`
RequiresAuthorization bool `json:"requires_authorization"`
}
type OidcDeviceTokenRequestDto struct {
GrantType string `form:"grant_type" binding:"required,eq=urn:ietf:params:oauth:grant-type:device_code"`
DeviceCode string `form:"device_code" binding:"required"`
ClientID string `form:"client_id"`
ClientSecret string `form:"client_secret"`
type ScopeInfoDto struct {
Key string `json:"key"`
Name string `json:"name"`
Description string `json:"description,omitempty"`
}
type DeviceCodeInfoDto struct {
Scope string `json:"scope"`
AuthorizationRequired bool `json:"authorizationRequired"`
Client OidcClientMetaDataDto `json:"client"`
Scope []string `json:"scope"`
ScopeInfo []ScopeInfoDto `json:"scopeInfo"`
AuthorizationRequired bool `json:"authorizationRequired"`
ReauthenticationRequired bool `json:"reauthenticationRequired"`
Client OidcClientMetaDataDto `json:"client"`
}
type AuthorizedOidcClientDto struct {

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

@@ -1,22 +0,0 @@
package dto
import (
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
"github.com/pocket-id/pocket-id/backend/internal/utils"
)
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"`
}
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"`
}

View File

@@ -6,6 +6,7 @@ import (
"strings"
"time"
"github.com/ory/fosite"
"github.com/pocket-id/pocket-id/backend/internal/utils"
"github.com/gin-gonic/gin/binding"
@@ -47,8 +48,8 @@ func init() {
"callback_url_pattern": func(fl validator.FieldLevel) bool {
return ValidateCallbackURLPattern(fl.Field().String())
},
"response_mode": func(fl validator.FieldLevel) bool {
return ValidateResponseMode(fl.Field().String())
"resource_uri": func(fl validator.FieldLevel) bool {
return ValidateResourceURI(fl.Field().String())
},
}
for k, v := range validators {
@@ -69,6 +70,27 @@ func ValidateClientID(clientID string) bool {
return validateClientIDRegex.MatchString(clientID)
}
// isActiveContentScheme reports whether the URL scheme can carry executable content, so it must never be accepted where a URL might later be rendered as a link
func isActiveContentScheme(scheme string) bool {
switch strings.ToLower(scheme) {
case "javascript", "data":
return true
default:
return false
}
}
// ValidateResourceURI validates RFC 8707 resource identifiers
func ValidateResourceURI(str string) bool {
if !fosite.IsValidResourceIndicatorURI(str) {
return false
}
// Reject active-content schemes so a resource identifier can never carry executable content if it is ever surfaced as a link
u, _ := url.Parse(str)
return !isActiveContentScheme(u.Scheme)
}
// ValidateCallbackURL validates the input callback URL
func ValidateCallbackURL(str string) bool {
// Ensure the URL is a valid one and that the protocol is not "javascript:" or "data:"
@@ -77,30 +99,10 @@ func ValidateCallbackURL(str string) bool {
return false
}
switch strings.ToLower(u.Scheme) {
case "javascript", "data":
return false
default:
return true
}
return !isActiveContentScheme(u.Scheme)
}
// ValidateCallbackURLPattern validates callback URL patterns, with support for wildcards
// ValidateCallbackURLPattern validates callback URL patterns, with support for wildcards.
func ValidateCallbackURLPattern(raw string) bool {
err := utils.ValidateCallbackURLPattern(raw)
return err == nil
}
// ValidateResponseMode validates response_mode parameter
// If responseMode is present, it must be "form_post", "query", or "fragment"
// Empty responseMode is allowed (field not provided, use default)
func ValidateResponseMode(responseMode string) bool {
switch responseMode {
case "form_post", "query", "fragment":
return true
case "":
return true
default:
return false
}
return utils.ValidateCallbackURLPattern(raw) == nil
}

View File

@@ -58,22 +58,31 @@ func TestValidateClientID(t *testing.T) {
}
}
func TestValidateResponseMode(t *testing.T) {
func TestValidateResourceURI(t *testing.T) {
tests := []struct {
name string
input string
expected bool
}{
{"valid form_post", "form_post", true},
{"valid query", "query", true},
{"valid fragment", "fragment", true},
{"valid empty", "", true},
{"invalid unknown", "unknown", false},
{"valid https URI", "https://api.example.com", true},
{"valid custom scheme URI", "api://PocketID", true},
{"valid URN", "urn:my-app", true},
{"valid query component", "https://api.example.com?tenant=1", true},
{"invalid relative path", "/foo", false},
{"invalid plain string", "foo", false},
{"invalid fragment", "https://api.example.com#tokens", false},
{"invalid empty fragment", "https://api.example.com#", false},
{"invalid unescaped path space", "https://api.example.com/a b", false},
{"invalid unescaped opaque space", "urn:my app", false},
{"invalid malformed URI", "http://[::1", false},
{"invalid javascript scheme", "javascript:alert(1)", false},
{"invalid data scheme", "data:text/html,<script>alert(1)</script>", false},
{"empty", "", false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
assert.Equal(t, tt.expected, ValidateResponseMode(tt.input))
assert.Equal(t, tt.expected, ValidateResourceURI(tt.input))
})
}
}
@@ -102,3 +111,26 @@ func TestValidateCallbackURL(t *testing.T) {
})
}
}
func TestValidateCallbackURLPattern(t *testing.T) {
tests := []struct {
name string
input string
expected bool
}{
{"valid exact URL", "https://example.com/callback", true},
{"valid wildcard URL", "https://*.example.com/callback", true},
{"valid custom scheme", "pocketid://callback", true},
{"valid global wildcard", "*", true},
{"invalid relative URL", "/callback", false},
{"invalid malformed URL", "http://[::1", false},
{"rejects javascript scheme", "javascript:alert(1)", false},
{"rejects data scheme", "data:text/html;base64,PGgxPkhlbGxvPC9oMT4=", false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
assert.Equal(t, tt.expected, ValidateCallbackURLPattern(tt.input))
})
}
}

View File

@@ -5,11 +5,12 @@ import (
"context"
"encoding/json"
"fmt"
"log/slog"
"net/http"
"time"
backoff "github.com/cenkalti/backoff/v5"
"github.com/go-co-op/gocron/v2"
"github.com/italypaleale/francis/builtin/cronjob"
"github.com/pocket-id/pocket-id/backend/internal/common"
"github.com/pocket-id/pocket-id/backend/internal/service"
@@ -17,23 +18,60 @@ import (
const heartbeatUrl = "https://analytics.pocket-id.org/heartbeat"
func (s *Scheduler) RegisterAnalyticsJob(ctx context.Context, appConfig *service.AppConfigService, httpClient *http.Client) error {
// GetAnalyticsJob returns the CronJob actor
func GetAnalyticsJob(appConfig *service.AppConfigService, httpClient *http.Client) (*cronjob.CronJob, error) {
// Skip if analytics are disabled or not in production environment
if common.EnvConfig.AnalyticsDisabled || !common.EnvConfig.AppEnv.IsProduction() {
return nil
return nil, nil
}
// Send every 24 hours
jobs := &AnalyticsJob{
appConfig: appConfig,
job := &AnalyticsJob{
httpClient: httpClient,
}
return s.RegisterJob(ctx, "SendHeartbeat", gocron.DurationJob(24*time.Hour), jobs.sendHeartbeat, service.RegisterJobOpts{RunImmediately: true})
err := job.createBody(appConfig)
if err != nil {
return nil, fmt.Errorf("error pre-computing request body: %w", err)
}
// Create the built-in actor
cj, err := cronjob.New(
"Analytics",
cronjob.WithJob(job.sendHeartbeat),
// Run every 24 hours
cronjob.WithInterval(24*time.Hour),
// Run immediately upon registration too
cronjob.WithImmediate(),
cronjob.WithLogger(slog.Default()),
)
if err != nil {
return nil, fmt.Errorf("error creating Analytics job: %w", err)
}
return cj, nil
}
type AnalyticsJob struct {
appConfig *service.AppConfigService
httpClient *http.Client
body []byte
}
// createBody pre-computes the body for all requests
func (j *AnalyticsJob) createBody(appConfig *service.AppConfigService) error {
body, err := json.Marshal(struct {
Version string `json:"version"`
InstanceID string `json:"instance_id"`
}{
Version: common.Version,
InstanceID: appConfig.GetDbConfig().InstanceID.Value,
})
if err != nil {
return fmt.Errorf("failed to marshal heartbeat body: %w", err)
}
// Set the body in the object
j.body = body
return nil
}
// sendHeartbeat sends a heartbeat to the analytics service
@@ -43,23 +81,13 @@ func (j *AnalyticsJob) sendHeartbeat(parentCtx context.Context) error {
return nil
}
body, err := json.Marshal(struct {
Version string `json:"version"`
InstanceID string `json:"instance_id"`
}{
Version: common.Version,
InstanceID: j.appConfig.GetDbConfig().InstanceID.Value,
})
if err != nil {
return fmt.Errorf("failed to marshal heartbeat body: %w", err)
}
_, err = backoff.Retry(
// Use a backoff to retry
_, err := backoff.Retry(
parentCtx,
func() (struct{}, error) {
ctx, cancel := context.WithTimeout(parentCtx, 20*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodPost, heartbeatUrl, bytes.NewReader(body))
req, err := http.NewRequestWithContext(ctx, http.MethodPost, heartbeatUrl, bytes.NewReader(j.body))
if err != nil {
return struct{}{}, fmt.Errorf("failed to create request: %w", err)
}
@@ -77,7 +105,6 @@ func (j *AnalyticsJob) sendHeartbeat(parentCtx context.Context) error {
backoff.WithBackOff(backoff.NewExponentialBackOff()),
backoff.WithMaxTries(3),
)
if err != nil {
return fmt.Errorf("heartbeat request failed: %w", err)
}

View File

@@ -7,18 +7,22 @@ import (
"github.com/go-co-op/gocron/v2"
"github.com/pocket-id/pocket-id/backend/internal/apikey"
"github.com/pocket-id/pocket-id/backend/internal/service"
"github.com/pocket-id/pocket-id/backend/internal/utils/email"
)
type ApiKeyEmailJobs struct {
apiKeyService *service.ApiKeyService
apiKeyModule *apikey.Module
appConfigService *service.AppConfigService
emailService *service.EmailService
}
func (s *Scheduler) RegisterApiKeyExpiryJob(ctx context.Context, apiKeyService *service.ApiKeyService, appConfigService *service.AppConfigService) error {
func (s *Scheduler) RegisterApiKeyExpiryJob(ctx context.Context, apiKeyModule *apikey.Module, appConfigService *service.AppConfigService, emailService *service.EmailService) error {
jobs := &ApiKeyEmailJobs{
apiKeyService: apiKeyService,
apiKeyModule: apiKeyModule,
appConfigService: appConfigService,
emailService: emailService,
}
// Send every day at midnight
@@ -31,7 +35,7 @@ func (j *ApiKeyEmailJobs) checkAndNotifyExpiringApiKeys(ctx context.Context) err
return nil
}
apiKeys, err := j.apiKeyService.ListExpiringApiKeys(ctx, 7)
apiKeys, err := j.apiKeyModule.ListExpiringApiKeys(ctx, 7)
if err != nil {
return fmt.Errorf("failed to list expiring API keys: %w", err)
}
@@ -40,13 +44,29 @@ func (j *ApiKeyEmailJobs) checkAndNotifyExpiringApiKeys(ctx context.Context) err
if key.User.Email == nil {
continue
}
err = j.apiKeyService.SendApiKeyExpiringSoonEmail(ctx, key)
err = service.SendEmail(ctx, j.emailService, email.Address{
Name: key.User.FullName(),
Email: *key.User.Email,
}, service.ApiKeyExpiringSoonTemplate, &service.ApiKeyExpiringSoonTemplateData{
Name: key.User.FirstName,
ApiKeyName: key.Name,
ExpiresAt: key.ExpiresAt.ToTime(),
})
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),
)
continue
}
if err = j.apiKeyModule.MarkExpirationEmailSent(ctx, key.ID); err != nil {
slog.ErrorContext(ctx, "Failed to record that the expiration email was sent",
slog.String("key", key.ID),
slog.Any("error", err),
)
}
}
return nil

View File

@@ -13,7 +13,10 @@ import (
"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/oidc"
"github.com/pocket-id/pocket-id/backend/internal/service"
"github.com/pocket-id/pocket-id/backend/internal/usersignup"
"github.com/pocket-id/pocket-id/backend/internal/webauthn"
)
func (s *Scheduler) RegisterDbCleanupJobs(ctx context.Context, db *gorm.DB) error {
@@ -34,11 +37,11 @@ func (s *Scheduler) RegisterDbCleanupJobs(ctx context.Context, db *gorm.DB) erro
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, "ClearOAuth2Sessions", jobDefWithJitter(24*time.Hour), jobs.clearOAuth2Sessions, service.RegisterJobOpts{RunImmediately: true, BackOff: newBackOff()}),
s.RegisterJob(ctx, "ClearOAuth2JTIs", jobDefWithJitter(24*time.Hour), jobs.clearOAuth2JTIs, service.RegisterJobOpts{RunImmediately: true, BackOff: newBackOff()}),
s.RegisterJob(ctx, "ClearInteractionSessions", jobDefWithJitter(24*time.Hour), jobs.clearInteractionSessions, 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, "ClearOidcPushedAuthorizationRequests", jobDefWithJitter(24*time.Hour), jobs.clearOidcPushedAuthorizationRequests, service.RegisterJobOpts{RunImmediately: true, BackOff: newBackOff()}),
)
}
@@ -46,16 +49,14 @@ type DbCleanupJobs struct {
db *gorm.DB
}
// ClearWebauthnSessions deletes WebAuthn sessions that have expired
// clearWebauthnSessions deletes expired WebAuthn challenge sessions.
func (j *DbCleanupJobs) clearWebauthnSessions(ctx context.Context) error {
st := j.db.
WithContext(ctx).
Delete(&model.WebauthnSession{}, "expires_at < ?", datatype.DateTime(time.Now()))
if st.Error != nil {
return fmt.Errorf("failed to clean expired WebAuthn sessions: %w", st.Error)
count, err := webauthn.CleanupExpiredSessions(ctx, j.db)
if err != nil {
return fmt.Errorf("failed to clean expired WebAuthn sessions: %w", err)
}
slog.InfoContext(ctx, "Cleaned expired WebAuthn sessions", slog.Int64("count", st.RowsAffected))
slog.InfoContext(ctx, "Cleaned expired WebAuthn sessions", slog.Int64("count", count))
return nil
}
@@ -74,59 +75,63 @@ func (j *DbCleanupJobs) clearOneTimeAccessTokens(ctx context.Context) error {
return nil
}
// ClearSignupTokens deletes signup tokens that have expired
// clearSignupTokens deletes signup tokens that have expired
func (j *DbCleanupJobs) clearSignupTokens(ctx context.Context) error {
// Delete tokens that are expired OR have reached their usage limit
st := j.db.
WithContext(ctx).
Delete(&model.SignupToken{}, "expires_at < ?", datatype.DateTime(time.Now()))
if st.Error != nil {
return fmt.Errorf("failed to clean expired tokens: %w", st.Error)
count, err := usersignup.CleanupExpiredSignupTokens(ctx, j.db)
if err != nil {
return fmt.Errorf("failed to clean expired signup tokens: %w", err)
}
slog.InfoContext(ctx, "Cleaned expired tokens", slog.Int64("count", st.RowsAffected))
slog.InfoContext(ctx, "Cleaned expired signup tokens", slog.Int64("count", count))
return nil
}
// ClearOidcAuthorizationCodes deletes OIDC authorization codes that have expired
func (j *DbCleanupJobs) clearOidcAuthorizationCodes(ctx context.Context) error {
st := j.db.
WithContext(ctx).
Delete(&model.OidcAuthorizationCode{}, "expires_at < ?", datatype.DateTime(time.Now()))
if st.Error != nil {
return fmt.Errorf("failed to clean expired OIDC authorization codes: %w", st.Error)
// clearOAuth2Sessions deletes expired and invalidated OAuth2 sessions.
func (j *DbCleanupJobs) clearOAuth2Sessions(ctx context.Context) error {
count, err := oidc.CleanupExpiredOAuth2Sessions(ctx, j.db)
if err != nil {
return fmt.Errorf("failed to clean OAuth2 sessions: %w", err)
}
slog.InfoContext(ctx, "Cleaned expired OIDC authorization codes", slog.Int64("count", st.RowsAffected))
slog.InfoContext(ctx, "Cleaned OAuth2 sessions", slog.Int64("count", count))
return nil
}
// ClearOidcAuthorizationCodes deletes OIDC authorization codes that have expired
func (j *DbCleanupJobs) clearOidcRefreshTokens(ctx context.Context) error {
st := j.db.
WithContext(ctx).
Delete(&model.OidcRefreshToken{}, "expires_at < ?", datatype.DateTime(time.Now()))
if st.Error != nil {
return fmt.Errorf("failed to clean expired OIDC refresh tokens: %w", st.Error)
// clearOAuth2JTIs deletes expired JWT IDs used for client assertion replay protection.
func (j *DbCleanupJobs) clearOAuth2JTIs(ctx context.Context) error {
count, err := oidc.CleanupExpiredClientAssertionJTIs(ctx, j.db)
if err != nil {
return fmt.Errorf("failed to clean OAuth2 client assertion JTIs: %w", err)
}
slog.InfoContext(ctx, "Cleaned expired OIDC refresh tokens", slog.Int64("count", st.RowsAffected))
slog.InfoContext(ctx, "Cleaned OAuth2 client assertion JTIs", slog.Int64("count", count))
return nil
}
// ClearReauthenticationTokens deletes reauthentication tokens that have expired
// clearInteractionSessions deletes abandoned OIDC interaction sessions.
func (j *DbCleanupJobs) clearInteractionSessions(ctx context.Context) error {
count, err := oidc.CleanupAbandonedInteractionSessions(ctx, j.db)
if err != nil {
return fmt.Errorf("failed to clean interaction sessions: %w", err)
}
slog.InfoContext(ctx, "Cleaned interaction sessions", slog.Int64("count", count))
return nil
}
// clearReauthenticationTokens deletes expired reauthentication tokens.
// What counts as expired is owned by the webauthn module.
func (j *DbCleanupJobs) clearReauthenticationTokens(ctx context.Context) error {
st := j.db.
WithContext(ctx).
Delete(&model.ReauthenticationToken{}, "expires_at < ?", datatype.DateTime(time.Now()))
if st.Error != nil {
return fmt.Errorf("failed to clean expired reauthentication tokens: %w", st.Error)
count, err := webauthn.CleanupExpiredReauthenticationTokens(ctx, j.db)
if err != nil {
return fmt.Errorf("failed to clean expired reauthentication tokens: %w", err)
}
slog.InfoContext(ctx, "Cleaned expired reauthentication tokens", slog.Int64("count", st.RowsAffected))
slog.InfoContext(ctx, "Cleaned expired reauthentication tokens", slog.Int64("count", count))
return nil
}
@@ -147,20 +152,6 @@ func (j *DbCleanupJobs) clearAuditLogs(ctx context.Context) error {
return nil
}
// clearOidcPushedAuthorizationRequests deletes PAR records that have expired without being consumed
func (j *DbCleanupJobs) clearOidcPushedAuthorizationRequests(ctx context.Context) error {
st := j.db.
WithContext(ctx).
Delete(&model.OidcPushedAuthorizationRequest{}, "expires_at < ?", datatype.DateTime(time.Now()))
if st.Error != nil {
return fmt.Errorf("failed to clean expired pushed authorization requests: %w", st.Error)
}
slog.InfoContext(ctx, "Cleaned expired pushed authorization requests", slog.Int64("count", st.RowsAffected))
return nil
}
// ClearEmailVerificationTokens deletes email verification tokens that have expired
func (j *DbCleanupJobs) clearEmailVerificationTokens(ctx context.Context) error {
st := j.db.

View File

@@ -2,37 +2,56 @@ package job
import (
"context"
"errors"
"fmt"
"log/slog"
"path"
"strings"
"time"
"github.com/go-co-op/gocron/v2"
"github.com/italypaleale/francis/builtin/cronjob"
"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{}),
)
// 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}),
)
// GetFileCleanupJobs returns the CronJob actors
func GetFileCleanupJobs(db *gorm.DB, fileStorage storage.FileStorage) (cjs []*cronjob.CronJob, err error) {
job := &FileCleanupJobs{
db: db,
fileStorage: fileStorage,
}
return errors.Join(errs...)
// Create the built-in actor for the ClearUnusedDefaultProfilePictures job
cj, err := cronjob.New(
"ClearUnusedDefaultProfilePictures",
cronjob.WithJob(job.clearUnusedDefaultProfilePictures),
// Run every 24 hours
cronjob.WithInterval(24*time.Hour),
cronjob.WithLogger(slog.Default()),
)
if err != nil {
return nil, fmt.Errorf("error creating ClearUnusedDefaultProfilePictures job: %w", err)
}
cjs = append(cjs, cj)
// Create the built-in actor for the ClearOrphanedTempFiles job
// Only necessary for file system storage
if fileStorage.Type() == storage.TypeFileSystem {
cj, err := cronjob.New(
"ClearOrphanedTempFiles",
cronjob.WithJob(job.clearOrphanedTempFiles),
// Run every 12 hours
cronjob.WithInterval(12*time.Hour),
cronjob.WithLogger(slog.Default()),
)
if err != nil {
return nil, fmt.Errorf("error creating ClearOrphanedTempFiles job: %w", err)
}
cjs = append(cjs, cj)
}
return cjs, nil
}
type FileCleanupJobs struct {
@@ -91,7 +110,7 @@ func (j *FileCleanupJobs) clearUnusedDefaultProfilePictures(ctx context.Context)
func (j *FileCleanupJobs) clearOrphanedTempFiles(ctx context.Context) error {
const minAge = 10 * time.Minute
var deleted int
deleted := 0
err := j.fileStorage.Walk(ctx, "/", func(p storage.ObjectInfo) error {
// Only temp files
if !strings.HasSuffix(p.Path, "-tmp") {
@@ -110,7 +129,6 @@ func (j *FileCleanupJobs) clearOrphanedTempFiles(ctx context.Context) error {
deleted++
return nil
})
if err != nil {
return fmt.Errorf("failed to scan storage: %w", err)
}

View File

@@ -10,8 +10,10 @@ import (
backoff "github.com/cenkalti/backoff/v5"
"github.com/go-co-op/gocron/v2"
"github.com/google/uuid"
"go.opentelemetry.io/otel/trace"
"github.com/pocket-id/pocket-id/backend/internal/service"
"github.com/pocket-id/pocket-id/backend/internal/tracing"
)
type Scheduler struct {
@@ -65,52 +67,17 @@ func (s *Scheduler) Run(ctx context.Context) error {
}
func (s *Scheduler) RegisterJob(ctx context.Context, name string, def gocron.JobDefinition, jobFn func(ctx context.Context) error, opts service.RegisterJobOpts) error {
// Wrap the job in a handler that adds tracing and logging
jobFn = jobWithObservability(name, jobFn)
// 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
}
jobFn = jobWithBackOff(jobFn, opts.BackOff)
}
jobOptions := []gocron.JobOption{
gocron.WithContext(ctx),
gocron.WithName(name),
gocron.WithEventListeners(
gocron.BeforeJobRuns(func(jobID uuid.UUID, jobName string) {
slog.Info("Starting job",
slog.String("name", name),
slog.String("id", jobID.String()),
)
}),
gocron.AfterJobRuns(func(jobID uuid.UUID, jobName string) {
slog.Info("Job run successfully",
slog.String("name", name),
slog.String("id", jobID.String()),
)
}),
gocron.AfterJobRunsWithError(func(jobID uuid.UUID, jobName string, err error) {
slog.Error("Job failed with error",
slog.String("name", name),
slog.String("id", jobID.String()),
slog.Any("error", err),
)
}),
),
}
if opts.RunImmediately {
@@ -120,7 +87,6 @@ func (s *Scheduler) RegisterJob(ctx context.Context, name string, def gocron.Job
jobOptions = append(jobOptions, opts.ExtraOptions...)
_, err := s.scheduler.NewJob(def, gocron.NewTask(jobFn), jobOptions...)
if err != nil {
return fmt.Errorf("failed to register job %q: %w", name, err)
}
@@ -128,6 +94,76 @@ func (s *Scheduler) RegisterJob(ctx context.Context, name string, def gocron.Job
return nil
}
type (
jobNameKey struct{}
jobIDKey struct{}
jobFn = func(ctx context.Context) error
)
func jobWithObservability(jobName string, job jobFn) jobFn {
return func(ctx context.Context) error {
// Generate a random job ID
jobID := uuid.NewString()
// Save in the context
ctx = context.WithValue(ctx, jobNameKey{}, jobName)
ctx = context.WithValue(ctx, jobIDKey{}, jobID)
// Create a new context with the span
var err error
ctx, span := tracing.Start(ctx, "pocketid.job."+jobName,
trace.WithSpanKind(trace.SpanKindInternal),
trace.WithAttributes(
tracing.JobID(jobID),
),
)
defer tracing.End(span, err)
// Log the start
logger := slog.With(
slog.String("name", jobName),
slog.String("jobID", jobID),
)
start := time.Now()
logger.InfoContext(ctx, "Starting job")
// Run the job
err = job(ctx)
d := time.Since(start)
if err != nil {
logger.ErrorContext(ctx, "Job failed", slog.Any("error", err), slog.Duration("duration", d))
return err
}
logger.InfoContext(ctx, "Job run successfully", slog.Duration("duration", d))
return nil
}
}
func jobWithBackOff(job jobFn, bo backoff.BackOff) jobFn {
return func(ctx context.Context) error {
jobName, _ := (ctx.Value(jobNameKey{})).(string)
jobID, _ := (ctx.Value(jobIDKey{})).(string)
_, err := backoff.Retry(
ctx,
func() (struct{}, error) {
return struct{}{}, job(ctx)
},
backoff.WithBackOff(bo),
backoff.WithNotify(func(err error, d time.Duration) {
slog.WarnContext(ctx, "Job failed, retrying",
slog.String("name", jobName),
slog.String("jobID", jobID),
slog.Any("error", err),
slog.Duration("retryIn", d),
)
}),
)
return err
}
}
func jobDefWithJitter(interval time.Duration) gocron.JobDefinition {
const jitter = 5 * time.Minute

View File

@@ -2,19 +2,20 @@ package middleware
import (
"github.com/gin-gonic/gin"
"github.com/pocket-id/pocket-id/backend/internal/apikey"
"github.com/pocket-id/pocket-id/backend/internal/common"
"github.com/pocket-id/pocket-id/backend/internal/service"
)
type ApiKeyAuthMiddleware struct {
apiKeyService *service.ApiKeyService
jwtService *service.JwtService
apiKeyModule *apikey.Module
jwtService *service.JwtService
}
func NewApiKeyAuthMiddleware(apiKeyService *service.ApiKeyService, jwtService *service.JwtService) *ApiKeyAuthMiddleware {
func NewApiKeyAuthMiddleware(apiKeyModule *apikey.Module, jwtService *service.JwtService) *ApiKeyAuthMiddleware {
return &ApiKeyAuthMiddleware{
apiKeyService: apiKeyService,
jwtService: jwtService,
apiKeyModule: apiKeyModule,
jwtService: jwtService,
}
}
@@ -36,7 +37,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")
user, err := m.apiKeyService.ValidateApiKey(c.Request.Context(), apiKey)
user, err := m.apiKeyModule.ValidateApiKey(c.Request.Context(), apiKey)
if err != nil {
return "", false, &common.NotSignedInError{}
}

View File

@@ -4,6 +4,7 @@ import (
"errors"
"github.com/gin-gonic/gin"
"github.com/pocket-id/pocket-id/backend/internal/apikey"
"github.com/pocket-id/pocket-id/backend/internal/common"
"github.com/pocket-id/pocket-id/backend/internal/service"
)
@@ -22,12 +23,12 @@ type AuthOptions struct {
}
func NewAuthMiddleware(
apiKeyService *service.ApiKeyService,
apiKeyModule *apikey.Module,
userService *service.UserService,
jwtService *service.JwtService,
) *AuthMiddleware {
return &AuthMiddleware{
apiKeyMiddleware: NewApiKeyAuthMiddleware(apiKeyService, jwtService),
apiKeyMiddleware: NewApiKeyAuthMiddleware(apiKeyModule, jwtService),
jwtMiddleware: NewJwtAuthMiddleware(jwtService, userService),
options: AuthOptions{
AdminRequired: true,
@@ -74,11 +75,12 @@ func (m *AuthMiddleware) WithApiKeyAuthDisabled() *AuthMiddleware {
func (m *AuthMiddleware) Add() gin.HandlerFunc {
return func(c *gin.Context) {
userID, isAdmin, authenticationMethod, err := m.jwtMiddleware.Verify(c, m.options.AdminRequired)
userID, isAdmin, authenticationMethod, authenticationTime, err := m.jwtMiddleware.Verify(c, m.options.AdminRequired)
if err == nil {
c.Set("userID", userID)
c.Set("userIsAdmin", isAdmin)
c.Set("authenticationMethod", authenticationMethod)
c.Set("authenticationTime", authenticationTime)
if c.IsAborted() {
return
}

View File

@@ -11,11 +11,12 @@ import (
"github.com/stretchr/testify/require"
"gorm.io/gorm"
"github.com/pocket-id/pocket-id/backend/internal/apikey"
"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"
"github.com/pocket-id/pocket-id/backend/internal/utils"
testutils "github.com/pocket-id/pocket-id/backend/internal/utils/testing"
)
@@ -38,20 +39,23 @@ func TestWithApiKeyAuthDisabled(t *testing.T) {
require.NoError(t, err)
userService := service.NewUserService(db, jwtService, nil, nil, appConfigService, nil, nil, nil, nil)
apiKeyService, err := service.NewApiKeyService(t.Context(), db, nil)
apiKeyModule, err := apikey.New(t.Context(), apikey.Dependencies{DB: db})
require.NoError(t, err)
authMiddleware := NewAuthMiddleware(apiKeyService, userService, jwtService)
authMiddleware := NewAuthMiddleware(apiKeyModule, 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{
apiKeyToken := "middleware-test-api-key-raw-token"
apiKeyRecord := apikey.ApiKey{
Name: "Middleware API Key",
Key: utils.CreateSha256Hash(apiKeyToken),
UserID: user.ID,
ExpiresAt: datatype.DateTime(time.Now().Add(24 * time.Hour)),
})
require.NoError(t, err)
}
require.NoError(t, db.Create(&apiKeyRecord).Error)
router := gin.New()
router.Use(NewErrorHandlerMiddleware().Add())

View File

@@ -43,7 +43,7 @@ func isCorsPath(path string) bool {
switch path {
case "/api/oidc/token",
"/api/oidc/userinfo",
"/oidc/end-session",
"/api/oidc/end-session",
"/api/oidc/introspect",
"/.well-known/jwks.json",
"/.well-known/openid-configuration":

View File

@@ -1,11 +1,8 @@
package middleware
import (
"crypto/rand"
"encoding/base64"
"strings"
"github.com/gin-gonic/gin"
"github.com/pocket-id/pocket-id/backend/internal/utils"
)
// CspMiddleware sets a Content Security Policy header and, when possible,
@@ -16,56 +13,16 @@ func NewCspMiddleware() *CspMiddleware { return &CspMiddleware{} }
// GetCSPNonce returns the CSP nonce generated for this request, if any.
func GetCSPNonce(c *gin.Context) string {
if v, ok := c.Get("csp_nonce"); ok {
if s, ok := v.(string); ok {
return s
}
}
return ""
return utils.GetCSPNonce(c)
}
func (m *CspMiddleware) Add() gin.HandlerFunc {
return func(c *gin.Context) {
// Generate a random base64 nonce for this request
nonce := generateNonce()
c.Set("csp_nonce", nonce)
c.Writer.Header().Set("Content-Security-Policy", BuildCSP(nonce))
nonce := utils.GenerateCSPNonce()
utils.SetCSPNonce(c, nonce)
c.Writer.Header().Set("Content-Security-Policy", utils.BuildCSP(nonce))
c.Next()
}
}
func BuildCSP(nonce string, formActionExtra ...string) string {
formAction := "'self'"
if len(formActionExtra) > 0 {
b := strings.Builder{}
for _, extra := range formActionExtra {
if extra != "" {
b.WriteByte(' ')
b.WriteString(extra)
}
}
formAction += b.String()
}
return "default-src 'self'; " +
"base-uri 'self'; " +
"object-src 'none'; " +
"frame-ancestors 'none'; " +
"form-action " + formAction + "; " +
"img-src * blob:;" +
"font-src 'self'; " +
"style-src 'self' 'unsafe-inline'; " +
"script-src 'self' 'nonce-" + nonce + "'"
}
func generateNonce() string {
b := make([]byte, 16)
if _, err := rand.Read(b); err != nil {
return "" // if generation fails, return empty; policy will omit nonce
}
return base64.RawURLEncoding.EncodeToString(b)
}

View File

@@ -1,24 +0,0 @@
package middleware
import (
"strings"
"testing"
"github.com/stretchr/testify/assert"
)
func TestBuildCSP(t *testing.T) {
t.Run("uses self form action by default", func(t *testing.T) {
csp := BuildCSP("test-nonce")
assert.Contains(t, csp, "form-action 'self';")
assert.Contains(t, csp, "script-src 'self' 'nonce-test-nonce'")
})
t.Run("adds validated form action targets", func(t *testing.T) {
csp := BuildCSP("test-nonce", "https://example.com/callback")
assert.Contains(t, csp, "form-action 'self' https://example.com/callback;")
assert.Equal(t, 1, strings.Count(csp, "form-action"))
})
}

View File

@@ -105,6 +105,8 @@ func handleValidationError(validationErrors validator.ValidationErrors) string {
errorMessage = fmt.Sprintf("%s must only contain letters, numbers, underscores, dots, hyphens, and '@' symbols and not start or end with a special character", fieldName)
case "url":
errorMessage = fmt.Sprintf("%s must be a valid URL", fieldName)
case "resource_uri":
errorMessage = fmt.Sprintf("%s must be an absolute URI without whitespace or a fragment", fieldName)
case "min":
errorMessage = fmt.Sprintf("%s must be at least %s characters long", fieldName, ve.Param())
case "max":

View File

@@ -2,6 +2,7 @@ package middleware
import (
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/pocket-id/pocket-id/backend/internal/common"
@@ -20,7 +21,7 @@ func NewJwtAuthMiddleware(jwtService *service.JwtService, userService *service.U
func (m *JwtAuthMiddleware) Add(adminRequired bool) gin.HandlerFunc {
return func(c *gin.Context) {
userID, isAdmin, authenticationMethod, err := m.Verify(c, adminRequired)
userID, isAdmin, authenticationMethod, authenticationTime, err := m.Verify(c, adminRequired)
if err != nil {
c.Abort()
_ = c.Error(err)
@@ -30,11 +31,12 @@ func (m *JwtAuthMiddleware) Add(adminRequired bool) gin.HandlerFunc {
c.Set("userID", userID)
c.Set("userIsAdmin", isAdmin)
c.Set("authenticationMethod", authenticationMethod)
c.Set("authenticationTime", authenticationTime)
c.Next()
}
}
func (m *JwtAuthMiddleware) Verify(c *gin.Context, adminRequired bool) (subject string, isAdmin bool, authenticationMethod string, err error) {
func (m *JwtAuthMiddleware) Verify(c *gin.Context, adminRequired bool) (subject string, isAdmin bool, authenticationMethod string, authenticationTime time.Time, err error) {
// Extract the token from the cookie
accessToken, err := c.Cookie(cookie.AccessTokenCookieName)
if err != nil {
@@ -42,37 +44,38 @@ func (m *JwtAuthMiddleware) Verify(c *gin.Context, adminRequired bool) (subject
var ok bool
_, accessToken, ok = strings.Cut(c.GetHeader("Authorization"), " ")
if !ok || accessToken == "" {
return "", false, "", &common.NotSignedInError{}
return "", false, "", time.Time{}, &common.NotSignedInError{}
}
}
token, err := m.jwtService.VerifyAccessToken(accessToken)
if err != nil {
return "", false, "", &common.NotSignedInError{}
return "", false, "", time.Time{}, &common.NotSignedInError{}
}
authenticationMethod, err = service.GetAuthenticationMethod(token)
authenticationMethod, err = m.jwtService.GetAuthenticationMethod(token)
if err != nil {
return "", false, "", &common.NotSignedInError{}
return "", false, "", time.Time{}, &common.NotSignedInError{}
}
authenticationTime, _ = token.IssuedAt()
subject, ok := token.Subject()
if !ok {
_ = c.Error(&common.TokenInvalidError{})
return "", false, "", &common.TokenInvalidError{}
return "", false, "", time.Time{}, &common.TokenInvalidError{}
}
user, err := m.userService.GetUser(c, subject)
if err != nil {
return "", false, "", &common.NotSignedInError{}
return "", false, "", time.Time{}, &common.NotSignedInError{}
}
if user.Disabled {
return "", false, "", &common.UserDisabledError{}
return "", false, "", time.Time{}, &common.UserDisabledError{}
}
if adminRequired && !user.IsAdmin {
return "", false, "", &common.MissingPermissionError{}
return "", false, "", time.Time{}, &common.MissingPermissionError{}
}
return subject, user.IsAdmin, authenticationMethod, nil
return subject, user.IsAdmin, authenticationMethod, authenticationTime, nil
}

View File

@@ -1,47 +1,116 @@
package middleware
import (
"sync"
"context"
"errors"
"log/slog"
"math"
"net"
"net/http"
"strconv"
"time"
"github.com/pocket-id/pocket-id/backend/internal/common"
"github.com/gin-gonic/gin"
"golang.org/x/time/rate"
"github.com/italypaleale/francis/builtin/ratelimit"
"github.com/pocket-id/pocket-id/backend/internal/common"
)
type RateLimitMiddleware struct{}
// Rate-limit policy names
// Each constant names a limiter registered on the actor host and is the value passed to Add to select that limiter
const (
RateLimitAPI = "api"
RateLimitSignup = "signup"
RateLimitWebauthnLogin = "webauthn-login"
RateLimitWebauthnReauthenticate = "webauthn-reauthenticate"
RateLimitOneTimeAccessToken = "one-time-access-token"
RateLimitOneTimeAccessEmail = "one-time-access-email"
RateLimitSendEmailVerification = "send-email-verification"
RateLimitVerifyEmail = "verify-email"
RateLimitInternal = "internal"
)
func NewRateLimitMiddleware() *RateLimitMiddleware {
return &RateLimitMiddleware{}
// RateLimitPolicy is the configuration for a single rate-limit actor
// Each policy maps to one Francis rate-limit actor type and requests are keyed by client IP, so every IP is limited independently and per-route limits stay isolated from each other
type RateLimitPolicy struct {
// Name must be unique across policies and must not contain '/'
Name string
// Rate is the number of calls admitted per Per window
Rate int
// Per is the window the rate applies over
Per time.Duration
// Burst is the token bucket's capacity, i.e. how many calls may be admitted instantly before throttling kicks in
Burst int
}
func (m *RateLimitMiddleware) Add(limit rate.Limit, burst int) gin.HandlerFunc {
// RateLimitPolicies returns the configuration for every rate-limit policy
// The slice is built on each call so the policies are not retained at the package level, and the actor host registers one limiter per entry
func RateLimitPolicies() []RateLimitPolicy {
return []RateLimitPolicy{
{Name: RateLimitAPI, Rate: 100, Per: time.Second, Burst: 300},
{Name: RateLimitSignup, Rate: 2, Per: time.Minute, Burst: 10},
{Name: RateLimitWebauthnLogin, Rate: 1, Per: 5 * time.Second, Burst: 10},
{Name: RateLimitWebauthnReauthenticate, Rate: 1, Per: 10 * time.Second, Burst: 5},
{Name: RateLimitOneTimeAccessToken, Rate: 1, Per: 10 * time.Second, Burst: 5},
{Name: RateLimitOneTimeAccessEmail, Rate: 2, Per: 10 * time.Minute, Burst: 5},
{Name: RateLimitSendEmailVerification, Rate: 2, Per: 10 * time.Minute, Burst: 1},
{Name: RateLimitVerifyEmail, Rate: 1, Per: 10 * time.Second, Burst: 5},
{Name: RateLimitInternal, Rate: 20, Per: time.Second, Burst: 20},
}
}
type RateLimitMiddleware struct {
services map[string]*ratelimit.RateLimitService
}
func NewRateLimitMiddleware(services map[string]*ratelimit.RateLimitService) *RateLimitMiddleware {
return &RateLimitMiddleware{
services: services,
}
}
func (m *RateLimitMiddleware) Add(policy string) 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
// Start the cleanup routine
go cleanupClients(&mu, clients)
// A missing service means the policy was never registered on the actor host, which is a development-time errror
svc := m.services[policy]
if svc == nil {
return func(c *gin.Context) {
c.AbortWithStatus(http.StatusInternalServerError)
}
}
return func(c *gin.Context) {
ip := c.ClientIP()
// Skip rate limiting for localhost and test environment
// If the client ip is localhost the request comes from the frontend
if ip == "" || ip == "127.0.0.1" || ip == "::1" || common.EnvConfig.AppEnv.IsTest() {
if common.EnvConfig.AppEnv == common.AppEnvTest || net.ParseIP(ip).IsLoopback() {
c.Next()
return
}
limiter := getLimiter(ip, limit, burst, &mu, clients)
if !limiter.Allow() {
// Allow is a non-blocking token-bucket check keyed by client IP: it consumes a slot and reports whether the call is admitted right now
allowed, retryAfter, err := svc.Allow(c.Request.Context(), ip)
if err != nil {
// Fail open so a limiter error does not turn away otherwise-valid traffic
if !errors.Is(err, context.Canceled) {
// A cancelled context just means the client went away, so it is not worth logging
slog.WarnContext(c.Request.Context(), "Rate limiter unavailable, allowing request", slog.String("policy", policy), slog.Any("error", err))
}
c.Next()
return
}
if !allowed {
// Advertise when the caller may retry, mapping the limiter's delay onto a Retry-After header
if retryAfter > 0 {
c.Header("Retry-After", strconv.Itoa(int(math.Ceil(retryAfter.Seconds()))))
}
_ = c.Error(&common.TooManyRequestsError{})
c.Abort()
return
@@ -50,37 +119,3 @@ func (m *RateLimitMiddleware) Add(limit rate.Limit, burst int) gin.HandlerFunc {
c.Next()
}
}
type client struct {
limiter *rate.Limiter
lastSeen time.Time
}
// Cleanup routine to remove stale clients that haven't been seen for a while
func cleanupClients(mu *sync.Mutex, clients map[string]*client) {
for {
time.Sleep(time.Minute)
mu.Lock()
for ip, client := range clients {
if time.Since(client.lastSeen) > 3*time.Minute {
delete(clients, ip)
}
}
mu.Unlock()
}
}
// getLimiter retrieves the rate limiter for a given IP address, creating one if it doesn't exist
func getLimiter(ip string, limit rate.Limit, burst int, mu *sync.Mutex, clients map[string]*client) *rate.Limiter {
mu.Lock()
defer mu.Unlock()
if client, exists := clients[ip]; exists {
client.lastSeen = time.Now()
return client.limiter
}
limiter := rate.NewLimiter(limit, burst)
clients[ip] = &client{limiter: limiter, lastSeen: time.Now()}
return limiter
}

View File

@@ -0,0 +1,147 @@
package middleware
import (
"context"
"net"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/gin-gonic/gin"
"github.com/italypaleale/francis/builtin/ratelimit"
"github.com/italypaleale/francis/host/local"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/pocket-id/pocket-id/backend/internal/common"
testutils "github.com/pocket-id/pocket-id/backend/internal/utils/testing"
)
// startRateLimitServices registers one rate-limit actor per policy on an in-memory test actor host and returns the bound services keyed by policy name
func startRateLimitServices(t *testing.T, policies ...RateLimitPolicy) map[string]*ratelimit.RateLimitService {
t.Helper()
limiters := make(map[string]*ratelimit.RateLimit, len(policies))
opts := make([]local.HostOption, 0, len(policies))
for _, p := range policies {
rl, err := ratelimit.New(p.Name, ratelimit.WithRate(p.Rate), ratelimit.WithPer(p.Per), ratelimit.WithBurst(p.Burst))
require.NoError(t, err)
limiters[p.Name] = rl
opts = append(opts, local.WithBuiltInActor(rl))
}
h := testutils.NewActorHostForTest(t, opts...)
services := make(map[string]*ratelimit.RateLimitService, len(limiters))
svc := h.Service()
for name, rl := range limiters {
services[name] = rl.Service(svc)
}
return services
}
// newRateLimitRouter builds a gin engine that runs the rate-limit middleware for the given policy on GET /test
// Trusted proxies are disabled so ClientIP resolves to the request's RemoteAddr, and the error handler turns the middleware's error into a 429 response
func newRateLimitRouter(t *testing.T, services map[string]*ratelimit.RateLimitService, policy string) *gin.Engine {
t.Helper()
r := gin.New()
err := r.SetTrustedProxies(nil)
require.NoError(t, err)
r.Use(NewErrorHandlerMiddleware().Add())
mw := NewRateLimitMiddleware(services)
r.GET("/test", mw.Add(policy), func(c *gin.Context) {
c.String(http.StatusOK, "ok")
})
return r
}
// doRateLimitRequest sends a GET /test request as the given client IP and returns the response recorder
func doRateLimitRequest(ctx context.Context, r *gin.Engine, ip string) *httptest.ResponseRecorder {
req := httptest.NewRequestWithContext(ctx, http.MethodGet, "/test", nil)
req.RemoteAddr = net.JoinHostPort(ip, "12345")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
return w
}
func TestRateLimitMiddleware(t *testing.T) {
gin.SetMode(gin.TestMode)
// The middleware reads the global config, so restore it after the test
originalEnvConfig := common.EnvConfig
t.Cleanup(func() {
common.EnvConfig = originalEnvConfig
})
common.EnvConfig.AppEnv = common.AppEnvProduction
common.EnvConfig.DisableRateLimiting = false
// A slow refill (one token per hour) with a burst of 2 means the first two calls for a key are admitted and the third is rejected, with no refill during the test
const policy = "test-limit"
services := startRateLimitServices(t, RateLimitPolicy{Name: policy, Rate: 1, Per: time.Hour, Burst: 2})
t.Run("rejects requests over the limit and sets Retry-After", func(t *testing.T) {
r := newRateLimitRouter(t, services, policy)
const ip = "203.0.113.1"
require.Equal(t, http.StatusOK, doRateLimitRequest(t.Context(), r, ip).Code)
require.Equal(t, http.StatusOK, doRateLimitRequest(t.Context(), r, ip).Code)
w := doRateLimitRequest(t.Context(), r, ip)
require.Equal(t, http.StatusTooManyRequests, w.Code)
assert.NotEmpty(t, w.Header().Get("Retry-After"), "a throttled response should advertise Retry-After")
})
t.Run("limits each client IP independently", func(t *testing.T) {
r := newRateLimitRouter(t, services, policy)
// Exhaust the budget for the first IP
require.Equal(t, http.StatusOK, doRateLimitRequest(t.Context(), r, "203.0.113.2").Code)
require.Equal(t, http.StatusOK, doRateLimitRequest(t.Context(), r, "203.0.113.2").Code)
require.Equal(t, http.StatusTooManyRequests, doRateLimitRequest(t.Context(), r, "203.0.113.2").Code)
// A different IP still has its full budget
require.Equal(t, http.StatusOK, doRateLimitRequest(t.Context(), r, "203.0.113.3").Code)
})
t.Run("allows all requests when rate limiting is disabled", func(t *testing.T) {
common.EnvConfig.DisableRateLimiting = true
t.Cleanup(func() { common.EnvConfig.DisableRateLimiting = false })
r := newRateLimitRouter(t, services, policy)
for range 5 {
require.Equal(t, http.StatusOK, doRateLimitRequest(t.Context(), r, "203.0.113.4").Code)
}
})
t.Run("skips rate limiting for loopback addresses", func(t *testing.T) {
r := newRateLimitRouter(t, services, policy)
for _, ip := range []string{"127.0.0.1", "::1"} {
for range 5 {
require.Equal(t, http.StatusOK, doRateLimitRequest(t.Context(), r, ip).Code)
}
}
})
t.Run("skips rate limiting in the test environment", func(t *testing.T) {
common.EnvConfig.AppEnv = common.AppEnvTest
t.Cleanup(func() { common.EnvConfig.AppEnv = common.AppEnvProduction })
r := newRateLimitRouter(t, services, policy)
for range 5 {
require.Equal(t, http.StatusOK, doRateLimitRequest(t.Context(), r, "203.0.113.5").Code)
}
})
t.Run("fails with 500 when the policy is not registered", func(t *testing.T) {
// An unknown policy has no bound service, which is a configuration error surfaced as a 500
r := newRateLimitRouter(t, services, "does-not-exist")
require.Equal(t, http.StatusInternalServerError, doRateLimitRequest(t.Context(), r, "203.0.113.6").Code)
})
}

View File

@@ -3,14 +3,13 @@ package model
import (
"database/sql/driver"
"encoding/json"
"strings"
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
"github.com/pocket-id/pocket-id/backend/internal/utils"
)
type UserAuthorizedOidcClient struct {
Scope string
Scope datatype.StringList
LastUsedAt datatype.DateTime `sortable:"true"`
UserID string `gorm:"primary_key;"`
@@ -20,35 +19,11 @@ type UserAuthorizedOidcClient struct {
Client OidcClient
}
func (c UserAuthorizedOidcClient) Scopes() []string {
if len(c.Scope) == 0 {
return []string{}
}
return strings.Split(c.Scope, " ")
}
type OidcAuthorizationCode struct {
Base
Code string
Scope string
AuthenticationMethod string
Nonce string
CodeChallenge *string
CodeChallengeMethodSha256 *bool
ExpiresAt datatype.DateTime
UserID string
User User
ClientID string
}
type OidcClient struct {
Base
Name string `sortable:"true"`
Description string
Secret string
CallbackURLs UrlList
LogoutCallbackURLs UrlList
@@ -58,9 +33,11 @@ type OidcClient struct {
PkceEnabled bool `sortable:"true" filterable:"true"`
RequiresReauthentication bool `sortable:"true" filterable:"true"`
RequiresPushedAuthorizationRequests bool `sortable:"true" filterable:"true"`
SkipConsent bool `sortable:"true" filterable:"true"`
Credentials OidcClientCredentials
LaunchURL *string
IsGroupRestricted bool `sortable:"true" filterable:"true"`
PkceSupported bool `sortable:"true" filterable:"true"`
AllowedUserGroups []UserGroup `gorm:"many2many:oidc_clients_allowed_user_groups;"`
CreatedByID *string
@@ -76,39 +53,16 @@ func (c OidcClient) HasDarkLogo() bool {
return c.DarkImageType != nil && *c.DarkImageType != ""
}
type OidcRefreshToken struct {
Base
Token string
IdTokenJti *string
ExpiresAt datatype.DateTime
Scope string
AuthenticationMethod string
UserID string
User User
ClientID string
Client OidcClient
}
func (c OidcRefreshToken) Scopes() []string {
if len(c.Scope) == 0 {
return []string{}
}
return strings.Split(c.Scope, " ")
}
type OidcClientCredentials struct { //nolint:recvcheck
FederatedIdentities []OidcClientFederatedIdentity `json:"federatedIdentities,omitempty"`
}
type OidcClientFederatedIdentity struct {
Issuer string `json:"issuer"`
Subject string `json:"subject,omitempty"`
Audience string `json:"audience,omitempty"`
JWKS string `json:"jwks,omitempty"` // URL of the JWKS
Issuer string `json:"issuer"`
Subject string `json:"subject,omitempty"`
Audience string `json:"audience,omitempty"`
JWKS string `json:"jwks,omitempty"` // URL of the JWKS
ReplayProtection bool `json:"replayProtection,omitempty"`
}
func (occ OidcClientCredentials) FederatedIdentityForIssuer(issuer string) (OidcClientFederatedIdentity, bool) {
@@ -142,48 +96,3 @@ func (cu *UrlList) Scan(value any) error {
func (cu UrlList) Value() (driver.Value, error) {
return json.Marshal(cu)
}
type OidcDeviceCode struct {
Base
DeviceCode string
UserCode string
Scope string
AuthenticationMethod string
Nonce string
ExpiresAt datatype.DateTime
IsAuthorized bool
UserID *string
User User
ClientID string
Client OidcClient
}
type OidcPushedAuthorizationRequest struct {
Base
RequestURI string
ClientID string
Parameters OidcAuthorizationRequestParameters
ExpiresAt datatype.DateTime
}
type OidcAuthorizationRequestParameters struct { //nolint:recvcheck
Scope string `json:"scope,omitempty"`
RedirectURI string `json:"redirect_uri,omitempty"`
State string `json:"state,omitempty"`
Nonce string `json:"nonce,omitempty"`
CodeChallenge string `json:"code_challenge,omitempty"`
CodeChallengeMethod string `json:"code_challenge_method,omitempty"`
ResponseType string `json:"response_type,omitempty"`
Prompt string `json:"prompt,omitempty"`
ResponseMode string `json:"response_mode,omitempty"`
}
func (p *OidcAuthorizationRequestParameters) Scan(value any) error {
return utils.UnmarshalJSONFromDatabase(p, value)
}
func (p OidcAuthorizationRequestParameters) Value() (driver.Value, error) {
return json.Marshal(p)
}

View File

@@ -67,14 +67,17 @@ func (e EncryptedString) String() string {
}
// DeriveEncryptedStringKey derives a key for encrypting EncryptedString values from the master key.
// Note: changing this function in any is considered a breaking change that will make all encrypted stored data irrecoverable.
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 {
_, err := io.ReadFull(r, key)
if err != nil {
return nil, err
}
return key, nil
}

View File

@@ -0,0 +1,20 @@
package datatype
import (
"encoding/hex"
"testing"
"github.com/stretchr/testify/require"
)
func TestDeriveEncryptedStringKeyUsesStableValue(t *testing.T) {
masterKey := []byte("test-encryption-key")
expectedHex := "8a08281e815dac248bd216b8ceff063c390f3515475909bfd4380ed640bd854e"
expected, err := hex.DecodeString(expectedHex)
require.NoError(t, err)
actual, err := DeriveEncryptedStringKey(masterKey)
require.NoError(t, err)
require.Equal(t, expected, actual)
}

View File

@@ -0,0 +1,18 @@
package datatype
import (
"database/sql/driver"
"encoding/json"
"github.com/pocket-id/pocket-id/backend/internal/utils"
)
type StringList []string //nolint:recvcheck
func (s *StringList) Scan(value any) error {
return utils.UnmarshalJSONFromDatabase(s, value)
}
func (s StringList) Value() (driver.Value, error) {
return json.Marshal(s)
}

View File

@@ -3,22 +3,11 @@ package model
import (
"database/sql/driver"
"encoding/json"
"time"
"github.com/go-webauthn/webauthn/protocol"
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
"github.com/pocket-id/pocket-id/backend/internal/utils"
)
type WebauthnSession struct {
Base
Challenge string
ExpiresAt datatype.DateTime
UserVerification string
CredentialParams CredentialParameters
}
type WebauthnCredential struct {
Base
@@ -34,27 +23,6 @@ type WebauthnCredential struct {
UserID string
}
type PublicKeyCredentialCreationOptions struct {
Response protocol.PublicKeyCredentialCreationOptions
SessionID string
Timeout time.Duration
}
type PublicKeyCredentialRequestOptions struct {
Response protocol.PublicKeyCredentialRequestOptions
SessionID string
Timeout time.Duration
}
type ReauthenticationToken struct {
Base
Token string
ExpiresAt datatype.DateTime
UserID string
User User
}
type AuthenticatorTransportList []protocol.AuthenticatorTransport //nolint:recvcheck
// Scan and Value methods for GORM to handle the custom type
@@ -65,14 +33,3 @@ func (atl *AuthenticatorTransportList) Scan(value any) error {
func (atl AuthenticatorTransportList) Value() (driver.Value, error) {
return json.Marshal(atl)
}
type CredentialParameters []protocol.CredentialParameter //nolint:recvcheck
// Scan and Value methods for GORM to handle the custom type
func (cp *CredentialParameters) Scan(value any) error {
return utils.UnmarshalJSONFromDatabase(cp, value)
}
func (cp CredentialParameters) Value() (driver.Value, error) {
return json.Marshal(cp)
}

View File

@@ -0,0 +1,59 @@
package oidc
import (
"context"
"slices"
"github.com/ory/fosite"
fositeoauth2 "github.com/ory/fosite/handler/oauth2"
)
// isIdentityScope reports whether the scope is an OIDC identity scope whose presence lets a token be presented to Pocket ID's own identity endpoints such as /userinfo
// offline_access is deliberately excluded: it only requests a refresh token and is not tied to any resource server
func isIdentityScope(scope string) bool {
return scope != "offline_access" && isStandardScope(scope)
}
// hasIdentityScope reports whether any of the granted scopes is an OIDC identity scope
func hasIdentityScope(scopes fosite.Arguments) bool {
return slices.ContainsFunc(scopes, isIdentityScope)
}
// withIdentityAudience returns a view of the requester whose granted audience additionally includes the issuer when the token carries an identity scope
// This lets an access token that was granted an identity scope be presented to Pocket ID's own identity endpoints such as /userinfo, even when it was also audienced to a custom API
// The issuer is added only to the materialized access token and never to the underlying grant, so a refresh does not have to re-whitelist the issuer against the client and a machine token whose identity scopes were stripped never receives it
func withIdentityAudience(requester fosite.Requester, issuer string) fosite.Requester {
if issuer == "" || !hasIdentityScope(requester.GetGrantedScopes()) {
return requester
}
granted := requester.GetGrantedAudience()
if granted.Has(issuer) {
return requester
}
audience := make(fosite.Arguments, 0, len(granted)+1)
audience = append(audience, granted...)
audience = append(audience, issuer)
return identityAudienceRequester{Requester: requester, grantedAudience: audience}
}
// identityAudienceRequester overrides only the granted audience of the wrapped requester
type identityAudienceRequester struct {
fosite.Requester
grantedAudience fosite.Arguments
}
func (r identityAudienceRequester) GetGrantedAudience() fosite.Arguments {
return r.grantedAudience
}
// identityAudienceAccessTokenStrategy wraps the access token strategy so an access token granted an identity scope also lists the issuer in its audience, keeping the self-contained JWT consistent with what is persisted for introspection and userinfo
type identityAudienceAccessTokenStrategy struct {
fositeoauth2.CoreStrategy
issuer string
}
func (s identityAudienceAccessTokenStrategy) GenerateAccessToken(ctx context.Context, requester fosite.Requester) (string, string, error) {
return s.CoreStrategy.GenerateAccessToken(ctx, withIdentityAudience(requester, s.issuer))
}

View File

@@ -0,0 +1,67 @@
package oidc
import (
"testing"
"github.com/ory/fosite"
"github.com/stretchr/testify/assert"
"github.com/pocket-id/pocket-id/backend/internal/model"
)
func newScopeTestRequester(clientID string, granted fosite.Arguments, audience fosite.Arguments) *fosite.Request {
request := fosite.NewRequest()
request.Client = Client{
OidcClient: model.OidcClient{
Base: model.Base{ID: clientID},
},
}
request.GrantedScope = granted
request.GrantedAudience = audience
return request
}
func TestWithIdentityAudienceAddsIssuerForIdentityScopes(t *testing.T) {
const issuer = "https://issuer.example.com"
// A token granted an identity scope alongside a custom API also lists the issuer so it can be presented to /userinfo
apiRequest := newScopeTestRequester("client-1",
fosite.Arguments{"openid", "read:orders"},
fosite.Arguments{"https://api.orders.example.com"},
)
assert.ElementsMatch(t,
fosite.Arguments{"https://api.orders.example.com", issuer},
withIdentityAudience(apiRequest, issuer).GetGrantedAudience(),
)
// A plain login token gains the issuer audience alongside the client it was bound to
loginRequest := newScopeTestRequester("client-1",
fosite.Arguments{"openid", "profile", "email"},
fosite.Arguments{"client-1"},
)
assert.ElementsMatch(t,
fosite.Arguments{"client-1", issuer},
withIdentityAudience(loginRequest, issuer).GetGrantedAudience(),
)
}
func TestWithIdentityAudienceLeavesNonIdentityTokensUntouched(t *testing.T) {
const issuer = "https://issuer.example.com"
// A token audienced only to a custom API with no identity scope never gains the issuer, so it cannot reach /userinfo
apiOnly := newScopeTestRequester("client-1",
fosite.Arguments{"read:orders"},
fosite.Arguments{"https://api.orders.example.com"},
)
assert.Equal(t, fosite.Arguments{"https://api.orders.example.com"}, withIdentityAudience(apiOnly, issuer).GetGrantedAudience())
// offline_access alone is not an identity scope, so it does not add the issuer either
offlineOnly := newScopeTestRequester("client-1",
fosite.Arguments{"offline_access", "read:orders"},
fosite.Arguments{"https://api.orders.example.com"},
)
assert.Equal(t, fosite.Arguments{"https://api.orders.example.com"}, withIdentityAudience(offlineOnly, issuer).GetGrantedAudience())
// An empty issuer disables the behavior entirely, leaving the audience untouched
assert.Equal(t, fosite.Arguments{"client-1"}, withIdentityAudience(newScopeTestRequester("client-1", fosite.Arguments{"openid"}, fosite.Arguments{"client-1"}), "").GetGrantedAudience())
}

View File

@@ -0,0 +1,117 @@
package oidc
import (
"context"
"slices"
"github.com/ory/fosite"
"github.com/pocket-id/pocket-id/backend/internal/dto"
"gorm.io/gorm"
)
// SubjectType identifies the subject the requested access token will represent
type SubjectType string
const (
// SubjectTypeUser covers user-delegated access: every flow whose access token acts on behalf of an end user
SubjectTypeUser SubjectType = "user"
// SubjectTypeClient covers client access: the client credentials grant, where the client acts as itself without a user
SubjectTypeClient SubjectType = "client"
)
var standardScopes = fosite.Arguments{"openid", "profile", "email", "groups", "offline_access"}
func isStandardScope(scope string) bool {
return slices.Contains(standardScopes, scope)
}
// APIAccessProvider is implemented by the api feature module
// It lets the OIDC module widen per-client scope and audience validation and resolve RFC 8707 resources to the permission keys a client may be granted
type APIAccessProvider interface {
// ClientAPIScopes returns the custom-API permission keys and the distinct API audiences a client is allowed to request across all subject types
ClientAPIScopes(ctx context.Context, tx *gorm.DB, clientID string) (scopes []string, audiences []string, err error)
// AllowedScopesForAudience returns the permission keys the client is allowed for the API identified by the given audience and subject type, and whether such an API exists
AllowedScopesForAudience(ctx context.Context, tx *gorm.DB, clientID, audience string, subjectType SubjectType) (scopes []string, apiExists bool, err error)
// DescribePermissions returns the display information for the given permission keys of the API identified by audience
// Unknown keys are omitted
DescribePermissions(ctx context.Context, audience string, keys []string) ([]dto.ScopeInfoDto, error)
}
// resolveResource maps an RFC 8707 resource, which may be empty, to the audience to stamp on the issued token and the subset of requestedScopes that may be granted
// An empty resource is a plain login token bound to the requesting client and yields only identity scopes
// The subject type selects which of the client's grants apply: user-delegated flows only see user grants, the client credentials grant only sees client grants
func resolveResource(ctx context.Context, tx *gorm.DB, provider APIAccessProvider, clientID, resource string, requestedScopes []string, subjectType SubjectType) (audience string, grantedScopes []string, err error) {
grantable := make(map[string]struct{}, len(standardScopes))
for _, scope := range standardScopes {
grantable[scope] = struct{}{}
}
if resource == "" {
// A plain login token is audienced to the requesting client
audience = clientID
} else {
if !fosite.IsValidResourceIndicatorURI(resource) || provider == nil {
return "", nil, fosite.ErrInvalidTarget.WithHintf("The requested resource '%s' is invalid, missing, unknown, or malformed.", resource)
}
allowedScopes, apiExists, err := provider.AllowedScopesForAudience(ctx, tx, clientID, resource, subjectType)
if err != nil {
return "", nil, err
}
if !apiExists {
return "", nil, fosite.ErrInvalidTarget.WithHintf("The requested resource '%s' is invalid, missing, unknown, or malformed.", resource)
}
if len(allowedScopes) == 0 {
return "", nil, fosite.ErrAccessDenied.WithHintf("The OAuth 2.0 Client is not allowed to access resource '%s'.", resource)
}
audience = resource
for _, scope := range allowedScopes {
grantable[scope] = struct{}{}
}
// A client credentials request without explicit scopes gets everything the client is granted for the API
if subjectType == SubjectTypeClient && len(requestedScopes) == 0 {
requestedScopes = allowedScopes
}
}
granted := make(fosite.Arguments, 0, len(requestedScopes))
for _, scope := range requestedScopes {
if _, ok := grantable[scope]; !ok {
return "", nil, fosite.ErrInvalidScope.WithHintf("The scope '%s' is not available for the requested resource.", scope)
}
if !granted.Has(scope) {
granted = append(granted, scope)
}
}
return audience, granted, nil
}
// grantResourceIndicator applies a resolved resource audience and scopes to the request
func grantResourceIndicator(requester fosite.Requester, audience string, scopes []string) {
for _, scope := range scopes {
requester.GrantScope(scope)
}
if audience != "" {
requester.GrantAudience(audience)
}
}
// consentScopeKey qualifies a custom-API scope by its audience so the same permission key on two different APIs is consented to separately
// The unit-separator delimiter is collision-free because audiences are validated as URIs and permission keys are restricted to RFC 6749 scope-token characters, so neither can contain it
// Standard identity scopes stay bare for backward compatibility with existing consents
func consentScopeKey(audience, scope string) string {
if isStandardScope(scope) {
return scope
}
return audience + "\x1f" + scope
}
func consentScopeKeys(audience string, scopes []string) []string {
keys := make([]string, len(scopes))
for i, scope := range scopes {
keys[i] = consentScopeKey(audience, scope)
}
return keys
}

View File

@@ -0,0 +1,176 @@
package oidc
import (
"context"
"slices"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
"github.com/pocket-id/pocket-id/backend/internal/dto"
)
// fakeAPIAccess implements APIAccessProvider from an audience -> subject type -> allowed-scopes map.
// An audience present in the map exists as an API even when a subject type has no grants.
type fakeAPIAccess struct {
allowed map[string]map[SubjectType][]string
}
// userAccess builds a fakeAPIAccess with only user-delegated grants, for tests that don't care about the split.
func userAccess(allowed map[string][]string) fakeAPIAccess {
f := fakeAPIAccess{allowed: map[string]map[SubjectType][]string{}}
for audience, scopes := range allowed {
f.allowed[audience] = map[SubjectType][]string{SubjectTypeUser: scopes}
}
return f
}
func (f fakeAPIAccess) ClientAPIScopes(_ context.Context, _ *gorm.DB, _ string) ([]string, []string, error) {
seen := map[string]struct{}{}
var scopes, audiences []string
for audience, bySubject := range f.allowed {
audiences = append(audiences, audience)
for _, scopeKeys := range bySubject {
for _, key := range scopeKeys {
if _, ok := seen[key]; !ok {
seen[key] = struct{}{}
scopes = append(scopes, key)
}
}
}
}
return scopes, audiences, nil
}
func (f fakeAPIAccess) AllowedScopesForAudience(_ context.Context, _ *gorm.DB, _ string, audience string, subjectType SubjectType) ([]string, bool, error) {
bySubject, exists := f.allowed[audience]
if !exists {
return nil, false, nil
}
return bySubject[subjectType], true, nil
}
func (f fakeAPIAccess) DescribePermissions(_ context.Context, audience string, keys []string) ([]dto.ScopeInfoDto, error) {
bySubject, ok := f.allowed[audience]
if !ok {
return nil, nil
}
var infos []dto.ScopeInfoDto
for _, key := range keys {
for _, allowed := range bySubject {
if slices.Contains(allowed, key) {
infos = append(infos, dto.ScopeInfoDto{Key: key, Name: key})
break
}
}
}
return infos, nil
}
func TestResolveResourceDefaultIsLoginToken(t *testing.T) {
// With no resource the token is a plain login token audienced to the requesting client
audience, granted, err := resolveResource(t.Context(), nil, nil, "client-1", "", []string{"openid", "profile"}, SubjectTypeUser)
require.NoError(t, err)
assert.Equal(t, "client-1", audience)
assert.Equal(t, []string{"openid", "profile"}, granted)
}
func TestResolveResourceRejectsCustomScopeWithoutResource(t *testing.T) {
// Requesting a custom scope without targeting its API must be rejected, not dropped.
_, _, err := resolveResource(t.Context(), nil, nil, "client-1", "", []string{"openid", "read:orders"}, SubjectTypeUser)
require.Error(t, err)
}
func TestResolveResourceCustomAPIGrantsValidScopes(t *testing.T) {
provider := userAccess(map[string][]string{
"https://api.orders.example.com": {"read:orders", "write:orders"},
})
audience, granted, err := resolveResource(t.Context(), nil, provider, "client-1", "https://api.orders.example.com", []string{"openid", "read:orders"}, SubjectTypeUser)
require.NoError(t, err)
assert.Equal(t, "https://api.orders.example.com", audience)
// openid stays (identity, for the ID token); read:orders is allowed for this API.
assert.ElementsMatch(t, []string{"openid", "read:orders"}, granted)
}
func TestResolveResourceRejectsScopeFromAnotherAPI(t *testing.T) {
provider := userAccess(map[string][]string{
"https://api.orders.example.com": {"read:orders", "write:orders"},
})
// write:billing belongs to a different API than the one targeted -> rejected.
_, _, err := resolveResource(t.Context(), nil, provider, "client-1", "https://api.orders.example.com", []string{"openid", "write:billing"}, SubjectTypeUser)
require.Error(t, err)
}
func TestResolveResourceUnknownIsRejected(t *testing.T) {
provider := userAccess(map[string][]string{})
_, _, err := resolveResource(t.Context(), nil, provider, "client-1", "https://api.unknown.example.com", []string{"read"}, SubjectTypeUser)
require.Error(t, err)
}
func TestResolveResourceUnauthorizedClientIsRejected(t *testing.T) {
// The API exists but the client has no allowed permissions for it.
provider := userAccess(map[string][]string{
"https://api.orders.example.com": {},
})
_, _, err := resolveResource(t.Context(), nil, provider, "client-1", "https://api.orders.example.com", []string{"read:orders"}, SubjectTypeUser)
require.Error(t, err)
}
// TestResolveResourceSubjectTypesAreIndependent guards the core of the user/client separation:
// a permission granted for one subject type must not leak into the other, so a scope users may
// delegate cannot be minted machine-to-machine and a machine scope cannot ride along on a login.
func TestResolveResourceSubjectTypesAreIndependent(t *testing.T) {
provider := fakeAPIAccess{allowed: map[string]map[SubjectType][]string{
"https://api.orders.example.com": {
SubjectTypeUser: {"read:orders"},
SubjectTypeClient: {"write:orders"},
},
}}
// The user-delegated grant works for user flows...
audience, granted, err := resolveResource(t.Context(), nil, provider, "client-1", "https://api.orders.example.com", []string{"read:orders"}, SubjectTypeUser)
require.NoError(t, err)
assert.Equal(t, "https://api.orders.example.com", audience)
assert.ElementsMatch(t, []string{"read:orders"}, granted)
// ...but not for the client itself.
_, _, err = resolveResource(t.Context(), nil, provider, "client-1", "https://api.orders.example.com", []string{"read:orders"}, SubjectTypeClient)
require.Error(t, err)
// The client grant works machine-to-machine...
audience, granted, err = resolveResource(t.Context(), nil, provider, "client-1", "https://api.orders.example.com", []string{"write:orders"}, SubjectTypeClient)
require.NoError(t, err)
assert.Equal(t, "https://api.orders.example.com", audience)
assert.ElementsMatch(t, []string{"write:orders"}, granted)
// ...but users cannot be asked to delegate it.
_, _, err = resolveResource(t.Context(), nil, provider, "client-1", "https://api.orders.example.com", []string{"write:orders"}, SubjectTypeUser)
require.Error(t, err)
}
// TestResolveResourceClientWithoutClientGrantsIsDenied models a client that only has user-delegated
// grants: the API must stay unreachable through the client credentials grant entirely.
func TestResolveResourceClientWithoutClientGrantsIsDenied(t *testing.T) {
provider := fakeAPIAccess{allowed: map[string]map[SubjectType][]string{
"https://api.orders.example.com": {
SubjectTypeUser: {"read:orders"},
},
}}
_, _, err := resolveResource(t.Context(), nil, provider, "client-1", "https://api.orders.example.com", nil, SubjectTypeClient)
require.Error(t, err)
}
func TestConsentScopeKeyQualifiesCustomScopesOnly(t *testing.T) {
// Identity scopes stay bare for backward compatibility with existing consents.
assert.Equal(t, "profile", consentScopeKey("https://api.orders.example.com", "profile"))
// Custom scopes are qualified by audience so the same key on two APIs is distinct.
assert.Equal(t, "https://api.orders.example.com\x1fread", consentScopeKey("https://api.orders.example.com", "read"))
assert.NotEqual(t,
consentScopeKey("https://api.orders.example.com", "read"),
consentScopeKey("https://api.billing.example.com", "read"),
)
}

View File

@@ -0,0 +1,193 @@
package oidc
import (
"context"
"errors"
"log/slog"
"net/http"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/ory/fosite"
"github.com/pocket-id/pocket-id/backend/internal/common"
"github.com/pocket-id/pocket-id/backend/internal/utils"
"github.com/pocket-id/pocket-id/backend/internal/utils/cookie"
)
const parRequestURIPrefix = "urn:ietf:params:oauth:request_uri:"
type authorizationHandler struct {
provider fosite.OAuth2Provider
authorizationService *authorizationService
baseURL string
}
func newAuthorizationHandler(
provider fosite.OAuth2Provider,
authorizationService *authorizationService,
baseURL string,
) *authorizationHandler {
return &authorizationHandler{
provider: provider,
authorizationService: authorizationService,
baseURL: baseURL,
}
}
func (h *authorizationHandler) authorize(c *gin.Context) {
ctx := c.Request.Context()
userID := c.GetString("userID")
authenticationMethod := c.GetString("authenticationMethod")
authenticationTime, _ := c.Get("authenticationTime")
typedAuthenticationTime, _ := authenticationTime.(time.Time)
reauthenticationToken, _ := c.Cookie(cookie.ReauthenticationTokenCookieName)
// A request that resumes an interaction only carries the interaction ID; the original
// parameters are restored from the stored session so they never travel through the
// front channel.
interactionID := c.Query("interaction")
if interactionID != "" {
query, err := h.authorizationService.interactionRequestQuery(ctx, interactionID)
if err != nil {
slog.WarnContext(ctx, "Failed to restore authorize request from interaction session", "error", err.Error())
h.writeAuthorizeError(ctx, c, fosite.NewAuthorizeRequest(), err)
return
}
c.Request.URL.RawQuery = query.Encode()
}
// Treat the request as a pushed authorization request only when the request_uri carries the
// PAR prefix. Without this, a client required to use PAR could bypass that requirement by
// sending an arbitrary (non-prefixed) request_uri, which fosite silently ignores.
hasPushedAuthorizationRequest := strings.HasPrefix(c.Query("request_uri"), parRequestURIPrefix)
ar, err := h.provider.NewAuthorizeRequest(ctx, c.Request)
if err != nil {
slog.ErrorContext(ctx, "Failed to create authorize request", "error", err.Error())
h.writeAuthorizeError(ctx, c, ar, err)
return
}
authorization, err := h.authorizationService.authorize(ctx, authorizeInput{
userID: userID,
authenticationMethod: authenticationMethod,
authenticationTime: typedAuthenticationTime,
requester: ar,
hasPushedAuthorizationRequest: hasPushedAuthorizationRequest,
reauthenticationToken: reauthenticationToken,
interactionID: interactionID,
requestParams: authorizeRequestParams(ar),
meta: requestMetaFromGin(c),
})
if err != nil {
slog.ErrorContext(ctx, "Failed to authorize request", "error", err.Error())
h.writeAuthorizeError(ctx, c, ar, err)
return
}
if authorization.RequiresInteraction {
c.Redirect(http.StatusFound, "/interaction?interaction="+authorization.InteractionID)
return
}
response, err := h.provider.NewAuthorizeResponse(ctx, ar, authorization.Session)
if err != nil {
slog.ErrorContext(ctx, "Failed to create authorize response", "error", err.Error())
h.writeAuthorizeError(ctx, c, ar, err)
return
}
response.AddParameter("iss", h.baseURL)
// fosite renders an auto-submitting HTML page for response_mode=form_post, which needs a relaxed CSP
h.relaxCSPForFormPost(c, ar)
h.provider.WriteAuthorizeResponse(ctx, c.Writer, ar, response)
}
func (h *authorizationHandler) getInteractionSession(c *gin.Context) {
interactionID := c.Param("id")
interactionSession, err := h.authorizationService.getInteractionSession(c.Request.Context(), interactionID)
if err != nil {
_ = c.Error(err)
return
}
c.JSON(http.StatusOK, interactionSession)
}
func (h *authorizationHandler) completeInteraction(c *gin.Context) {
interactionID := c.Param("id")
authenticationTime, _ := c.Get("authenticationTime")
typedAuthenticationTime, _ := authenticationTime.(time.Time)
var request completeInteractionRequest
if err := c.ShouldBindJSON(&request); err != nil {
_ = c.Error(&common.ValidationError{Message: "invalid interaction request"})
return
}
reauthenticationToken, _ := c.Cookie(cookie.ReauthenticationTokenCookieName)
response, err := h.authorizationService.completeInteractionStep(c.Request.Context(), interactionID, c.GetString("userID"), request.Step, reauthenticationToken, typedAuthenticationTime, requestMetaFromGin(c))
if err != nil {
_ = c.Error(err)
return
}
c.JSON(http.StatusOK, response)
}
func (h *authorizationHandler) writeAuthorizeError(ctx context.Context, c *gin.Context, ar fosite.AuthorizeRequester, err error) {
if ar.IsRedirectURIValid() {
// Send the error to the client
// fosite delivers the error through response_mode=form_post as well, so it needs the same CSP relaxation as the success path
h.relaxCSPForFormPost(c, ar)
h.provider.WriteAuthorizeError(ctx, c.Writer, ar, err)
return
}
// If no redirect URI is available, we can't send the error to the client,
// so we redirect to a generic error page instead.
errorMessage := "An unknown error occurred during the authorization request."
if err, ok := errors.AsType[*fosite.RFC6749Error](err); ok {
if err.HintField != "" {
errorMessage = err.HintField
} else if err.DescriptionField != "" {
errorMessage = err.DescriptionField
}
}
c.Redirect(http.StatusFound, "/interaction/error?error="+errorMessage)
}
func requestMetaFromGin(c *gin.Context) requestMeta {
return requestMeta{
IPAddress: c.ClientIP(),
UserAgent: c.Request.UserAgent(),
}
}
func authorizeRequestParams(requester fosite.AuthorizeRequester) map[string]string {
params := make(map[string]string)
for key, values := range requester.GetRequestForm() {
// The raw "request" object is dropped alongside "request_uri": its claims are already merged
// into the form, and replaying the JWT on interaction resume would re-validate its "exp"
// against the resume time, failing logins that took longer than the object's lifetime.
if len(values) == 0 || key == "request" || key == "request_uri" || key == "interaction" {
continue
}
params[key] = values[0]
}
return params
}
// relaxCSPForFormPost loosens the per-request Content-Security-Policy when the response is delivered via response_mode=form_post
func (h *authorizationHandler) relaxCSPForFormPost(c *gin.Context, ar fosite.AuthorizeRequester) {
if ar.GetResponseMode() != fosite.ResponseModeFormPost || ar.GetRedirectURI() == nil {
return
}
c.Header("Content-Security-Policy", utils.BuildFormPostCSP(utils.GetCSPNonce(c), ar.GetRedirectURI().String(), formPostScriptCSPHash))
}

View File

@@ -0,0 +1,883 @@
package oidc
import (
"context"
"errors"
"log/slog"
"maps"
"net/url"
"slices"
"strconv"
"strings"
"time"
"github.com/ory/fosite"
"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/utils"
"gorm.io/gorm"
)
func newAuthorizationService(db *gorm.DB, interactionSessionService *interactionSessionService, claimsService *ClaimsService, reauth ReauthenticationTokenConsumer, auditLog AuditLogger, apiAccess APIAccessProvider) *authorizationService {
return &authorizationService{
db: db,
interactionSessionService: interactionSessionService,
claimsService: claimsService,
reauth: reauth,
auditLog: auditLog,
apiAccess: apiAccess,
}
}
type authorizationService struct {
db *gorm.DB
interactionSessionService *interactionSessionService
claimsService *ClaimsService
reauth ReauthenticationTokenConsumer
auditLog AuditLogger
apiAccess APIAccessProvider
}
// resolveGrant resolves the RFC 8707 resource of a request into the token audience, the scopes that may actually be granted, and the audience-qualified keys used to record and check consent
// It always resolves against the client's user-delegated grants because every flow that passes through here acts on behalf of a user
func (s *authorizationService) resolveGrant(ctx context.Context, clientID, resource string, requestedScopes []string) (audience string, grantedScopes []string, consentKeys []string, err error) {
audience, grantedScopes, err = resolveResource(ctx, dbFromContext(ctx, s.db), s.apiAccess, clientID, resource, requestedScopes, SubjectTypeUser)
if err != nil {
return "", nil, nil, err
}
return audience, grantedScopes, consentScopeKeys(audience, grantedScopes), nil
}
type requestMeta struct {
IPAddress string
UserAgent string
}
type authorizationResult struct {
RequiresInteraction bool
InteractionID string
Session *Session
}
type promptValues []string
func newPromptValues(prompt string) promptValues {
return strings.Fields(prompt)
}
func (p promptValues) has(value string) bool {
return slices.Contains(p, value)
}
// consentRequired reports whether the user has to be shown the consent screen
// A client can be configured to skip consent so trusted first-party apps are not prompted on every new authorization, but an explicit prompt=consent always forces the screen regardless of that setting
func consentRequired(hasAlreadyAuthorizedClient, clientSkipsConsent bool, prompt promptValues) bool {
if prompt.has("consent") {
return true
}
return !hasAlreadyAuthorizedClient && !clientSkipsConsent
}
// authorizeInput is the authorization request as provided by the handler.
type authorizeInput struct {
userID string
authenticationMethod string
authenticationTime time.Time
requester fosite.AuthorizeRequester
hasPushedAuthorizationRequest bool
reauthenticationToken string
interactionID string
requestParams map[string]string
meta requestMeta
}
// authorizeRequest is the input enriched with everything the service derives from it.
type authorizeRequest struct {
authorizeInput
client Client
prompt promptValues
interactionSession *InteractionSession
now time.Time
}
func (s *authorizationService) authorize(ctx context.Context, input authorizeInput) (authorizationResult, error) {
client := input.requester.GetClient().(Client)
prompt := newPromptValues(input.requester.GetRequestForm().Get("prompt"))
err := validateClientPKCERequirement(client, input.requester)
if err != nil {
return authorizationResult{}, err
}
interactionSession, err := s.boundInteractionSession(ctx, input.interactionID, input.userID, client, input.requester)
if err != nil {
return authorizationResult{}, err
}
// Reject authorization requests that require PAR when the request is not a resumed interaction and doesn't have a valid PAR
if client.RequiresPushedAuthorizationRequests && !input.hasPushedAuthorizationRequest && interactionSession == nil {
return authorizationResult{}, &common.OidcPARRequiredError{}
}
resource, err := input.requester.GetResource()
if err != nil {
return authorizationResult{}, err
}
// Validate the requested scopes against the targeted API up front, before the user authenticates or reaches the consent screen
// This rejects a custom permission requested without, or with the wrong, resource at the authorize endpoint itself
_, _, _, err = s.resolveGrant(ctx, client.GetID(), resource, input.requester.GetRequestedScopes())
if err != nil {
// resolveGrant distinguishes an unknown API, an API this client is not granted, and a scope not allowed for the API
// This validation runs before authentication, so returning those distinct errors would let anyone holding a public client_id diff the responses to enumerate which API audiences exist and which ones the client may request
// Collapse every resource-targeted failure into one generic invalid_request so the pre-auth response reveals no backend state, while keeping the underlying reason in the server log for operators
// A request that names no resource cannot leak API topology, so its scope error is returned unchanged to help legitimate integrations
if resource != "" {
slog.DebugContext(ctx, "Rejected authorize request with an invalid or unauthorized resource or scope", "client_id", client.GetID(), "error", err.Error())
return authorizationResult{}, fosite.ErrInvalidRequest.WithHint("The 'resource' or 'scope' parameter is invalid.")
}
return authorizationResult{}, err
}
if input.userID == "" {
if prompt.has("none") {
return authorizationResult{}, fosite.ErrLoginRequired
}
interactionSession, err := s.createInteractionSession(ctx, input.requester, input.requestParams, "", interactionRequirements{
AuthenticationRequired: true,
ReauthenticationRequired: prompt.has("login") || client.RequiresReauthentication,
AccountSelectionRequired: prompt.has("select_account"),
ConsentRequired: prompt.has("consent"),
})
if err != nil {
return authorizationResult{}, err
}
return authorizationResult{RequiresInteraction: true, InteractionID: interactionSession.ID}, nil
}
req := authorizeRequest{
authorizeInput: input,
client: client,
prompt: prompt,
interactionSession: interactionSession,
now: time.Now().UTC(),
}
codeChallenge := input.requester.GetRequestForm().Get("code_challenge")
var result authorizationResult
err = withTx(ctx, s.db, func(ctx context.Context) error {
var txErr error
result, txErr = s.authorizeAuthenticated(ctx, req)
if txErr != nil {
return txErr
}
if codeChallenge != "" && !client.PkceEnabled && !client.PkceSupported {
tx := dbFromContext(ctx, s.db)
_ = flagPkceSupportedClient(ctx, client.GetID(), tx)
}
return nil
})
if err != nil {
return authorizationResult{}, err
}
if result.Session == nil {
return result, nil
}
err = s.claimsService.applyIDTokenClaims(ctx, result.Session, input.requester.GetGrantedScopes())
if err != nil {
return authorizationResult{}, err
}
return result, nil
}
// authorizeAuthenticated either reports the interaction the user still has to complete or grants the request.
func (s *authorizationService) authorizeAuthenticated(ctx context.Context, req authorizeRequest) (authorizationResult, error) {
var user model.User
err := dbFromContext(ctx, s.db).
Preload("UserGroups").
First(&user, "id = ?", req.userID).
Error
if err != nil {
return authorizationResult{}, err
}
if !IsUserGroupAllowedToAuthorize(user, req.client.OidcClient) {
return authorizationResult{}, fosite.ErrAccessDenied.WithHint("You are not allowed to access this service.")
}
interactionSession := req.interactionSession
if interactionSession != nil && interactionSession.UserID != nil && *interactionSession.UserID != req.userID {
if err := s.switchInteractionSessionUser(ctx, interactionSession, req.userID, req.authenticationTime); err != nil {
return authorizationResult{}, err
}
if err := s.interactionSessionService.update(ctx, *interactionSession); err != nil {
return authorizationResult{}, err
}
}
requirements, authenticationTime, err := s.resolveRequirements(ctx, req, interactionSession)
if err != nil {
return authorizationResult{}, err
}
if requirements.any() {
if interactionSession != nil {
return authorizationResult{RequiresInteraction: true, InteractionID: interactionSession.ID}, nil
}
created, err := s.createInteractionSession(ctx, req.requester, req.requestParams, req.userID, requirements)
if err != nil {
return authorizationResult{}, err
}
return authorizationResult{RequiresInteraction: true, InteractionID: created.ID}, nil
}
if interactionSession != nil {
err := s.interactionSessionService.delete(ctx, interactionSession.ID)
if errors.Is(err, gorm.ErrRecordNotFound) {
return authorizationResult{}, fosite.ErrInvalidRequest.WithHint("The interaction session has already been used.")
}
if err != nil {
return authorizationResult{}, err
}
}
resource, err := req.requester.GetResource()
if err != nil {
return authorizationResult{}, err
}
audience, grantedScopes, consentKeys, err := s.resolveGrant(ctx, req.client.GetID(), resource, req.requester.GetRequestedScopes())
if err != nil {
return authorizationResult{}, err
}
hasAlreadyAuthorizedClient, err := s.consent(ctx, req.userID, req.client.GetID(), consentKeys)
if err != nil {
return authorizationResult{}, err
}
session := s.buildAuthorizedSession(req, interactionSession, authenticationTime)
grantResourceIndicator(req.requester, audience, grantedScopes)
authorizationEvent := model.AuditLogEventClientAuthorization
if !hasAlreadyAuthorizedClient {
authorizationEvent = model.AuditLogEventNewClientAuthorization
}
if s.auditLog != nil {
s.auditLog.Create(ctx, authorizationEvent, req.meta.IPAddress, req.meta.UserAgent, req.userID, model.AuditLogData{"clientName": req.client.Name}, dbFromContext(ctx, s.db))
}
return authorizationResult{Session: session}, nil
}
func flagPkceSupportedClient(ctx context.Context, clientID string, tx *gorm.DB) error {
err := tx.
WithContext(ctx).
Model(&model.OidcClient{}).
Where("id = ?", clientID).
Update("pkce_supported", true).
Error
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return err
}
return nil
}
// resolveRequirements determines the interaction steps still required; the requirements
// of a resumed interaction session win over the ones derived from the request.
func (s *authorizationService) resolveRequirements(ctx context.Context, req authorizeRequest, interactionSession *InteractionSession) (interactionRequirements, time.Time, error) {
authenticationTime := req.authenticationTime
resource, err := req.requester.GetResource()
if err != nil {
return interactionRequirements{}, authenticationTime, err
}
_, _, consentKeys, err := s.resolveGrant(ctx, req.client.GetID(), resource, req.requester.GetRequestedScopes())
if err != nil {
return interactionRequirements{}, authenticationTime, err
}
hasAlreadyAuthorizedClient, err := s.hasAuthorizedClient(ctx, req.client.GetID(), req.userID, consentKeys)
if err != nil {
return interactionRequirements{}, authenticationTime, err
}
maxAgeReauthenticationRequired, err := requiresReauthenticationForMaxAge(req.requester.GetRequestForm().Get("max_age"), authenticationTime, req.now)
if err != nil {
return interactionRequirements{}, authenticationTime, err
}
requirements := interactionRequirements{
ConsentRequired: consentRequired(hasAlreadyAuthorizedClient, req.client.SkipConsent, req.prompt),
ReauthenticationRequired: req.prompt.has("login") || req.client.RequiresReauthentication || maxAgeReauthenticationRequired,
AccountSelectionRequired: req.prompt.has("select_account"),
AuthenticationRequired: false,
}
if interactionSession != nil {
requirements = interactionRequirements{
ConsentRequired: interactionSession.ConsentRequired,
ReauthenticationRequired: interactionSession.ReauthenticationRequired,
AccountSelectionRequired: interactionSession.AccountSelectionRequired,
AuthenticationRequired: interactionSession.AuthenticationRequired,
}
if interactionSession.ReauthenticatedAt != nil {
authenticationTime = interactionSession.ReauthenticatedAt.UTC()
}
}
if req.prompt.has("none") && requirements.ConsentRequired {
return interactionRequirements{}, authenticationTime, fosite.ErrConsentRequired
}
if req.prompt.has("none") && requirements.ReauthenticationRequired {
return interactionRequirements{}, authenticationTime, fosite.ErrLoginRequired
}
if requirements.ReauthenticationRequired && req.reauthenticationToken != "" && s.reauth != nil {
reauthenticatedAt, err := s.reauth.ConsumeReauthenticationToken(ctx, dbFromContext(ctx, s.db), req.reauthenticationToken, req.userID)
if err == nil {
requirements.ReauthenticationRequired = false
authenticationTime = reauthenticatedAt
}
}
return requirements, authenticationTime, nil
}
func (s *authorizationService) buildAuthorizedSession(req authorizeRequest, interactionSession *InteractionSession, authenticationTime time.Time) *Session {
if authenticationTime.IsZero() {
authenticationTime = req.now
}
requestedAt := req.requester.GetRequestedAt()
if interactionSession != nil && !interactionSession.RequestedAt.ToTime().IsZero() {
requestedAt = interactionSession.RequestedAt.UTC()
}
if requestedAt.IsZero() {
requestedAt = req.now
}
return NewAuthenticatedSession(req.userID, req.authenticationMethod, authenticationTime, requestedAt)
}
// interactionRequestQuery returns the authorize parameters stored for the interaction
// session, so the handler can rebuild the re-entry request server-side.
func (s *authorizationService) interactionRequestQuery(ctx context.Context, interactionID string) (query url.Values, err error) {
interactionSession, err := s.interactionSessionService.get(ctx, interactionID)
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, fosite.ErrInvalidRequest.WithHint("The interaction session is invalid or has expired.")
}
if err != nil {
return nil, err
}
query = url.Values{}
for key, value := range interactionSession.Parameters {
if value != "" {
query.Set(key, value)
}
}
query.Set("interaction", interactionSession.ID)
return query, nil
}
// boundInteractionSession loads the referenced interaction session and validates the request against it.
func (s *authorizationService) boundInteractionSession(ctx context.Context, interactionID string, userID string, client Client, requester fosite.AuthorizeRequester) (*InteractionSession, error) {
if interactionID == "" {
return nil, nil
}
session, err := s.interactionSessionService.get(ctx, interactionID)
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, fosite.ErrInvalidRequest.WithHint("The interaction session is invalid or has expired.")
}
if err != nil {
return nil, err
}
if err := validateInteractionSessionBinding(session, userID, client, requester); err != nil {
return nil, err
}
return &session, nil
}
// validateInteractionSessionBinding ensures a request matches the interaction session
// it resumes: the requirement flags and the PAR exemption come from the session, so a
// tampered request (extra scopes, another client's interaction) must not inherit them.
func validateInteractionSessionBinding(interactionSession InteractionSession, userID string, client Client, requester fosite.AuthorizeRequester) error {
if interactionSession.ClientID != client.GetID() {
return fosite.ErrInvalidRequest.WithHint("The interaction session does not belong to this client.")
}
if interactionSession.UserID != nil {
if userID == "" {
return fosite.ErrLoginRequired
}
}
for _, scope := range requester.GetRequestedScopes() {
if !slices.Contains(interactionSession.Scopes, scope) {
return fosite.ErrInvalidRequest.WithHint("The requested scopes exceed the scopes of the interaction session.")
}
}
return nil
}
type interactionRequirements struct {
ConsentRequired bool
ReauthenticationRequired bool
AuthenticationRequired bool
AccountSelectionRequired bool
}
func (r interactionRequirements) any() bool {
return r.ConsentRequired || r.ReauthenticationRequired || r.AuthenticationRequired || r.AccountSelectionRequired
}
func (s *authorizationService) createInteractionSession(ctx context.Context, requester fosite.AuthorizeRequester, requestParams map[string]string, userID string, requirements interactionRequirements) (InteractionSession, error) {
parameters := make(map[string]string, len(requestParams))
maps.Copy(parameters, requestParams)
scopes := requester.GetRequestedScopes()
if scopes == nil {
scopes = []string{}
}
return s.interactionSessionService.create(ctx, InteractionSession{
Base: model.Base{
ID: requester.GetID(),
},
Scopes: datatype.StringList(scopes),
ClientID: requester.GetClient().GetID(),
UserID: utils.PtrOrNil(userID),
ConsentRequired: requirements.ConsentRequired,
ReauthenticationRequired: requirements.ReauthenticationRequired,
AuthenticationRequired: requirements.AuthenticationRequired,
AccountSelectionRequired: requirements.AccountSelectionRequired,
RequestedAt: datatype.DateTime(requester.GetRequestedAt()),
Parameters: parameters,
})
}
func bindInteractionSessionUser(interactionSession *InteractionSession, userID string) error {
if userID == "" {
return fosite.ErrLoginRequired
}
if interactionSession.UserID != nil && *interactionSession.UserID != userID {
return fosite.ErrInvalidRequest.WithHint("The interaction session belongs to another user.")
}
if interactionSession.UserID == nil {
interactionSession.UserID = &userID
}
return nil
}
func (s *authorizationService) switchInteractionSessionUser(ctx context.Context, interactionSession *InteractionSession, userID string, authenticationTime time.Time) error {
if userID == "" {
return fosite.ErrLoginRequired
}
interactionSession.UserID = &userID
requirements, err := s.interactionRequirementsForUser(ctx, userID, interactionSession, authenticationTime)
if err != nil {
return err
}
interactionSession.AuthenticationRequired = false
interactionSession.ConsentRequired = requirements.ConsentRequired
interactionSession.ReauthenticationRequired = requirements.ReauthenticationRequired
interactionSession.ReauthenticatedAt = nil
return nil
}
func (s *authorizationService) getInteractionSession(ctx context.Context, interactionSessionID string) (interactionSessionForUser, error) {
interactionSession, err := s.interactionSessionService.get(ctx, interactionSessionID)
if err != nil {
return interactionSessionForUser{}, err
}
return s.buildInteractionForUser(ctx, interactionSession)
}
// buildInteractionForUser builds the consent-screen DTO and enriches it with display information for the requested custom-API permissions
func (s *authorizationService) buildInteractionForUser(ctx context.Context, interactionSession InteractionSession) (interactionSessionForUser, error) {
result, err := newInteractionSessionForUser(interactionSession)
if err != nil {
return interactionSessionForUser{}, err
}
scopeInfo, err := s.resolveScopeInfo(ctx, interactionSession)
if err != nil {
return interactionSessionForUser{}, err
}
// Always serialize a possibly empty array rather than null
if scopeInfo == nil {
scopeInfo = []dto.ScopeInfoDto{}
}
result.ScopeInfo = scopeInfo
return result, nil
}
// resolveScopeInfo resolves display names and descriptions for the requested non-standard scopes, looked up against the API targeted by the request's RFC 8707 resource
// Standard identity scopes are rendered by the client
func (s *authorizationService) resolveScopeInfo(ctx context.Context, interactionSession InteractionSession) ([]dto.ScopeInfoDto, error) {
return s.resolveScopeInfoForRequest(ctx, interactionSession.Parameters["resource"], interactionSession.Scopes)
}
// resolveScopeInfoForRequest resolves display names and descriptions for the requested non-standard scopes against the API identified by resource
// The browser and device consent flows share it so both show friendly permission names instead of raw scope keys
func (s *authorizationService) resolveScopeInfoForRequest(ctx context.Context, resource string, scopes []string) ([]dto.ScopeInfoDto, error) {
if s.apiAccess == nil {
return nil, nil
}
if resource == "" {
return nil, nil
}
customKeys := make([]string, 0, len(scopes))
for _, scope := range scopes {
if !isStandardScope(scope) {
customKeys = append(customKeys, scope)
}
}
if len(customKeys) == 0 {
return nil, nil
}
infos, err := s.apiAccess.DescribePermissions(ctx, resource, customKeys)
if err != nil {
return nil, err
}
return infos, nil
}
func (s *authorizationService) completeInteractionStep(ctx context.Context, interactionSessionID, userID string, step interactionStep, reauthenticationToken string, authenticationTime time.Time, meta requestMeta) (completeInteractionResponse, error) {
var interactionSession InteractionSession
var response completeInteractionResponse
err := withTx(ctx, s.db, func(ctx context.Context) error {
var err error
interactionSession, err = s.interactionSessionService.get(ctx, interactionSessionID)
if err != nil {
return err
}
if interactionSession.UserID != nil && *interactionSession.UserID != userID {
if err := s.switchInteractionSessionUser(ctx, &interactionSession, userID, authenticationTime); err != nil {
return err
}
}
if userID == "" {
return fosite.ErrLoginRequired
}
requiredSteps := requiredInteractionSteps(interactionSession)
if len(requiredSteps) == 0 {
response = completeInteractionResponse{RedirectURL: authorizeRedirectURL(interactionSession.ID)}
return nil
}
if requiredSteps[0] != step {
return &common.ValidationError{Message: "expected interaction step " + string(requiredSteps[0]) + " but got " + string(step)}
}
if err := s.applyInteractionStep(ctx, &interactionSession, userID, step, reauthenticationToken, authenticationTime, meta); err != nil {
return err
}
return s.interactionSessionService.update(ctx, interactionSession)
})
if err != nil {
return completeInteractionResponse{}, err
}
if response.RedirectURL != "" {
return response, nil
}
if !hasRemainingInteractionSteps(interactionSession) {
return completeInteractionResponse{RedirectURL: authorizeRedirectURL(interactionSession.ID)}, nil
}
interaction, err := s.buildInteractionForUser(ctx, interactionSession)
if err != nil {
return completeInteractionResponse{}, err
}
return completeInteractionResponse{Interaction: &interaction}, nil
}
func (s *authorizationService) applyInteractionStep(ctx context.Context, interactionSession *InteractionSession, userID string, step interactionStep, reauthenticationToken string, authenticationTime time.Time, meta requestMeta) error {
switch step {
case interactionStepAuthenticate:
if err := bindInteractionSessionUser(interactionSession, userID); err != nil {
return err
}
interactionSession.AuthenticationRequired = false
return s.populatePostAuthenticationRequirements(ctx, userID, interactionSession, authenticationTime)
case interactionStepSelectAccount:
if err := s.switchInteractionSessionUser(ctx, interactionSession, userID, authenticationTime); err != nil {
return err
}
interactionSession.AccountSelectionRequired = false
return nil
case interactionStepReauthenticate:
return s.completeReauthenticationStep(ctx, interactionSession, userID, reauthenticationToken)
case interactionStepConsent:
return s.completeConsentStep(ctx, interactionSession, userID, meta)
default:
return &common.ValidationError{Message: "unknown interaction step " + string(step)}
}
}
func (s *authorizationService) completeReauthenticationStep(ctx context.Context, interactionSession *InteractionSession, userID, reauthenticationToken string) error {
if err := bindInteractionSessionUser(interactionSession, userID); err != nil {
return err
}
if reauthenticationToken == "" {
return &common.ValidationError{Message: "reauthentication token is required"}
}
reauthenticatedAt, err := s.reauth.ConsumeReauthenticationToken(ctx, dbFromContext(ctx, s.db), reauthenticationToken, userID)
if err != nil {
return err
}
interactionSession.ReauthenticationRequired = false
interactionSession.ReauthenticatedAt = new(datatype.DateTime(reauthenticatedAt))
return nil
}
func (s *authorizationService) completeConsentStep(ctx context.Context, interactionSession *InteractionSession, userID string, meta requestMeta) error {
if err := bindInteractionSessionUser(interactionSession, userID); err != nil {
return err
}
resource := interactionSession.Parameters["resource"]
_, _, consentKeys, err := s.resolveGrant(ctx, interactionSession.ClientID, resource, interactionSession.Scopes)
if err != nil {
return err
}
hasAlreadyAuthorizedClient, err := s.consent(ctx, userID, interactionSession.ClientID, consentKeys)
if err != nil {
return err
}
if !hasAlreadyAuthorizedClient && s.auditLog != nil {
s.auditLog.Create(ctx, model.AuditLogEventNewClientAuthorization, meta.IPAddress, meta.UserAgent, userID, model.AuditLogData{"clientName": interactionSession.Client.Name}, dbFromContext(ctx, s.db))
}
interactionSession.ConsentRequired = false
return nil
}
func (s *authorizationService) populatePostAuthenticationRequirements(ctx context.Context, userID string, interactionSession *InteractionSession, authenticationTime time.Time) error {
if userID == "" {
return errors.New("user is required to complete authentication")
}
requirements, err := s.interactionRequirementsForUser(ctx, userID, interactionSession, authenticationTime)
if err != nil {
return err
}
interactionSession.ConsentRequired = interactionSession.ConsentRequired || requirements.ConsentRequired
interactionSession.AccountSelectionRequired = interactionSession.AccountSelectionRequired || requirements.AccountSelectionRequired
interactionSession.ReauthenticationRequired = interactionSession.ReauthenticationRequired || requirements.ReauthenticationRequired
return nil
}
func (s *authorizationService) interactionRequirementsForUser(ctx context.Context, userID string, interactionSession *InteractionSession, authenticationTime time.Time) (interactionRequirements, error) {
prompt := newPromptValues(interactionSession.Parameters["prompt"])
resource := interactionSession.Parameters["resource"]
_, _, consentKeys, err := s.resolveGrant(ctx, interactionSession.ClientID, resource, interactionSession.Scopes)
if err != nil {
return interactionRequirements{}, err
}
hasAlreadyAuthorizedClient, err := s.hasAuthorizedClient(ctx, interactionSession.ClientID, userID, consentKeys)
if err != nil {
return interactionRequirements{}, err
}
maxAgeReauthenticationRequired, err := requiresReauthenticationForMaxAge(interactionSession.Parameters["max_age"], authenticationTime, time.Now().UTC())
if err != nil {
return interactionRequirements{}, err
}
return interactionRequirements{
ConsentRequired: consentRequired(hasAlreadyAuthorizedClient, interactionSession.Client.SkipConsent, prompt),
ReauthenticationRequired: prompt.has("login") || interactionSession.Client.RequiresReauthentication || maxAgeReauthenticationRequired,
AccountSelectionRequired: prompt.has("select_account"),
AuthenticationRequired: false,
}, nil
}
// authorizeRedirectURL only references the interaction session; the authorize endpoint
// restores the request parameters from it server-side.
func authorizeRedirectURL(interactionSessionID string) string {
return "/authorize?interaction=" + url.QueryEscape(interactionSessionID)
}
// validateClientPKCERequirement enforces the per-client PkceEnabled flag. fosite only
// enforces PKCE for public clients (EnforcePKCEForPublicClients).
func validateClientPKCERequirement(client Client, requester fosite.AuthorizeRequester) error {
if !client.PkceEnabled {
return nil
}
if requester.GetRequestForm().Get("code_challenge") == "" {
return fosite.ErrInvalidRequest.WithHint("This client requires PKCE, but the 'code_challenge' parameter is missing.")
}
return nil
}
func requiresReauthenticationForMaxAge(maxAgeRaw string, authenticationTime time.Time, now time.Time) (bool, error) {
if maxAgeRaw == "" {
return false, nil
}
maxAge, err := strconv.ParseInt(maxAgeRaw, 10, 64)
if err != nil || maxAge < 0 {
return false, fosite.ErrInvalidRequest.WithHint("Parameter 'max_age' must be a non-negative integer.")
}
if authenticationTime.IsZero() {
return true, nil
}
return !now.Before(authenticationTime.UTC().Add(time.Duration(maxAge) * time.Second)), nil
}
func (s *authorizationService) consent(ctx context.Context, userID string, clientID string, scope []string) (hasAlreadyAuthorizedClient bool, err error) {
db := dbFromContext(ctx, s.db)
now := datatype.DateTime(time.Now())
var existing model.UserAuthorizedOidcClient
err = db.
First(&existing, "client_id = ? AND user_id = ?", clientID, userID).
Error
if err == nil {
// If the user has already authorized the client with the same or a superset of the requested scopes, we just update the last_used_at timestamp and return
hasAlreadyAuthorizedClient = consentScopesInclude(existing.Scope, scope)
if hasAlreadyAuthorizedClient {
err = db.
Model(&model.UserAuthorizedOidcClient{}).
Where("user_id = ? AND client_id = ?", userID, clientID).
Update("last_used_at", now).
Error
return hasAlreadyAuthorizedClient, err
}
// If the user has already authorized the client but with a different set of scopes, we merge the scopes and update the record
err = db.
Model(&model.UserAuthorizedOidcClient{}).
Where("user_id = ? AND client_id = ?", userID, clientID).
Updates(map[string]any{
"scope": mergeConsentScopes(existing.Scope, scope),
"last_used_at": now,
}).
Error
return hasAlreadyAuthorizedClient, err
}
if !errors.Is(err, gorm.ErrRecordNotFound) {
return false, err
}
// The user has not authorized the client yet, so we create a new record
err = db.Create(&model.UserAuthorizedOidcClient{
UserID: userID,
ClientID: clientID,
Scope: mergeConsentScopes(nil, scope),
LastUsedAt: now,
}).Error
if errors.Is(err, gorm.ErrDuplicatedKey) {
return s.consent(ctx, userID, clientID, scope)
}
return false, err
}
func (s *authorizationService) hasAuthorizedClient(ctx context.Context, clientID, userID string, scope []string) (bool, error) {
var userAuthorizedOidcClient model.UserAuthorizedOidcClient
err := dbFromContext(ctx, s.db).
First(&userAuthorizedOidcClient, "client_id = ? AND user_id = ?", clientID, userID).
Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return false, nil
}
return false, err
}
return consentScopesInclude(userAuthorizedOidcClient.Scope, scope), nil
}
func consentScopesInclude(authorizedScopes []string, requestedScopes []string) bool {
for _, requestedScope := range requestedScopes {
if !slices.Contains(authorizedScopes, requestedScope) {
return false
}
}
return true
}
// mergeConsentScopes merges the authorized scopes with the requested scopes, ensuring that there are no duplicates.
// Merging is necessary because the user may authorize scopes for multiple resources.
// If we would just replace the authorized scopes with the requested scopes, we would lose the previously authorized scopes for other resources.
func mergeConsentScopes(authorizedScopes []string, requestedScopes []string) datatype.StringList {
merged := make(datatype.StringList, 0, len(authorizedScopes)+len(requestedScopes))
seen := make(map[string]struct{}, len(authorizedScopes)+len(requestedScopes))
for _, scope := range authorizedScopes {
if _, ok := seen[scope]; ok {
continue
}
seen[scope] = struct{}{}
merged = append(merged, scope)
}
for _, scope := range requestedScopes {
if _, ok := seen[scope]; ok {
continue
}
seen[scope] = struct{}{}
merged = append(merged, scope)
}
return merged
}
// IsUserGroupAllowedToAuthorize reports whether the user may use the group-restricted client.
func IsUserGroupAllowedToAuthorize(user model.User, client model.OidcClient) bool {
if !client.IsGroupRestricted {
return true
}
for _, userGroup := range client.AllowedUserGroups {
for _, userGroupUser := range user.UserGroups {
if userGroup.ID == userGroupUser.ID {
return true
}
}
}
return false
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,166 @@
package oidc
import (
"context"
"encoding/json"
"errors"
"slices"
"github.com/ory/fosite"
"github.com/pocket-id/pocket-id/backend/internal/common"
"github.com/pocket-id/pocket-id/backend/internal/model"
"gorm.io/gorm"
)
const (
idTokenType = "id-token"
)
type ClaimsService struct {
db *gorm.DB
customClaims CustomClaimSource
baseURL string
signer TokenSigner
}
func newClaimsService(db *gorm.DB, customClaims CustomClaimSource, baseURL string, signer TokenSigner) *ClaimsService {
return &ClaimsService{
db: db,
customClaims: customClaims,
baseURL: baseURL,
signer: signer,
}
}
// ValidateUserAccess re-checks, at token-issuance time, that the user behind a grant is
// still allowed to obtain tokens for the client.
func (s *ClaimsService) ValidateUserAccess(ctx context.Context, userID string, client Client) error {
// Grants without a resource owner (e.g. client_credentials) carry an empty subject
// and have no user to validate.
if userID == "" {
return nil
}
var user model.User
err := dbFromContext(ctx, s.db).
Preload("UserGroups").
First(&user, "id = ?", userID).
Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return fosite.ErrInvalidGrant.WithHint("The user account no longer exists.")
}
if err != nil {
return err
}
if user.Disabled {
return fosite.ErrInvalidGrant.WithHint("The user account is disabled.")
}
if !IsUserGroupAllowedToAuthorize(user, client.OidcClient) {
return fosite.ErrAccessDenied.WithHint("You are not allowed to access this service.")
}
return nil
}
// applyIDTokenClaims applies the claims of a user to the ID token claims in the session based on the requested scopes.
func (s *ClaimsService) applyIDTokenClaims(ctx context.Context, session *Session, scopes fosite.Arguments) error {
userID := session.Subject
if userID == "" {
return nil
}
claims, err := s.GetUserClaims(ctx, userID, scopes)
if err != nil {
return err
}
// Record the signing algorithm on the ID token header so fosite derives the at_hash/
// c_hash digest from it (e.g. RS384 -> SHA-384, ES512 -> SHA-512). Without this the
// header is empty and fosite defaults to SHA-256, producing wrong hashes whenever the
// signing key is not a 256-bit algorithm. ToMap() strips "alg" before signing, so this
// never overrides the real JWS header. The signer is always wired in production; it is
// only nil in unit tests that do not assert hash correctness.
if s.signer != nil {
alg, err := s.signer.GetKeyAlg()
if err != nil {
return err
}
session.IDTokenHeaders().Add("alg", alg.String())
}
applyUserClaimsToIDToken(session, userID, claims)
return nil
}
func applyUserClaimsToIDToken(session *Session, userID string, claims map[string]any) {
idTokenClaims := session.IDTokenClaims()
idTokenClaims.Subject = userID
idTokenClaims.Extra = claims
idTokenClaims.Extra[common.TokenTypeClaim] = idTokenType
if session.AuthenticationMethod != "" {
idTokenClaims.AuthenticationMethodsReferences = []string{session.AuthenticationMethod}
}
}
// GetUserClaims retrieves the claims for a user based on the requested scopes. It includes standard claims
// like "sub" and "email" as well as any custom claims defined for the user or their groups.
func (s *ClaimsService) GetUserClaims(ctx context.Context, userID string, scopes []string) (map[string]any, error) {
db := dbFromContext(ctx, s.db)
var user model.User
err := db.
Preload("UserGroups").
First(&user, "id = ?", userID).
Error
if err != nil {
return nil, err
}
claims := make(map[string]any, 10)
if slices.Contains(scopes, "profile") {
customClaims, err := s.customClaims.GetCustomClaimsForUserWithUserGroups(ctx, user.ID, db)
if err != nil {
return nil, err
}
for _, customClaim := range customClaims {
// A custom claim value can be a JSON document or a plain string
var jsonValue any
if err := json.Unmarshal([]byte(customClaim.Value), &jsonValue); err == nil {
claims[customClaim.Key] = jsonValue
} else {
claims[customClaim.Key] = customClaim.Value
}
}
claims["given_name"] = user.FirstName
claims["family_name"] = user.LastName
claims["name"] = user.FullName()
claims["display_name"] = user.DisplayName
claims["preferred_username"] = user.Username
claims["picture"] = s.baseURL + "/api/users/" + user.ID + "/profile-picture.png"
}
claims["sub"] = user.ID
// Only release the email claims when the user actually has an email. Emitting
// email_verified alongside a null/absent email (OIDC Core §5.1) is malformed and can
// mislead relying parties that key trust decisions on email_verified.
if slices.Contains(scopes, "email") && user.Email != nil && *user.Email != "" {
claims["email"] = *user.Email
claims["email_verified"] = user.EmailVerified
}
if slices.Contains(scopes, "groups") {
userGroups := make([]string, len(user.UserGroups))
for i, group := range user.UserGroups {
userGroups[i] = group.Name
}
claims["groups"] = userGroups
}
return claims, nil
}

View File

@@ -0,0 +1,173 @@
package oidc
import (
"context"
"testing"
"github.com/lestrrat-go/jwx/v3/jwa"
"github.com/ory/fosite"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
"github.com/pocket-id/pocket-id/backend/internal/model"
testutils "github.com/pocket-id/pocket-id/backend/internal/utils/testing"
)
// TestClaimsServiceValidateUserAccess covers the per-grant re-validation that the token
// endpoint performs on every grant (notably refresh_token, which fosite replays without
// reloading the user). A disabled user, a user removed from a group-restricted client, or
// a deleted user must be rejected so they cannot keep minting tokens from a still-valid
// refresh token.
func TestClaimsServiceValidateUserAccess(t *testing.T) {
db := testutils.NewDatabaseForTest(t)
claimsService := newClaimsService(db, nil, "", nil)
group := model.UserGroup{Base: model.Base{ID: "group-allowed"}, Name: "allowed", FriendlyName: "Allowed"}
require.NoError(t, db.Create(&group).Error)
enabledUser := model.User{Base: model.Base{ID: "user-enabled"}, Username: "enabled"}
require.NoError(t, db.Create(&enabledUser).Error)
require.NoError(t, db.Model(&enabledUser).Association("UserGroups").Append(&group))
disabledUser := model.User{Base: model.Base{ID: "user-disabled"}, Username: "disabled", Disabled: true}
require.NoError(t, db.Create(&disabledUser).Error)
outsiderUser := model.User{Base: model.Base{ID: "user-outsider"}, Username: "outsider"}
require.NoError(t, db.Create(&outsiderUser).Error)
openClient := Client{OidcClient: model.OidcClient{Base: model.Base{ID: "client-open"}, Name: "Open"}}
restrictedClient := Client{OidcClient: model.OidcClient{
Base: model.Base{ID: "client-restricted"},
Name: "Restricted",
IsGroupRestricted: true,
AllowedUserGroups: []model.UserGroup{group},
}}
t.Run("empty subject is allowed (client_credentials)", func(t *testing.T) {
require.NoError(t, claimsService.ValidateUserAccess(t.Context(), "", openClient))
})
t.Run("enabled user is allowed", func(t *testing.T) {
require.NoError(t, claimsService.ValidateUserAccess(t.Context(), enabledUser.ID, openClient))
})
t.Run("disabled user is rejected with invalid_grant", func(t *testing.T) {
err := claimsService.ValidateUserAccess(t.Context(), disabledUser.ID, openClient)
require.ErrorIs(t, err, fosite.ErrInvalidGrant)
})
t.Run("user in an allowed group may use a group-restricted client", func(t *testing.T) {
require.NoError(t, claimsService.ValidateUserAccess(t.Context(), enabledUser.ID, restrictedClient))
})
t.Run("user outside the allowed groups is rejected with access_denied", func(t *testing.T) {
err := claimsService.ValidateUserAccess(t.Context(), outsiderUser.ID, restrictedClient)
require.ErrorIs(t, err, fosite.ErrAccessDenied)
})
t.Run("deleted user is rejected with invalid_grant", func(t *testing.T) {
err := claimsService.ValidateUserAccess(t.Context(), "does-not-exist", openClient)
require.ErrorIs(t, err, fosite.ErrInvalidGrant)
})
}
type fakeCustomClaimSource struct {
claims []model.CustomClaim
}
func (f fakeCustomClaimSource) GetCustomClaimsForUserWithUserGroups(_ context.Context, _ string, _ *gorm.DB) ([]model.CustomClaim, error) {
return f.claims, nil
}
// TestClaimsServiceGetUserClaims pins the scope-to-claims mapping that powers both the ID
// token and the userinfo endpoint: each OIDC scope must only release its own claims, "sub"
// is always present, and custom claims are emitted as parsed JSON when the stored value is
// valid JSON and as a raw string otherwise.
func TestClaimsServiceGetUserClaims(t *testing.T) {
db := testutils.NewDatabaseForTest(t)
const (
baseURL = "https://id.example.com"
userID = "user-1"
)
customClaims := fakeCustomClaimSource{claims: []model.CustomClaim{
{Key: "department", Value: "engineering"}, // plain string
{Key: "roles", Value: `["admin","dev"]`}, // JSON document
}}
service := newClaimsService(db, customClaims, baseURL, nil)
group := model.UserGroup{Base: model.Base{ID: "group-1"}, Name: "developers", FriendlyName: "Developers"}
require.NoError(t, db.Create(&group).Error)
user := model.User{
Base: model.Base{ID: userID},
Username: "tim",
FirstName: "Tim",
LastName: "Cook",
DisplayName: "Tim Cook",
Email: stringPointer("tim@example.com"),
EmailVerified: true,
}
require.NoError(t, db.Create(&user).Error)
require.NoError(t, db.Model(&user).Association("UserGroups").Append(&group))
t.Run("openid only releases sub", func(t *testing.T) {
claims, err := service.GetUserClaims(t.Context(), userID, []string{"openid"})
require.NoError(t, err)
require.Equal(t, map[string]any{"sub": userID}, claims)
})
t.Run("email scope releases email claims", func(t *testing.T) {
claims, err := service.GetUserClaims(t.Context(), userID, []string{"openid", "email"})
require.NoError(t, err)
require.Equal(t, userID, claims["sub"])
require.Equal(t, "tim@example.com", claims["email"])
require.Equal(t, true, claims["email_verified"])
require.NotContains(t, claims, "given_name")
require.NotContains(t, claims, "groups")
})
t.Run("groups scope releases group names", func(t *testing.T) {
claims, err := service.GetUserClaims(t.Context(), userID, []string{"groups"})
require.NoError(t, err)
require.Equal(t, []string{"developers"}, claims["groups"])
})
t.Run("profile scope releases profile and custom claims", func(t *testing.T) {
claims, err := service.GetUserClaims(t.Context(), userID, []string{"profile"})
require.NoError(t, err)
require.Equal(t, "Tim", claims["given_name"])
require.Equal(t, "Cook", claims["family_name"])
require.Equal(t, "Tim Cook", claims["name"])
require.Equal(t, "Tim Cook", claims["display_name"])
require.Equal(t, "tim", claims["preferred_username"])
require.Equal(t, baseURL+"/api/users/"+userID+"/profile-picture.png", claims["picture"])
// Custom claims: plain string stays a string, JSON document is decoded.
require.Equal(t, "engineering", claims["department"])
require.Equal(t, []any{"admin", "dev"}, claims["roles"])
// Profile must not leak email when the email scope was not requested.
require.NotContains(t, claims, "email")
})
}
// TestClaimsServiceAppliesSigningAlgToIDTokenHeader verifies the ID token header carries the
// signing algorithm so fosite derives the at_hash/c_hash digest from it (e.g. RS384 ->
// SHA-384, ES512 -> SHA-512) instead of always defaulting to SHA-256.
func TestClaimsServiceAppliesSigningAlgToIDTokenHeader(t *testing.T) {
db := testutils.NewDatabaseForTest(t)
require.NoError(t, db.Create(&model.User{Base: model.Base{ID: "alg-user"}, Username: "alg"}).Error)
for _, alg := range []jwa.SignatureAlgorithm{jwa.RS256(), jwa.RS384(), jwa.ES512()} {
t.Run(alg.String(), func(t *testing.T) {
service := newClaimsService(db, nil, "", algTestSigner{alg: alg})
session := NewEmptySession()
session.Subject = "alg-user"
require.NoError(t, service.applyIDTokenClaims(t.Context(), session, fosite.Arguments{"openid"}))
require.Equal(t, alg.String(), session.IDTokenHeaders().Get("alg"))
})
}
}

View File

@@ -0,0 +1,37 @@
package oidc
import (
"context"
"time"
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
"gorm.io/gorm"
)
// CleanupExpiredOAuth2Sessions deletes OAuth2 sessions whose tokens or codes have
// expired.
//
// Invalidated-but-unexpired rows are intentionally KEPT until their original expiry: fosite relies on
// finding the inactive row to detect a reuse and revoke the affected token family.
func CleanupExpiredOAuth2Sessions(ctx context.Context, db *gorm.DB) (int64, error) {
st := db.
WithContext(ctx).
Delete(&OAuth2Session{}, "expires_at < ?", datatype.DateTime(time.Now()))
return st.RowsAffected, st.Error
}
// CleanupExpiredClientAssertionJTIs deletes expired JWT IDs used for client assertion replay protection.
func CleanupExpiredClientAssertionJTIs(ctx context.Context, db *gorm.DB) (int64, error) {
st := db.
WithContext(ctx).
Delete(&clientAssertionJTI{}, "expires_at < ?", datatype.DateTime(time.Now()))
return st.RowsAffected, st.Error
}
// CleanupAbandonedInteractionSessions removes interaction sessions that were never completed.
func CleanupAbandonedInteractionSessions(ctx context.Context, db *gorm.DB) (int64, error) {
st := db.
WithContext(ctx).
Delete(&InteractionSession{}, "created_at < ?", datatype.DateTime(time.Now().Add(-interactionSessionLifetime)))
return st.RowsAffected, st.Error
}

View File

@@ -0,0 +1,39 @@
package oidc
import (
"testing"
"time"
"github.com/stretchr/testify/require"
"github.com/pocket-id/pocket-id/backend/internal/model"
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
testutils "github.com/pocket-id/pocket-id/backend/internal/utils/testing"
)
// TestCleanupExpiredOAuth2SessionsKeepsInvalidatedButUnexpiredSessions verifies that
// rotated/consumed (active=false) sessions are kept until their original expiry, so fosite
// can still detect refresh-token reuse and revoke the affected token family. Only rows
// past their expiry are removed.
func TestCleanupExpiredOAuth2SessionsKeepsInvalidatedButUnexpiredSessions(t *testing.T) {
db := testutils.NewDatabaseForTest(t)
future := datatype.DateTime(time.Now().Add(time.Hour))
rows := []OAuth2Session{
{Base: model.Base{ID: "expired"}, Kind: "access_token", Key: "k-expired", RequestID: "r1", Active: true, RequestData: "{}", ExpiresAt: new(datatype.DateTime(time.Now().Add(-time.Hour)))},
{Base: model.Base{ID: "rotated"}, Kind: "refresh_token", Key: "k-rotated", RequestID: "r2", Active: false, RequestData: "{}", ExpiresAt: &future},
{Base: model.Base{ID: "active"}, Kind: "refresh_token", Key: "k-active", RequestID: "r3", Active: true, RequestData: "{}", ExpiresAt: &future},
}
for i := range rows {
require.NoError(t, db.Create(&rows[i]).Error)
}
deleted, err := CleanupExpiredOAuth2Sessions(t.Context(), db)
require.NoError(t, err)
require.Equal(t, int64(1), deleted)
var remaining []string
require.NoError(t, db.Model(&OAuth2Session{}).Pluck("id", &remaining).Error)
require.ElementsMatch(t, []string{"active", "rotated"}, remaining)
}

View File

@@ -0,0 +1,71 @@
package oidc
import (
"github.com/ory/fosite"
"github.com/pocket-id/pocket-id/backend/internal/model"
)
type Client struct {
model.OidcClient
apiScopes []string
apiAudiences []string
}
func (c Client) GetID() string {
return c.ID
}
func (c Client) GetHashedSecret() []byte {
return []byte(c.Secret)
}
func (c Client) GetRedirectURIs() []string {
return c.CallbackURLs
}
func (c Client) GetGrantTypes() fosite.Arguments {
grantTypes := fosite.Arguments{
string(fosite.GrantTypeAuthorizationCode),
string(fosite.GrantTypeRefreshToken),
string(fosite.GrantTypeDeviceCode),
}
if !c.IsPublic() {
grantTypes = append(grantTypes, string(fosite.GrantTypeClientCredentials))
}
return grantTypes
}
func (c Client) GetResponseTypes() fosite.Arguments {
return fosite.Arguments{"code"}
}
func (c Client) GetScopes() fosite.Arguments {
scopes := make(fosite.Arguments, 5, 5+len(c.apiScopes))
scopes[0] = "openid"
scopes[1] = "profile"
scopes[2] = "email"
scopes[3] = "groups"
scopes[4] = "offline_access"
scopes = append(scopes, c.apiScopes...)
return scopes
}
func (c Client) IsPublic() bool {
return c.OidcClient.IsPublic
}
func (c Client) GetAudience() fosite.Arguments {
audience := make(fosite.Arguments, 0, len(c.apiAudiences)+1)
audience = append(audience, c.ID)
audience = append(audience, c.apiAudiences...)
return audience
}
func (c Client) GetResponseModes() []fosite.ResponseModeType {
return []fosite.ResponseModeType{
fosite.ResponseModeQuery,
fosite.ResponseModeFragment,
fosite.ResponseModeFormPost,
}
}

View File

@@ -0,0 +1,11 @@
package oidc
import (
"github.com/ory/fosite"
)
// Interface assertions
var (
_ fosite.Client = (*Client)(nil)
_ fosite.ResponseModeClient = (*Client)(nil)
)

View File

@@ -0,0 +1,87 @@
package oidc
import (
"errors"
"log/slog"
"net/http"
"time"
"github.com/gin-gonic/gin"
"github.com/ory/fosite"
"github.com/pocket-id/pocket-id/backend/internal/common"
"github.com/pocket-id/pocket-id/backend/internal/utils/cookie"
)
type deviceHandler struct {
provider fosite.OAuth2Provider
deviceService *deviceService
}
func newDeviceHandler(provider fosite.OAuth2Provider, deviceService *deviceService) *deviceHandler {
return &deviceHandler{
provider: provider,
deviceService: deviceService,
}
}
func (h *deviceHandler) authorizeDevice(c *gin.Context) {
ctx := c.Request.Context()
response, request, err := h.deviceService.createDeviceAuthorization(ctx, c.Request)
if err != nil {
slog.ErrorContext(ctx, "Failed to create device authorization", "error", err)
h.provider.WriteAccessError(ctx, c.Writer, request, err)
return
}
c.Header("Cache-Control", "no-store")
c.JSON(http.StatusOK, response)
}
func (h *deviceHandler) verifyDeviceCode(c *gin.Context) {
authenticationTime, _ := c.Get("authenticationTime")
typedAuthenticationTime, _ := authenticationTime.(time.Time)
reauthenticationToken, _ := c.Cookie(cookie.ReauthenticationTokenCookieName)
userCode := c.Query("code")
if userCode == "" {
_ = c.Error(&common.ValidationError{Message: "code is required"})
return
}
err := h.deviceService.acceptDeviceCode(
c.Request.Context(),
userCode,
c.GetString("userID"),
c.GetString("authenticationMethod"),
typedAuthenticationTime,
reauthenticationToken,
requestMetaFromGin(c),
)
if err != nil {
if errors.Is(err, fosite.ErrAccessDenied) {
c.JSON(http.StatusForbidden, gin.H{"error": "You're not allowed to access this service."})
return
}
_ = c.Error(err)
return
}
c.Status(http.StatusNoContent)
}
func (h *deviceHandler) deviceCodeInfo(c *gin.Context) {
userCode := c.Query("code")
if userCode == "" {
_ = c.Error(&common.ValidationError{Message: "code is required"})
return
}
deviceCodeInfo, err := h.deviceService.getDeviceCodeInfo(c.Request.Context(), userCode, c.GetString("userID"))
if err != nil {
_ = c.Error(err)
return
}
c.JSON(http.StatusOK, deviceCodeInfo)
}

View File

@@ -0,0 +1,243 @@
package oidc
import (
"context"
"errors"
"net/http"
"time"
"github.com/ory/fosite"
"github.com/ory/fosite/handler/rfc8628"
"github.com/pocket-id/pocket-id/backend/internal/common"
"github.com/pocket-id/pocket-id/backend/internal/dto"
"github.com/pocket-id/pocket-id/backend/internal/model"
"gorm.io/gorm"
)
type deviceService struct {
provider fosite.OAuth2Provider
store *Store
userCodeStrategy rfc8628.UserCodeStrategy
authorizationService *authorizationService
claimsService *ClaimsService
auditLog AuditLogger
db *gorm.DB
}
func newDeviceService(
provider fosite.OAuth2Provider,
store *Store,
userCodeStrategy rfc8628.UserCodeStrategy,
authorizationService *authorizationService,
claimsService *ClaimsService,
auditLog AuditLogger,
db *gorm.DB,
) *deviceService {
return &deviceService{
provider: provider,
store: store,
userCodeStrategy: userCodeStrategy,
authorizationService: authorizationService,
claimsService: claimsService,
auditLog: auditLog,
db: db,
}
}
func (s *deviceService) createDeviceAuthorization(ctx context.Context, req *http.Request) (*dto.OidcDeviceAuthorizationResponseDto, fosite.Requester, error) {
request, err := s.provider.NewDeviceRequest(ctx, req)
if err != nil {
return nil, request, err
}
// Validate the requested scopes and resolve the resource indicator to an audience and the subset of requested scopes that may be granted
client := request.GetClient().(Client)
resource, err := request.GetResource()
if err != nil {
return nil, request, err
}
audience, grantedScopes, _, err := s.authorizationService.resolveGrant(ctx, client.GetID(), resource, request.GetRequestedScopes())
if err != nil {
if resource != "" && errors.Is(err, fosite.ErrAccessDenied) {
return nil, request, fosite.ErrInvalidTarget.WithHintf("The requested resource '%s' is invalid, missing, unknown, or malformed.", resource)
}
return nil, request, err
}
grantResourceIndicator(request, audience, grantedScopes)
session := NewEmptySession()
response, err := s.provider.NewDeviceResponse(ctx, request, session)
if err != nil {
return nil, request, err
}
return &dto.OidcDeviceAuthorizationResponseDto{
DeviceCode: response.GetDeviceCode(),
UserCode: response.GetUserCode(),
VerificationURI: response.GetVerificationURI(),
VerificationURIComplete: response.GetVerificationURIComplete(),
ExpiresIn: int(response.GetExpiresIn()),
Interval: response.GetInterval(),
}, request, nil
}
func (s *deviceService) acceptDeviceCode(ctx context.Context, userCode, userID, authenticationMethod string, authenticationTime time.Time, reauthenticationToken string, meta requestMeta) error {
request, userCodeSignature, err := s.deviceRequestFromUserCode(ctx, userCode)
if err != nil {
return err
}
// A user code may be approved only once. Rejecting an already-approved code prevents a second
// logged-in user from rebinding a pending device authorization to themselves before the device
// polls for its token.
if request.GetUserCodeState() != fosite.UserCodeUnused {
return &common.OidcInvalidDeviceCodeError{}
}
client := request.GetClient().(Client)
var user model.User
if err = s.db.WithContext(ctx).Preload("UserGroups").First(&user, "id = ?", userID).Error; err != nil {
return err
}
if !IsUserGroupAllowedToAuthorize(user, client.OidcClient) {
return fosite.ErrAccessDenied.WithHint("You are not allowed to access this service.")
}
resource, err := request.GetResource()
if err != nil {
return err
}
audience, grantedScopes, consentKeys, err := s.authorizationService.resolveGrant(ctx, client.GetID(), resource, request.GetRequestedScopes())
if err != nil {
return err
}
grantResourceIndicator(request, audience, grantedScopes)
return withTx(ctx, s.db, func(ctx context.Context) error {
if client.RequiresReauthentication {
if reauthenticationToken == "" || s.authorizationService == nil || s.authorizationService.reauth == nil {
return &common.ReauthenticationRequiredError{}
}
reauthenticatedAt, err := s.authorizationService.reauth.ConsumeReauthenticationToken(ctx, dbFromContext(ctx, s.db), reauthenticationToken, userID)
if err != nil {
return err
}
authenticationTime = reauthenticatedAt
}
if authenticationTime.IsZero() {
authenticationTime = time.Now().UTC()
}
session := NewAuthenticatedSession(userID, authenticationMethod, authenticationTime, request.GetRequestedAt())
if err = s.claimsService.applyIDTokenClaims(ctx, session, request.GetGrantedScopes()); err != nil {
return err
}
request.SetSession(session)
hasAlreadyAuthorizedClient, err := s.authorizationService.consent(ctx, userID, client.GetID(), consentKeys)
if err != nil {
return err
}
event := model.AuditLogEventDeviceCodeAuthorization
if !hasAlreadyAuthorizedClient {
event = model.AuditLogEventNewDeviceCodeAuthorization
}
s.auditLog.Create(ctx, event, meta.IPAddress, meta.UserAgent, userID, model.AuditLogData{"clientName": client.Name}, dbFromContext(ctx, s.db))
deviceCodeSignature, err := s.store.AcceptDeviceCodeSessionByUserCodeSignature(ctx, userCodeSignature, request)
if err != nil {
return err
}
if request.GetGrantedScopes().Has("openid") {
if err := s.store.CreateOpenIDConnectSession(ctx, deviceCodeSignature, request); err != nil {
return err
}
}
return nil
})
}
func (s *deviceService) getDeviceCodeInfo(ctx context.Context, userCode, userID string) (*dto.DeviceCodeInfoDto, error) {
request, _, err := s.deviceRequestFromUserCode(ctx, userCode)
if err != nil {
return nil, err
}
client := request.GetClient().(Client)
resource, err := request.GetResource()
if err != nil {
return nil, err
}
authorizationRequired := true
if userID != "" {
_, _, consentKeys, err := s.authorizationService.resolveGrant(ctx, client.GetID(), resource, request.GetRequestedScopes())
if err != nil {
return nil, err
}
hasAuthorizedClient, err := s.authorizationService.hasAuthorizedClient(ctx, client.GetID(), userID, consentKeys)
if err != nil {
return nil, err
}
// The device flow has no per-request prompt parameter, so consent depends only on prior authorization and the client's skip-consent setting
authorizationRequired = consentRequired(hasAuthorizedClient, client.SkipConsent, nil)
}
scope := request.GetRequestedScopes()
if scope == nil {
scope = []string{}
}
// Resolve friendly names for the requested custom-API permissions so the device consent screen matches the browser flow
scopeInfo, err := s.authorizationService.resolveScopeInfoForRequest(ctx, resource, scope)
if err != nil {
return nil, err
}
// Always serialize a possibly empty array rather than null
if scopeInfo == nil {
scopeInfo = []dto.ScopeInfoDto{}
}
return &dto.DeviceCodeInfoDto{
Client: dto.OidcClientMetaDataDto{
ID: client.ID,
Name: client.Name,
HasLogo: client.HasLogo(),
HasDarkLogo: client.HasDarkLogo(),
LaunchURL: client.LaunchURL,
RequiresReauthentication: client.RequiresReauthentication,
},
Scope: scope,
ScopeInfo: scopeInfo,
AuthorizationRequired: authorizationRequired,
ReauthenticationRequired: client.RequiresReauthentication,
}, nil
}
func (s *deviceService) deviceRequestFromUserCode(ctx context.Context, userCode string) (fosite.DeviceRequester, string, error) {
userCodeSignature, err := s.userCodeStrategy.UserCodeSignature(ctx, userCode)
if err != nil {
return nil, "", err
}
request, err := s.store.GetDeviceCodeSessionByUserCodeSignature(ctx, userCodeSignature)
if errors.Is(err, fosite.ErrNotFound) {
return nil, "", &common.OidcInvalidDeviceCodeError{}
}
if err != nil {
return nil, "", err
}
if err = s.userCodeStrategy.ValidateUserCode(ctx, request, userCode); err != nil {
if errors.Is(err, fosite.ErrDeviceExpiredToken) {
return nil, "", &common.OidcDeviceCodeExpiredError{}
}
return nil, "", err
}
return request, userCodeSignature, nil
}

View File

@@ -0,0 +1,160 @@
package oidc
import (
"context"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"time"
"github.com/ory/fosite"
"github.com/pocket-id/pocket-id/backend/internal/common"
"github.com/pocket-id/pocket-id/backend/internal/model"
testutils "github.com/pocket-id/pocket-id/backend/internal/utils/testing"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
)
type fakeReauthenticationConsumer struct {
token string
userID string
reauthenticatedAt time.Time
calls int
}
func (f *fakeReauthenticationConsumer) ConsumeReauthenticationToken(_ context.Context, _ *gorm.DB, token string, userID string) (time.Time, error) {
f.calls++
if token != f.token || userID != f.userID {
return time.Time{}, &common.ReauthenticationRequiredError{}
}
return f.reauthenticatedAt, nil
}
func TestDeviceServiceAcceptRequiresReauthenticationTokenWhenClientRequiresIt(t *testing.T) {
const (
userID = "test-user"
clientID = "test-client"
)
reauth := &fakeReauthenticationConsumer{ //nolint:gosec // test fixture token, not a real credential
token: "valid-reauth-token",
userID: userID,
reauthenticatedAt: time.Now().UTC().Truncate(time.Second),
}
service, _, _, userCode, _ := newTestDeviceServiceWithCode(t, clientID, userID, true, reauth)
err := service.acceptDeviceCode(t.Context(), userCode, userID, "phr", time.Now().UTC(), "", requestMeta{})
require.ErrorAs(t, err, new(*common.ReauthenticationRequiredError))
require.Zero(t, reauth.calls)
info, err := service.getDeviceCodeInfo(t.Context(), userCode, userID)
require.NoError(t, err)
require.True(t, info.ReauthenticationRequired)
err = service.acceptDeviceCode(t.Context(), userCode, userID, "phr", time.Now().UTC(), reauth.token, requestMeta{})
require.NoError(t, err)
require.Equal(t, 1, reauth.calls)
}
func TestDeviceServiceAcceptUsesReauthenticationTimeForDeviceSession(t *testing.T) {
const (
userID = "test-user"
clientID = "test-client"
)
reauthenticatedAt := time.Now().Add(-30 * time.Second).UTC().Truncate(time.Second)
reauth := &fakeReauthenticationConsumer{ //nolint:gosec // test fixture token, not a real credential
token: "valid-reauth-token",
userID: userID,
reauthenticatedAt: reauthenticatedAt,
}
service, store, provider, userCode, deviceCode := newTestDeviceServiceWithCode(t, clientID, userID, true, reauth)
err := service.acceptDeviceCode(t.Context(), userCode, userID, "phr", time.Now().Add(-time.Hour).UTC(), reauth.token, requestMeta{})
require.NoError(t, err)
deviceCodeSignature, err := provider.deviceStrategy.DeviceCodeSignature(t.Context(), deviceCode)
require.NoError(t, err)
acceptedRequest, err := store.GetDeviceCodeSession(t.Context(), deviceCodeSignature, NewEmptySession())
require.NoError(t, err)
session := acceptedRequest.GetSession().(*Session)
require.Equal(t, reauthenticatedAt, session.IDTokenClaims().AuthTime)
}
func TestDeviceServiceCreateRejectsResourceWithoutUserDelegatedGrant(t *testing.T) {
const (
userID = "test-user"
clientID = "test-client"
audience = "https://api.orders.example.com"
)
apiAccess := fakeAPIAccess{allowed: map[string]map[SubjectType][]string{
audience: {
SubjectTypeClient: {"write:orders"},
},
}}
service, store, _ := newTestDeviceService(t, clientID, userID, false, nil, apiAccess)
form := url.Values{
"client_id": {clientID},
"resource": {audience},
"scope": {"write:orders"},
}
req := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/api/oidc/device/authorize", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
_, _, err := service.createDeviceAuthorization(t.Context(), req)
require.ErrorIs(t, err, fosite.ErrInvalidTarget)
var count int64
require.NoError(t, store.db.Model(&OAuth2Session{}).Where("kind IN ?", []string{sessionKindDeviceCode, sessionKindUserCode}).Count(&count).Error)
require.Zero(t, count)
}
func newTestDeviceServiceWithCode(t *testing.T, clientID, userID string, requiresReauthentication bool, reauth ReauthenticationTokenConsumer) (*deviceService, *Store, *oidcProvider, string, string) {
t.Helper()
service, store, provider := newTestDeviceService(t, clientID, userID, requiresReauthentication, reauth, nil)
form := url.Values{
"client_id": {clientID},
"scope": {"openid"},
}
req := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/api/oidc/device/authorize", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
response, _, err := service.createDeviceAuthorization(t.Context(), req)
require.NoError(t, err)
return service, store, provider, response.UserCode, response.DeviceCode
}
func newTestDeviceService(t *testing.T, clientID, userID string, requiresReauthentication bool, reauth ReauthenticationTokenConsumer, apiAccess APIAccessProvider) (*deviceService, *Store, *oidcProvider) {
t.Helper()
db := testutils.NewDatabaseForTest(t)
require.NoError(t, db.Create(&model.User{Base: model.Base{ID: userID}}).Error)
require.NoError(t, db.Create(&model.OidcClient{
Base: model.Base{ID: clientID},
Name: "Test Client",
IsPublic: true,
RequiresReauthentication: requiresReauthentication,
}).Error)
store := NewStore(db, apiAccess)
signerKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
require.NoError(t, err)
provider, err := newProvider(store, nil, testTokenSigner{key: signerKey}, Config{ //nolint:gosec // static test-only provider secret
BaseURL: "https://issuer.example.com",
TokenBaseURL: "https://issuer.example.com",
Secret: []byte("test-secret"),
})
require.NoError(t, err)
claimsService := newClaimsService(db, nil, "", nil)
authorizationService := newAuthorizationService(db, newInteractionSessionService(db), claimsService, reauth, &fakeAuditLogger{}, apiAccess)
service := newDeviceService(provider, store, provider.deviceStrategy, authorizationService, claimsService, &fakeAuditLogger{}, db)
return service, store, provider
}

View File

@@ -0,0 +1,58 @@
package oidc
import (
"log/slog"
"net/http"
"github.com/gin-gonic/gin"
"github.com/pocket-id/pocket-id/backend/internal/dto"
"github.com/pocket-id/pocket-id/backend/internal/utils/cookie"
)
type endSessionHandler struct {
endSessionService *endSessionService
baseURL string
}
func newEndSessionHandler(endSessionService *endSessionService, baseURL string) *endSessionHandler {
return &endSessionHandler{
endSessionService: endSessionService,
baseURL: baseURL,
}
}
func (h *endSessionHandler) endSession(c *gin.Context) {
input, err := bindEndSessionRequest(c)
if err != nil {
_ = c.Error(err)
return
}
callbackURL, err := h.endSessionService.endSession(c.Request.Context(), input, c.GetString("userID"))
if err != nil {
slog.WarnContext(c.Request.Context(), "Error getting logout callback URL, the user has to confirm the logout manually", "error", err)
c.Redirect(http.StatusFound, h.baseURL+"/logout")
return
}
cookie.AddAccessTokenCookie(c, 0, "")
if callbackURL == "" {
c.Redirect(http.StatusFound, h.baseURL+"/logout")
return
}
c.Redirect(http.StatusFound, appendStateToURL(callbackURL, input.State))
}
func bindEndSessionRequest(c *gin.Context) (dto.OidcLogoutDto, error) {
var input dto.OidcLogoutDto
switch c.Request.Method {
case http.MethodGet:
return input, c.ShouldBindQuery(&input)
case http.MethodPost:
return input, c.ShouldBind(&input)
default:
return input, nil
}
}

View File

@@ -0,0 +1,153 @@
package oidc
import (
"context"
"errors"
"net/url"
"time"
"github.com/lestrrat-go/jwx/v3/jwt"
"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"
"github.com/pocket-id/pocket-id/backend/internal/utils"
"gorm.io/gorm"
)
type endSessionService struct {
db *gorm.DB
store *Store
signer TokenSigner
baseURL string
}
func newEndSessionService(db *gorm.DB, store *Store, signer TokenSigner, baseURL string) *endSessionService {
return &endSessionService{
db: db,
store: store,
signer: signer,
baseURL: baseURL,
}
}
// endSession revokes the sessions belonging to the ID token hint and returns the
// client's post-logout callback URL (empty if none is configured).
func (s *endSessionService) endSession(ctx context.Context, input dto.OidcLogoutDto, userID string) (string, error) {
if input.IdTokenHint == "" {
return "", &common.TokenInvalidError{}
}
token, err := s.verifyIDTokenHint(input.IdTokenHint)
if err != nil {
return "", &common.TokenInvalidError{}
}
clientIDs, ok := token.Audience()
if !ok || len(clientIDs) == 0 {
return "", &common.TokenInvalidError{}
}
clientID := clientIDs[0]
if input.ClientId != "" && clientID != input.ClientId {
return "", &common.OidcClientIdNotMatchingError{}
}
subject, ok := token.Subject()
if !ok || subject == "" {
return "", &common.TokenInvalidError{}
}
if userID != "" && subject != userID {
return "", &common.TokenInvalidError{}
}
userID = subject
idTokenJTI, ok := token.JwtID()
if !ok {
return "", &common.TokenInvalidError{}
}
var callbackURL string
err = withTx(ctx, s.db, func(ctx context.Context) error {
var authorizedClient model.UserAuthorizedOidcClient
err := dbFromContext(ctx, s.db).
Preload("Client").
First(&authorizedClient, "client_id = ? AND user_id = ?", clientID, userID).
Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return &common.OidcMissingAuthorizationError{}
}
return err
}
callbackURL, err = logoutCallbackURL(&authorizedClient.Client, input.PostLogoutRedirectUri)
if err != nil {
return err
}
return s.store.RevokeSessionsByIDTokenHint(ctx, userID, clientID, idTokenJTI)
})
if err != nil {
return "", err
}
return callbackURL, nil
}
func (s *endSessionService) verifyIDTokenHint(tokenString string) (jwt.Token, error) {
alg, err := s.signer.GetKeyAlg()
if err != nil {
return nil, err
}
token, err := jwt.ParseString(
tokenString,
jwt.WithValidate(true),
jwt.WithKey(alg, s.signer.GetPrivateKey()),
jwt.WithAcceptableSkew(time.Minute),
jwt.WithResetValidators(true),
jwt.WithIssuer(s.baseURL),
jwt.WithValidator(jwt.IsIssuedAtValid()),
jwt.WithValidator(jwt.IsNbfValid()),
)
if err != nil {
return nil, err
}
// id_token_hint must be an ID token, never an access token (both are signed with the same
// key). An expired ID token is still accepted here, as required by OIDC RP-Initiated Logout.
var tokenType string
if err := token.Get(common.TokenTypeClaim, &tokenType); err != nil || tokenType != idTokenType {
return nil, &common.TokenInvalidError{}
}
return token, nil
}
func logoutCallbackURL(client *model.OidcClient, inputLogoutCallbackURL string) (string, error) {
if len(client.LogoutCallbackURLs) == 0 {
return "", nil
}
if inputLogoutCallbackURL == "" {
return client.LogoutCallbackURLs[0], nil
}
matched, err := utils.GetCallbackURLFromList(client.LogoutCallbackURLs, inputLogoutCallbackURL)
if err != nil || matched == "" {
return "", &common.OidcInvalidCallbackURLError{}
}
return matched, nil
}
func appendStateToURL(callbackURL string, state string) string {
parsed, err := url.Parse(callbackURL)
if err != nil {
return callbackURL
}
if state != "" {
q := parsed.Query()
q.Set("state", state)
parsed.RawQuery = q.Encode()
}
return parsed.String()
}

View File

@@ -0,0 +1,309 @@
package oidc
import (
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/rsa"
"net/url"
"testing"
"time"
"github.com/lestrrat-go/jwx/v3/jwa"
"github.com/lestrrat-go/jwx/v3/jwt"
"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/model"
testutils "github.com/pocket-id/pocket-id/backend/internal/utils/testing"
)
// TestLogoutCallbackURL locks the post-logout redirect resolution that replaced the
// deleted callback_url_util helper. An attacker-supplied post_logout_redirect_uri must
// only be honored when it exactly matches one the client registered, otherwise logout
// would be an open redirect.
func TestLogoutCallbackURL(t *testing.T) {
noURLs := &model.OidcClient{Base: model.Base{ID: "c"}}
withURLs := &model.OidcClient{Base: model.Base{ID: "c"}, LogoutCallbackURLs: model.UrlList{
"https://app.example/logout",
"https://app.example/logout2",
"https://*.example/logout",
}}
t.Run("no configured logout URLs yields no callback", func(t *testing.T) {
got, err := logoutCallbackURL(noURLs, "")
require.NoError(t, err)
require.Empty(t, got)
})
t.Run("empty input falls back to first registered URL", func(t *testing.T) {
got, err := logoutCallbackURL(withURLs, "")
require.NoError(t, err)
require.Equal(t, "https://app.example/logout", got)
})
t.Run("exact match is honored", func(t *testing.T) {
got, err := logoutCallbackURL(withURLs, "https://app.example/logout2")
require.NoError(t, err)
require.Equal(t, "https://app.example/logout2", got)
})
t.Run("wildcard match is honored with the requested URL", func(t *testing.T) {
got, err := logoutCallbackURL(withURLs, "https://tenant.example/logout")
require.NoError(t, err)
require.Equal(t, "https://tenant.example/logout", got)
})
t.Run("unregistered URL is rejected (no open redirect)", func(t *testing.T) {
_, err := logoutCallbackURL(withURLs, "https://evil.example/steal")
var target *common.OidcInvalidCallbackURLError
require.ErrorAs(t, err, &target)
})
}
func TestAppendStateToURL(t *testing.T) {
require.Equal(t, "https://app.example/cb", appendStateToURL("https://app.example/cb", ""))
require.Equal(t, "https://app.example/cb?state=xyz", appendStateToURL("https://app.example/cb", "xyz"))
got := appendStateToURL("https://app.example/cb?foo=bar", "xyz")
parsed, err := url.Parse(got)
require.NoError(t, err)
require.Equal(t, "bar", parsed.Query().Get("foo"))
require.Equal(t, "xyz", parsed.Query().Get("state"))
}
// TestEndSessionService exercises the RP-initiated logout validation and the session
// revocation it triggers. The ID token hint must be a valid, first-party token whose
// subject identifies the logged-out user and whose audience matches the client; only then
// is the client's registered post-logout URL returned and the user/client sessions revoked.
func TestEndSessionService(t *testing.T) {
const (
baseURL = "https://issuer.example.com"
userID = "user-1"
clientID = "client-1"
jti = "id-token-jti"
)
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
require.NoError(t, err)
signer := testTokenSigner{key: key}
type tokenOptions struct {
issuer string
subject string
audience string
jti string
omitSubject bool
omitJTI bool
omitAud bool
omitType bool
}
signToken := func(t *testing.T, opts tokenOptions) string {
t.Helper()
builder := jwt.NewBuilder().IssuedAt(time.Now())
if opts.issuer != "" {
builder = builder.Issuer(opts.issuer)
}
if !opts.omitSubject {
builder = builder.Subject(opts.subject)
}
if !opts.omitAud {
builder = builder.Audience([]string{opts.audience})
}
if !opts.omitJTI {
builder = builder.JwtID(opts.jti)
}
if !opts.omitType {
builder = builder.Claim(common.TokenTypeClaim, idTokenType)
}
token, err := builder.Build()
require.NoError(t, err)
signed, err := jwt.Sign(token, jwt.WithKey(jwa.ES256(), key))
require.NoError(t, err)
return string(signed)
}
newService := func(t *testing.T) (*endSessionService, *Store) {
t.Helper()
db := testutils.NewDatabaseForTest(t)
require.NoError(t, db.Create(&model.OidcClient{
Base: model.Base{ID: clientID},
Name: "Test Client",
LogoutCallbackURLs: model.UrlList{"https://app.example/logout"},
}).Error)
require.NoError(t, db.Create(&model.User{Base: model.Base{ID: userID}, Username: "tim"}).Error)
require.NoError(t, db.Create(&model.UserAuthorizedOidcClient{UserID: userID, ClientID: clientID}).Error)
store := NewStore(db, nil)
return newEndSessionService(db, store, signer, baseURL), store
}
validToken := tokenOptions{issuer: baseURL, subject: userID, audience: clientID, jti: jti}
t.Run("missing id_token_hint is rejected", func(t *testing.T) {
service, _ := newService(t)
_, err := service.endSession(t.Context(), dto.OidcLogoutDto{}, userID)
var target *common.TokenInvalidError
require.ErrorAs(t, err, &target)
})
t.Run("malformed id_token_hint is rejected", func(t *testing.T) {
service, _ := newService(t)
_, err := service.endSession(t.Context(), dto.OidcLogoutDto{IdTokenHint: "not-a-jwt"}, userID)
var target *common.TokenInvalidError
require.ErrorAs(t, err, &target)
})
t.Run("token signed by a foreign key is rejected", func(t *testing.T) {
service, _ := newService(t)
otherKey, err := rsa.GenerateKey(rand.Reader, 2048)
require.NoError(t, err)
token, err := jwt.NewBuilder().
Issuer(baseURL).Subject(userID).Audience([]string{clientID}).JwtID(jti).IssuedAt(time.Now()).Build()
require.NoError(t, err)
signed, err := jwt.Sign(token, jwt.WithKey(jwa.RS256(), otherKey))
require.NoError(t, err)
_, err = service.endSession(t.Context(), dto.OidcLogoutDto{IdTokenHint: string(signed)}, userID)
var target *common.TokenInvalidError
require.ErrorAs(t, err, &target)
})
t.Run("non-ID token (missing type claim) is rejected as id_token_hint", func(t *testing.T) {
// A first-party JWT access token of the same user/client (signed with the same key) must
// not be accepted as an id_token_hint; only genuine ID tokens carry the type claim.
service, _ := newService(t)
token := signToken(t, tokenOptions{issuer: baseURL, subject: userID, audience: clientID, jti: jti, omitType: true})
_, err := service.endSession(t.Context(), dto.OidcLogoutDto{IdTokenHint: token}, userID)
var target *common.TokenInvalidError
require.ErrorAs(t, err, &target)
})
t.Run("subject not matching the logged-in user is rejected", func(t *testing.T) {
service, _ := newService(t)
token := signToken(t, tokenOptions{issuer: baseURL, subject: "someone-else", audience: clientID, jti: jti})
_, err := service.endSession(t.Context(), dto.OidcLogoutDto{IdTokenHint: token}, userID)
var target *common.TokenInvalidError
require.ErrorAs(t, err, &target)
})
t.Run("client_id parameter not matching the token audience is rejected", func(t *testing.T) {
service, _ := newService(t)
token := signToken(t, validToken)
_, err := service.endSession(t.Context(), dto.OidcLogoutDto{IdTokenHint: token, ClientId: "different-client"}, userID)
var target *common.OidcClientIdNotMatchingError
require.ErrorAs(t, err, &target)
})
t.Run("user that never authorized the client is rejected", func(t *testing.T) {
service, _ := newService(t)
// A valid token for a user that has no authorization record for the client.
token := signToken(t, tokenOptions{issuer: baseURL, subject: "ghost-user", audience: clientID, jti: jti})
_, err := service.endSession(t.Context(), dto.OidcLogoutDto{IdTokenHint: token}, "ghost-user")
var target *common.OidcMissingAuthorizationError
require.ErrorAs(t, err, &target)
})
t.Run("unregistered post_logout_redirect_uri is rejected", func(t *testing.T) {
service, _ := newService(t)
token := signToken(t, validToken)
_, err := service.endSession(t.Context(), dto.OidcLogoutDto{
IdTokenHint: token,
PostLogoutRedirectUri: "https://evil.example/steal",
}, userID)
var target *common.OidcInvalidCallbackURLError
require.ErrorAs(t, err, &target)
})
t.Run("valid logout returns the callback URL and revokes the sessions", func(t *testing.T) {
service, store := newService(t)
// Seed an active refresh/access token pair tied to this user, client and ID token.
require.NoError(t, store.CreateRefreshTokenSession(t.Context(), "rt-sig", "at-sig", newTestRequester("logout-req", clientID, userID, jti)))
require.NoError(t, store.CreateAccessTokenSession(t.Context(), "at-sig", newTestRequester("logout-req", clientID, userID, jti)))
token := signToken(t, validToken)
callback, err := service.endSession(t.Context(), dto.OidcLogoutDto{IdTokenHint: token}, userID)
require.NoError(t, err)
require.Equal(t, "https://app.example/logout", callback)
var refresh OAuth2Session
require.NoError(t, service.db.First(&refresh, "kind = ? AND key = ?", sessionKindRefreshToken, "rt-sig").Error)
require.False(t, refresh.Active, "refresh token must be revoked on logout")
var accessCount int64
require.NoError(t, service.db.Model(&OAuth2Session{}).
Where("kind = ? AND key = ?", sessionKindAccessToken, "at-sig").
Count(&accessCount).Error)
require.Zero(t, accessCount, "access token must be deleted on logout")
})
t.Run("valid logout revokes only the session matching the id_token_hint", func(t *testing.T) {
service, store := newService(t)
require.NoError(t, store.CreateRefreshTokenSession(t.Context(), "rt-matching", "at-matching", newTestRequester("matching-req", clientID, userID, jti)))
require.NoError(t, store.CreateAccessTokenSession(t.Context(), "at-matching", newTestRequester("matching-req", clientID, userID, jti)))
require.NoError(t, store.CreateRefreshTokenSession(t.Context(), "rt-other", "at-other", newTestRequester("other-req", clientID, userID, "other-id-token-jti")))
require.NoError(t, store.CreateAccessTokenSession(t.Context(), "at-other", newTestRequester("other-req", clientID, userID, "other-id-token-jti")))
token := signToken(t, validToken)
callback, err := service.endSession(t.Context(), dto.OidcLogoutDto{IdTokenHint: token}, userID)
require.NoError(t, err)
require.Equal(t, "https://app.example/logout", callback)
var matchingRefresh OAuth2Session
require.NoError(t, service.db.First(&matchingRefresh, "kind = ? AND key = ?", sessionKindRefreshToken, "rt-matching").Error)
require.False(t, matchingRefresh.Active, "refresh token matching id_token_hint must be revoked on logout")
var otherRefresh OAuth2Session
require.NoError(t, service.db.First(&otherRefresh, "kind = ? AND key = ?", sessionKindRefreshToken, "rt-other").Error)
require.True(t, otherRefresh.Active, "unrelated refresh token for the same user/client must remain active")
var matchingAccessCount int64
require.NoError(t, service.db.Model(&OAuth2Session{}).
Where("kind = ? AND key = ?", sessionKindAccessToken, "at-matching").
Count(&matchingAccessCount).Error)
require.Zero(t, matchingAccessCount, "access token matching id_token_hint must be deleted on logout")
var otherAccessCount int64
require.NoError(t, service.db.Model(&OAuth2Session{}).
Where("kind = ? AND key = ?", sessionKindAccessToken, "at-other").
Count(&otherAccessCount).Error)
require.Equal(t, int64(1), otherAccessCount, "unrelated access token for the same user/client must remain active")
})
t.Run("valid logout without UI session derives the user from id_token_hint", func(t *testing.T) {
service, store := newService(t)
// The OP browser session is absent, but the ID token hint still identifies the
// End-User and RP session that should be logged out.
require.NoError(t, store.CreateRefreshTokenSession(t.Context(), "rt-no-session", "at-no-session", newTestRequester("logout-req", clientID, userID, jti)))
require.NoError(t, store.CreateAccessTokenSession(t.Context(), "at-no-session", newTestRequester("logout-req", clientID, userID, jti)))
token := signToken(t, validToken)
callback, err := service.endSession(t.Context(), dto.OidcLogoutDto{IdTokenHint: token}, "")
require.NoError(t, err)
require.Equal(t, "https://app.example/logout", callback)
var refresh OAuth2Session
require.NoError(t, service.db.First(&refresh, "kind = ? AND key = ?", sessionKindRefreshToken, "rt-no-session").Error)
require.False(t, refresh.Active, "refresh token must be revoked on logout")
var accessCount int64
require.NoError(t, service.db.Model(&OAuth2Session{}).
Where("kind = ? AND key = ?", sessionKindAccessToken, "at-no-session").
Count(&accessCount).Error)
require.Zero(t, accessCount, "access token must be deleted on logout")
})
t.Run("valid logout honors a registered post_logout_redirect_uri", func(t *testing.T) {
service, _ := newService(t)
token := signToken(t, validToken)
callback, err := service.endSession(t.Context(), dto.OidcLogoutDto{
IdTokenHint: token,
PostLogoutRedirectUri: "https://app.example/logout",
}, userID)
require.NoError(t, err)
require.Equal(t, "https://app.example/logout", callback)
})
}

View File

@@ -0,0 +1,228 @@
package oidc
import (
"context"
"crypto/tls"
"errors"
"fmt"
"log/slog"
"net/http"
"net/url"
"strings"
"time"
"github.com/lestrrat-go/httprc/v3"
"github.com/lestrrat-go/httprc/v3/errsink"
"github.com/lestrrat-go/jwx/v3/jwk"
"github.com/lestrrat-go/jwx/v3/jws"
"github.com/lestrrat-go/jwx/v3/jwt"
"github.com/ory/fosite"
)
const clientAssertionTypeJWTBearer = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer" //nolint:gosec
var errNoFederatedClientAssertion = errors.New("no federated client assertion")
// federatedClientStore is the subset of the store the federated authenticator needs.
type federatedClientStore interface {
GetClient(ctx context.Context, id string) (fosite.Client, error)
ClientAssertionJWTValid(ctx context.Context, jti string) error
SetClientAssertionJWT(ctx context.Context, jti string, exp time.Time) error
}
// federatedClientAuthenticator authenticates clients via JWT bearer assertions issued
// by a federated identity provider configured per client.
type federatedClientAuthenticator struct {
clients federatedClientStore
httpClient *http.Client
jwksCache *jwk.Cache
defaultAudience string
}
func newFederatedClientAuthenticator(ctx context.Context, clients federatedClientStore, httpClient *http.Client, defaultAudience string) (*federatedClientAuthenticator, error) {
authenticator := &federatedClientAuthenticator{
clients: clients,
httpClient: httpClient,
defaultAudience: defaultAudience,
}
jwksCache, err := authenticator.getJWKCache(ctx)
if err != nil {
return nil, err
}
authenticator.jwksCache = jwksCache
return authenticator, nil
}
func (a *federatedClientAuthenticator) getJWKCache(ctx context.Context) (*jwk.Cache, error) {
// We need to create a custom HTTP client to set a timeout.
client := a.httpClient
if client == nil {
client = &http.Client{
Timeout: 10 * time.Second,
}
defaultTransport, ok := http.DefaultTransport.(*http.Transport)
if !ok {
// Indicates a development-time error
panic("Default transport is not of type *http.Transport")
}
transport := defaultTransport.Clone()
transport.TLSClientConfig.MinVersion = tls.VersionTLS12
client.Transport = transport
}
return jwk.NewCache(ctx,
httprc.NewClient(
httprc.WithErrorSink(errsink.NewSlog(slog.Default())),
httprc.WithHTTPClient(client),
),
)
}
// newClientAuthenticationStrategy accepts federated client assertions before falling
// back to fosite's default client authentication.
func newClientAuthenticationStrategy(authenticator *federatedClientAuthenticator, provider *fosite.Fosite) fosite.ClientAuthenticationStrategy {
return func(ctx context.Context, r *http.Request, form url.Values) (fosite.Client, error) {
client, err := authenticator.authenticateForm(ctx, form)
if err == nil {
return client, nil
}
if !errors.Is(err, errNoFederatedClientAssertion) {
return nil, err
}
return provider.DefaultClientAuthenticationStrategy(ctx, r, form)
}
}
// authenticateForm returns errNoFederatedClientAssertion when the form carries no
// federated assertion, so the caller can fall back to other authentication methods.
func (a *federatedClientAuthenticator) authenticateForm(ctx context.Context, form url.Values) (fosite.Client, error) {
if form.Get("client_assertion_type") != clientAssertionTypeJWTBearer || form.Get("client_assertion") == "" {
return nil, errNoFederatedClientAssertion
}
return a.authenticateAssertion(ctx, form.Get("client_assertion"), form.Get("client_id"))
}
// authenticateAssertion validates the assertion JWT against the client's configured
// federated identity. An empty clientID falls back to the assertion's subject.
func (a *federatedClientAuthenticator) authenticateAssertion(ctx context.Context, assertion string, clientID string) (fosite.Client, error) {
rawAssertion := []byte(assertion)
insecureToken, err := jwt.ParseInsecure(rawAssertion)
if err != nil {
return nil, fosite.ErrInvalidClient.WithHint("Invalid client assertion.").WithWrap(err)
}
issuer, _ := insecureToken.Issuer()
if issuer == "" {
return nil, fosite.ErrInvalidClient.WithHint("Client assertion is missing issuer.")
}
if clientID == "" {
clientID, _ = insecureToken.Subject()
}
if clientID == "" {
return nil, fosite.ErrInvalidClient.WithHint("Client assertion is missing subject.")
}
client, err := a.clients.GetClient(ctx, clientID)
if err != nil {
return nil, fosite.ErrInvalidClient.WithWrap(err)
}
oidcClient, ok := client.(Client)
if !ok {
return nil, errNoFederatedClientAssertion
}
federatedIdentity, ok := oidcClient.Credentials.FederatedIdentityForIssuer(issuer)
if !ok {
return nil, errNoFederatedClientAssertion
}
jwksURL := federatedIdentity.JWKS
if jwksURL == "" {
jwksURL = strings.TrimRight(issuer, "/") + "/.well-known/jwks.json"
}
jwks, err := a.fetchJWKSet(ctx, jwksURL)
if err != nil {
return nil, fosite.ErrInvalidClient.WithHint("Unable to fetch client assertion JWKS.").WithWrap(err)
}
audience := federatedIdentity.Audience
if audience == "" {
audience = a.defaultAudience
}
subject := federatedIdentity.Subject
if subject == "" {
subject = client.GetID()
}
parsed, err := jwt.Parse(rawAssertion,
jwt.WithValidate(true),
jwt.WithAcceptableSkew(30*time.Second),
jwt.WithRequiredClaim(jwt.ExpirationKey),
jwt.WithIssuer(issuer),
jwt.WithSubject(subject),
jwt.WithAudience(audience),
jwt.WithKeySet(jwks, jws.WithInferAlgorithmFromKey(true), jws.WithUseDefault(true)),
)
if err != nil {
return nil, fosite.ErrInvalidClient.WithHint("Invalid client assertion.").WithWrap(err)
}
if federatedIdentity.ReplayProtection {
jti, ok := parsed.JwtID()
if !ok || jti == "" {
return nil, fosite.ErrInvalidClient.WithHint("Client assertion is missing jti claim, which is required for replay protection.")
}
// Check if the jti has been used before
if err := a.clients.ClientAssertionJWTValid(ctx, jti); err != nil {
return nil, fosite.ErrInvalidClient.WithHint("Client assertion has already been used.").WithWrap(err)
}
// Store the jti to prevent future reuse
exp, _ := parsed.Expiration()
if err := a.clients.SetClientAssertionJWT(ctx, jti, exp); err != nil {
return nil, fosite.ErrInvalidClient.WithWrap(err)
}
}
return client, nil
}
func (a *federatedClientAuthenticator) fetchJWKSet(ctx context.Context, jwksURL string) (jwk.Set, error) {
if !a.jwksCache.IsRegistered(ctx, jwksURL) {
// We set a timeout because otherwise Register will keep trying in case of errors
registerCtx, registerCancel := context.WithTimeout(ctx, 15*time.Second)
defer registerCancel()
registerOptions := []jwk.RegisterOption{
jwk.WithMaxInterval(24 * time.Hour),
jwk.WithMinInterval(15 * time.Minute),
jwk.WithWaitReady(true),
}
if a.httpClient != nil {
registerOptions = append(registerOptions, jwk.WithHTTPClient(a.httpClient))
}
// We need to register the URL
err := a.jwksCache.Register(registerCtx, jwksURL, registerOptions...)
// In case of race conditions (two goroutines calling jwkCache.Register at the same time), it's possible we can get a conflict anyways, so we ignore that error
if err != nil && !errors.Is(err, httprc.ErrResourceAlreadyExists()) {
return nil, fmt.Errorf("failed to register JWK set: %w", err)
}
}
jwks, err := a.jwksCache.CachedSet(jwksURL)
if err != nil {
return nil, fmt.Errorf("failed to get cached JWK set: %w", err)
}
return jwks, nil
}

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