Compare commits

..

15 Commits
v2.6.1 ... main

Author SHA1 Message Date
Kyle Mendell
2340bb0f1d chore: fix caching of ldap-cli e2e tests docker build (#1457) 2026-04-28 15:14:22 -05:00
Elias Schneider
d860ef43ec chore(translations): update translations via Crowdin (#1456)
Co-authored-by: Kyle Mendell <kmendell@ofkm.us>
2026-04-28 12:12:41 -05:00
Kyle Mendell
d5cf60689e ci/cd: migrate github actions runners to depot runners (#1329) 2026-04-26 19:48:19 -05:00
Alessandro (Ale) Segala
f4706cd6cc feat: add support for "select_account" prompt (#1453)
Co-authored-by: Elias Schneider <login@eliasschneider.com>
2026-04-26 17:26:21 +00:00
Elias Schneider
e33a9b8c88 chore: post dependency upgrade fixes 2026-04-26 15:46:35 +02:00
Elias Schneider
20df033c1f chore: upgrade dependencies 2026-04-26 14:41:41 +02:00
Elias Schneider
f9f93f0ef1 chore: add script to update deps 2026-04-26 14:41:27 +02:00
John
64d4ac7919 feat: add support for response_mode=form_post (#1360)
Co-authored-by: Alessandro (Ale) Segala <43508+ItalyPaleAle@users.noreply.github.com>
Co-authored-by: Kyle Mendell <kmendell@ofkm.us>
Co-authored-by: Elias Schneider <login@eliasschneider.com>
2026-04-26 14:11:35 +02:00
Anthony Clerici
0ed2c48591 docs: add missing /api prefix to app config swagger routes (#1454) 2026-04-25 17:58:41 +02:00
Alessandro (Ale) Segala
5559077ab4 fix: add _FILE support for S3_SECRET_ACCESS_KEY_FILE env var (#1452) 2026-04-22 14:22:42 -07:00
Kyle Mendell
c8ff6b1cca release: 2.6.2 2026-04-21 16:47:45 -05:00
Elias Schneider
ea5a0fcf1e chore(translations): update translations via Crowdin (#1441) 2026-04-21 15:23:07 -05:00
Björn F.
a9fdab10f1 fix: improve keyboard navigation and screen-reader labels (#1445) 2026-04-21 15:21:23 -05:00
Kyle Mendell
605c8b2ba4 chore(deps): upgrade to vite 8.0 and pnpm 10.33.0 (#1446) 2026-04-21 15:21:08 -05:00
Amit
c96d591484 fix: return correct byte count in HEAD request writer (#1443)
Co-authored-by: Kyle Mendell <kmendell@ofkm.us>
2026-04-21 15:03:08 -05:00
81 changed files with 3627 additions and 1869 deletions

View File

@@ -17,14 +17,15 @@ permissions:
pull-requests: read
# Optional: allow write access to checks to allow the action to annotate code in the PR.
checks: write
id-token: write
jobs:
golangci-lint:
name: Run Golangci-lint
runs-on: ubuntu-latest
runs-on: depot-ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v5
uses: actions/checkout@v6
- name: Set up Go
uses: actions/setup-go@v6

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1 +1 @@
2.6.1
2.6.2

View File

@@ -1,3 +1,16 @@
## v2.6.2
### Bug Fixes
- return correct byte count in HEAD request writer ([#1443](https://github.com/pocket-id/pocket-id/pull/1443) by @ahampal)
- improve keyboard navigation and screen-reader labels ([#1445](https://github.com/pocket-id/pocket-id/pull/1445) by @bjoernch)
### Other
- upgrade to vite 8.0 and pnpm 10.33.0 ([#1446](https://github.com/pocket-id/pocket-id/pull/1446) by @kmendell)
**Full Changelog**: https://github.com/pocket-id/pocket-id/compare/v2.6.1...v2.6.2
## v2.6.1
### Bug Fixes

View File

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

View File

@@ -10,13 +10,14 @@ import (
"io/fs"
"mime"
"net/http"
"os"
"path"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/pocket-id/pocket-id/backend/internal/middleware"
"github.com/pocket-id/pocket-id/backend/internal/service"
"golang.org/x/time/rate"
)
//go:embed all:dist/*
@@ -54,7 +55,7 @@ func init() {
}
}
func RegisterFrontend(router *gin.Engine, rateLimitMiddleware gin.HandlerFunc) error {
func RegisterFrontend(router *gin.Engine, oidcService *service.OidcService) error {
distFS, err := fs.Sub(frontendFS, "dist")
if err != nil {
return fmt.Errorf("failed to create sub FS: %w", err)
@@ -83,16 +84,19 @@ func RegisterFrontend(router *gin.Engine, rateLimitMiddleware gin.HandlerFunc) e
return
}
// If path is / or does not exist, serve index.html
if path == "" {
path = "index.html"
} else if _, err := fs.Stat(distFS, path); os.IsNotExist(err) {
path = "index.html"
}
if path == "index.html" {
if isSPARequest(path, distFS) {
nonce := middleware.GetCSPNonce(c)
if isOAuth2AuthorizationPostRequest(c) {
// In that case, we need to validate and allow form submissions to the redirect_uri
redirectURI := c.Query("redirect_uri")
clientID := c.Query("client_id")
validatedRedirectURI, err := oidcService.ResolveAllowedCallbackURL(c.Request.Context(), clientID, redirectURI)
if err == nil {
c.Header("Content-Security-Policy", middleware.BuildCSP(nonce, validatedRedirectURI))
}
}
// Do not cache the HTML shell, as it embeds a per-request nonce
c.Header("Content-Type", "text/html; charset=utf-8")
c.Header("Cache-Control", "no-store")
@@ -108,11 +112,46 @@ func RegisterFrontend(router *gin.Engine, rateLimitMiddleware gin.HandlerFunc) e
fileServer.ServeHTTP(c.Writer, c.Request)
}
router.NoRoute(rateLimitMiddleware, handler)
rateLimitMiddleware := middleware.NewRateLimitMiddleware().Add(rate.Every(300*time.Millisecond), 50)
router.NoRoute(rateLimitOnlyForOAuth2AuthorizationPostRequest(rateLimitMiddleware, distFS), handler)
return nil
}
func rateLimitOnlyForOAuth2AuthorizationPostRequest(rateLimitMiddleware gin.HandlerFunc, distFS fs.FS) gin.HandlerFunc {
return func(c *gin.Context) {
path := strings.TrimPrefix(c.Request.URL.Path, "/")
if isSPARequest(path, distFS) && isOAuth2AuthorizationPostRequest(c) {
rateLimitMiddleware(c)
return
}
c.Next()
}
}
// isOAuth2AuthorizationRequest checks if this is an OAuth2 authorization request with response_mode=form_post
// In that case, we need to validate and allow form submissions to the redirect_uri
func isOAuth2AuthorizationPostRequest(c *gin.Context) bool {
responseMode := c.Query("response_mode")
redirectURI := c.Query("redirect_uri")
clientID := c.Query("client_id")
return responseMode == "form_post" && redirectURI != "" && clientID != ""
}
func isSPARequest(path string, distFS fs.FS) bool {
if path == "" {
return true
}
if _, err := fs.Stat(distFS, path); err != nil {
return true
}
return false
}
// FileServerWithCaching wraps http.FileServer to add caching headers
type FileServerWithCaching struct {
root http.FileSystem

View File

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

View File

@@ -3,11 +3,11 @@ module github.com/pocket-id/pocket-id/backend
go 1.26.0
require (
github.com/aws/aws-sdk-go-v2 v1.41.5
github.com/aws/aws-sdk-go-v2/config v1.32.14
github.com/aws/aws-sdk-go-v2/credentials v1.19.14
github.com/aws/aws-sdk-go-v2/service/s3 v1.99.0
github.com/aws/smithy-go v1.24.3
github.com/aws/aws-sdk-go-v2 v1.41.6
github.com/aws/aws-sdk-go-v2/config v1.32.16
github.com/aws/aws-sdk-go-v2/credentials v1.19.15
github.com/aws/aws-sdk-go-v2/service/s3 v1.99.1
github.com/aws/smithy-go v1.25.0
github.com/caarlos0/env/v11 v11.4.0
github.com/cenkalti/backoff/v5 v5.0.3
github.com/disintegration/imageorient v0.0.0-20180920195336-8147d86e83ec
@@ -15,22 +15,22 @@ require (
github.com/dunglas/go-urlpattern v0.0.0-20241020164140-716dfa1c80b1
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6
github.com/emersion/go-smtp v0.24.0
github.com/fsnotify/fsnotify v1.8.0
github.com/fsnotify/fsnotify v1.9.0
github.com/gin-contrib/slog v1.2.1
github.com/gin-gonic/gin v1.12.0
github.com/glebarez/go-sqlite v1.22.0
github.com/glebarez/sqlite v1.11.0
github.com/go-co-op/gocron/v2 v2.20.0
github.com/go-co-op/gocron/v2 v2.21.0
github.com/go-ldap/ldap/v3 v3.4.13
github.com/go-playground/validator/v10 v10.30.2
github.com/go-webauthn/webauthn v0.16.4
github.com/go-webauthn/webauthn v0.16.5
github.com/golang-migrate/migrate/v4 v4.19.1
github.com/google/uuid v1.6.0
github.com/hashicorp/go-uuid v1.0.3
github.com/jinzhu/copier v0.4.0
github.com/joho/godotenv v1.5.1
github.com/lestrrat-go/httprc/v3 v3.0.5
github.com/lestrrat-go/jwx/v3 v3.0.13
github.com/lestrrat-go/jwx/v3 v3.1.0
github.com/lmittmann/tint v1.1.3
github.com/mattn/go-isatty v0.0.21
github.com/mileusna/useragent v1.3.5
@@ -61,20 +61,19 @@ require (
require (
github.com/Azure/go-ntlmssp v0.1.0 // indirect
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.21 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21 // indirect
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.6 // indirect
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.22 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.13 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.21 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.21 // indirect
github.com/aws/aws-sdk-go-v2/service/signin v1.0.9 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.30.15 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.19 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.41.10 // indirect
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.9 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.22 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.22 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.22 // indirect
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.23 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.8 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.14 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.22 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.22 // indirect
github.com/aws/aws-sdk-go-v2/service/signin v1.0.10 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.30.16 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.20 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.42.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
@@ -141,7 +140,7 @@ require (
github.com/robfig/cron/v3 v3.0.1 // indirect
github.com/segmentio/asm v1.2.1 // indirect
github.com/spf13/pflag v1.0.10 // indirect
github.com/tinylib/msgp v1.6.3 // indirect
github.com/tinylib/msgp v1.6.4 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.3.1 // indirect
github.com/valyala/fastjson v1.6.10 // indirect

View File

@@ -6,44 +6,42 @@ github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERo
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e h1:4dAU9FXIyQktpoUAgOJK3OTFc/xug0PCXYCqU0FgDKI=
github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4=
github.com/aws/aws-sdk-go-v2 v1.41.5 h1:dj5kopbwUsVUVFgO4Fi5BIT3t4WyqIDjGKCangnV/yY=
github.com/aws/aws-sdk-go-v2 v1.41.5/go.mod h1:mwsPRE8ceUUpiTgF7QmQIJ7lgsKUPQOUl3o72QBrE1o=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8 h1:eBMB84YGghSocM7PsjmmPffTa+1FBUeNvGvFou6V/4o=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8/go.mod h1:lyw7GFp3qENLh7kwzf7iMzAxDn+NzjXEAGjKS2UOKqI=
github.com/aws/aws-sdk-go-v2/config v1.32.14 h1:opVIRo/ZbbI8OIqSOKmpFaY7IwfFUOCCXBsUpJOwDdI=
github.com/aws/aws-sdk-go-v2/config v1.32.14/go.mod h1:U4/V0uKxh0Tl5sxmCBZ3AecYny4UNlVmObYjKuuaiOo=
github.com/aws/aws-sdk-go-v2/credentials v1.19.14 h1:n+UcGWAIZHkXzYt87uMFBv/l8THYELoX6gVcUvgl6fI=
github.com/aws/aws-sdk-go-v2/credentials v1.19.14/go.mod h1:cJKuyWB59Mqi0jM3nFYQRmnHVQIcgoxjEMAbLkpr62w=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.21 h1:NUS3K4BTDArQqNu2ih7yeDLaS3bmHD0YndtA6UP884g=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.21/go.mod h1:YWNWJQNjKigKY1RHVJCuupeWDrrHjRqHm0N9rdrWzYI=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21 h1:Rgg6wvjjtX8bNHcvi9OnXWwcE0a2vGpbwmtICOsvcf4=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21/go.mod h1:A/kJFst/nm//cyqonihbdpQZwiUhhzpqTsdbhDdRF9c=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21 h1:PEgGVtPoB6NTpPrBgqSE5hE/o47Ij9qk/SEZFbUOe9A=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21/go.mod h1:p+hz+PRAYlY3zcpJhPwXlLC4C+kqn70WIHwnzAfs6ps=
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.6 h1:qYQ4pzQ2Oz6WpQ8T3HvGHnZydA72MnLuFK9tJwmrbHw=
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.6/go.mod h1:O3h0IK87yXci+kg6flUKzJnWeziQUKciKrLjcatSNcY=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.22 h1:rWyie/PxDRIdhNf4DzRk0lvjVOqFJuNnO8WwaIRVxzQ=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.22/go.mod h1:zd/JsJ4P7oGfUhXn1VyLqaRZwPmZwg44Jf2dS84Dm3Y=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7 h1:5EniKhLZe4xzL7a+fU3C2tfUN4nWIqlLesfrjkuPFTY=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7/go.mod h1:x0nZssQ3qZSnIcePWLvcoFisRXJzcTVvYpAAdYX8+GI=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.13 h1:JRaIgADQS/U6uXDqlPiefP32yXTda7Kqfx+LgspooZM=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.13/go.mod h1:CEuVn5WqOMilYl+tbccq8+N2ieCy0gVn3OtRb0vBNNM=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.21 h1:c31//R3xgIJMSC8S6hEVq+38DcvUlgFY0FM6mSI5oto=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.21/go.mod h1:r6+pf23ouCB718FUxaqzZdbpYFyDtehyZcmP5KL9FkA=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.21 h1:ZlvrNcHSFFWURB8avufQq9gFsheUgjVD9536obIknfM=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.21/go.mod h1:cv3TNhVrssKR0O/xxLJVRfd2oazSnZnkUeTf6ctUwfQ=
github.com/aws/aws-sdk-go-v2/service/s3 v1.99.0 h1:hlSuz394kV0vhv9drL5lhuEFbEOEP1VyQpy15qWh1Pk=
github.com/aws/aws-sdk-go-v2/service/s3 v1.99.0/go.mod h1:uoA43SdFwacedBfSgfFSjjCvYe8aYBS7EnU5GZ/YKMM=
github.com/aws/aws-sdk-go-v2/service/signin v1.0.9 h1:QKZH0S178gCmFEgst8hN0mCX1KxLgHBKKY/CLqwP8lg=
github.com/aws/aws-sdk-go-v2/service/signin v1.0.9/go.mod h1:7yuQJoT+OoH8aqIxw9vwF+8KpvLZ8AWmvmUWHsGQZvI=
github.com/aws/aws-sdk-go-v2/service/sso v1.30.15 h1:lFd1+ZSEYJZYvv9d6kXzhkZu07si3f+GQ1AaYwa2LUM=
github.com/aws/aws-sdk-go-v2/service/sso v1.30.15/go.mod h1:WSvS1NLr7JaPunCXqpJnWk1Bjo7IxzZXrZi1QQCkuqM=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.19 h1:dzztQ1YmfPrxdrOiuZRMF6fuOwWlWpD2StNLTceKpys=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.19/go.mod h1:YO8TrYtFdl5w/4vmjL8zaBSsiNp3w0L1FfKVKenZT7w=
github.com/aws/aws-sdk-go-v2/service/sts v1.41.10 h1:p8ogvvLugcR/zLBXTXrTkj0RYBUdErbMnAFFp12Lm/U=
github.com/aws/aws-sdk-go-v2/service/sts v1.41.10/go.mod h1:60dv0eZJfeVXfbT1tFJinbHrDfSJ2GZl4Q//OSSNAVw=
github.com/aws/smithy-go v1.24.3 h1:XgOAaUgx+HhVBoP4v8n6HCQoTRDhoMghKqw4LNHsDNg=
github.com/aws/smithy-go v1.24.3/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
github.com/aws/aws-sdk-go-v2 v1.41.6 h1:1AX0AthnBQzMx1vbmir3Y4WsnJgiydmnJjiLu+LvXOg=
github.com/aws/aws-sdk-go-v2 v1.41.6/go.mod h1:dy0UzBIfwSeot4grGvY1AqFWN5zgziMmWGzysDnHFcQ=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.9 h1:adBsCIIpLbLmYnkQU+nAChU5yhVTvu5PerROm+/Kq2A=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.9/go.mod h1:uOYhgfgThm/ZyAuJGNQ5YgNyOlYfqnGpTHXvk3cpykg=
github.com/aws/aws-sdk-go-v2/config v1.32.16 h1:Q0iQ7quUgJP0F/SCRTieScnaMdXr9h/2+wze1u3cNeM=
github.com/aws/aws-sdk-go-v2/config v1.32.16/go.mod h1:duCCnJEFqpt2RC6no1iK6q+8HpwOAkiUua0pY507dQc=
github.com/aws/aws-sdk-go-v2/credentials v1.19.15 h1:fyvgWTszojq8hEnMi8PPBTvZdTtEVmAVyo+NFLHBhH4=
github.com/aws/aws-sdk-go-v2/credentials v1.19.15/go.mod h1:gJiYyMOjNg8OEdRWOf3CrFQxM2a98qmrtjx1zuiQfB8=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.22 h1:IOGsJ1xVWhsi+ZO7/NW8OuZZBtMJLZbk4P5HDjJO0jQ=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.22/go.mod h1:b+hYdbU+jGKfXE8kKM6g1+h+L/Go3vMvzlxBsiuGsxg=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.22 h1:GmLa5Kw1ESqtFpXsx5MmC84QWa/ZrLZvlJGa2y+4kcQ=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.22/go.mod h1:6sW9iWm9DK9YRpRGga/qzrzNLgKpT2cIxb7Vo2eNOp0=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.22 h1:dY4kWZiSaXIzxnKlj17nHnBcXXBfac6UlsAx2qL6XrU=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.22/go.mod h1:KIpEUx0JuRZLO7U6cbV204cWAEco2iC3l061IxlwLtI=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.23 h1:FPXsW9+gMuIeKmz7j6ENWcWtBGTe1kH8r9thNt5Uxx4=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.23/go.mod h1:7J8iGMdRKk6lw2C+cMIphgAnT8uTwBwNOsGkyOCm80U=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.8 h1:HtOTYcbVcGABLOVuPYaIihj6IlkqubBwFj10K5fxRek=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.8/go.mod h1:VsK9abqQeGlzPgUr+isNWzPlK2vKe9INMLWnY65f5Xs=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.14 h1:xnvDEnw+pnj5mctWiYuFbigrEzSm35x7k4KS/ZkCANg=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.14/go.mod h1:yS5rNogD8e0Wu9+l3MUwr6eENBzEeGejvINpN5PAYfY=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.22 h1:PUmZeJU6Y1Lbvt9WFuJ0ugUK2xn6hIWUBBbKuOWF30s=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.22/go.mod h1:nO6egFBoAaoXze24a2C0NjQCvdpk8OueRoYimvEB9jo=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.22 h1:SE+aQ4DEqG53RRCAIHlCf//B2ycxGH7jFkpnAh/kKPM=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.22/go.mod h1:ES3ynECd7fYeJIL6+oax+uIEljmfps0S70BaQzbMd/o=
github.com/aws/aws-sdk-go-v2/service/s3 v1.99.1 h1:kU/eBN5+MWNo/LcbNa4hWDdN76hdcd7hocU5kvu7IsU=
github.com/aws/aws-sdk-go-v2/service/s3 v1.99.1/go.mod h1:Fw9aqhJicIVee1VytBBjH+l+5ov6/PhbtIK/u3rt/ls=
github.com/aws/aws-sdk-go-v2/service/signin v1.0.10 h1:a1Fq/KXn75wSzoJaPQTgZO0wHGqE9mjFnylnqEPTchA=
github.com/aws/aws-sdk-go-v2/service/signin v1.0.10/go.mod h1:p6+MXNxW7IA6dMgHfTAzljuwSKD0NCm/4lbS4t6+7vI=
github.com/aws/aws-sdk-go-v2/service/sso v1.30.16 h1:x6bKbmDhsgSZwv6q19wY/u3rLk/3FGjJWyqKcIRufpE=
github.com/aws/aws-sdk-go-v2/service/sso v1.30.16/go.mod h1:CudnEVKRtLn0+3uMV0yEXZ+YZOKnAtUJ5DmDhilVnIw=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.20 h1:oK/njaL8GtyEihkWMD4k3VgHCT64RQKkZwh0DG5j8ak=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.20/go.mod h1:JHs8/y1f3zY7U5WcuzoJ/yAYGYtNIVPKLIbp61euvmg=
github.com/aws/aws-sdk-go-v2/service/sts v1.42.0 h1:ks8KBcZPh3PYISr5dAiXCM5/Thcuxk8l+PG4+A0exds=
github.com/aws/aws-sdk-go-v2/service/sts v1.42.0/go.mod h1:pFw33T0WLvXU3rw1WBkpMlkgIn54eCB5FYLhjDc9Foo=
github.com/aws/smithy-go v1.25.0 h1:Sz/XJ64rwuiKtB6j98nDIPyYrV1nVNJ4YU74gttcl5U=
github.com/aws/smithy-go v1.25.0/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=
@@ -101,8 +99,8 @@ github.com/emersion/go-smtp v0.24.0 h1:g6AfoF140mvW0vLNPD/LuCBLEAdlxOjIXqbIkJIS6
github.com/emersion/go-smtp v0.24.0/go.mod h1:ZtRRkbTyp2XTHCA+BmyTFTrj8xY4I+b4McvHxCU2gsQ=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/8M=
github.com/fsnotify/fsnotify v1.8.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/fxamacker/cbor/v2 v2.9.1 h1:2rWm8B193Ll4VdjsJY28jxs70IdDsHRWgQYAI80+rMQ=
github.com/fxamacker/cbor/v2 v2.9.1/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM=
@@ -119,8 +117,8 @@ github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GM
github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ=
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 h1:BP4M0CvQ4S3TGls2FvczZtj5Re/2ZzkV9VwqPHH/3Bo=
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0=
github.com/go-co-op/gocron/v2 v2.20.0 h1:9IMrnnVSWjfSh3E54gWmWCHbloQJLh6f9+nwyKfLNpc=
github.com/go-co-op/gocron/v2 v2.20.0/go.mod h1:5lEiCKk1oVJV39Zg7/YG10OnaVrDAV5GGR6O0663k6U=
github.com/go-co-op/gocron/v2 v2.21.0 h1:e1nt9AEFglarRH9/9y9q0V5sblwxlknpHPjttEajrwQ=
github.com/go-co-op/gocron/v2 v2.21.0/go.mod h1:5lEiCKk1oVJV39Zg7/YG10OnaVrDAV5GGR6O0663k6U=
github.com/go-ldap/ldap/v3 v3.4.13 h1:+x1nG9h+MZN7h/lUi5Q3UZ0fJ1GyDQYbPvbuH38baDQ=
github.com/go-ldap/ldap/v3 v3.4.13/go.mod h1:LxsGZV6vbaK0sIvYfsv47rfh4ca0JXokCoKjZxsszv0=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
@@ -138,8 +136,8 @@ github.com/go-playground/validator/v10 v10.30.2 h1:JiFIMtSSHb2/XBUbWM4i/MpeQm9ZK
github.com/go-playground/validator/v10 v10.30.2/go.mod h1:mAf2pIOVXjTEBrwUMGKkCWKKPs9NheYGabeB04txQSc=
github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro=
github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/go-webauthn/webauthn v0.16.4 h1:R9jqR/cYZa7hRquFF7Za/8qoH/K/TIs1/Q/4CyGN+1Q=
github.com/go-webauthn/webauthn v0.16.4/go.mod h1:SU2ljAgToTV/YLPI0C05QS4qn+e04WpB5g1RMfcZfS4=
github.com/go-webauthn/webauthn v0.16.5 h1:x+vADHlaiIjta23kGhtwyCIlB5mayKx6SBlpwQ5NF9A=
github.com/go-webauthn/webauthn v0.16.5/go.mod h1:mQC6L0lZ5Kiu35G70zeB2WnrW4+vbHjR8Koq4HdVaMg=
github.com/go-webauthn/x v0.2.3 h1:8oArS+Rc1SWFLXhE17KZNx258Z4kUSyaDgsSncCO5RA=
github.com/go-webauthn/x v0.2.3/go.mod h1:tM04GF3V6VYq79AZMl7vbj4q6pz9r7L2criWRzbWhPk=
github.com/goccy/go-json v0.10.6 h1:p8HrPJzOakx/mn/bQtjgNjdTcN+/S6FcG2CTtQOrHVU=
@@ -237,8 +235,8 @@ github.com/lestrrat-go/httpcc v1.0.1 h1:ydWCStUeJLkpYyjLDHihupbn2tYmZ7m22BGkcvZZ
github.com/lestrrat-go/httpcc v1.0.1/go.mod h1:qiltp3Mt56+55GPVCbTdM9MlqhvzyuL6W/NMDA8vA5E=
github.com/lestrrat-go/httprc/v3 v3.0.5 h1:S+Mb4L2I+bM6JGTibLmxExhyTOqnXjqx+zi9MoXw/TM=
github.com/lestrrat-go/httprc/v3 v3.0.5/go.mod h1:mSMtkZW92Z98M5YoNNztbRGxbXHql7tSitCvaxvo9l0=
github.com/lestrrat-go/jwx/v3 v3.0.13 h1:AdHKiPIYeCSnOJtvdpipPg/0SuFh9rdkN+HF3O0VdSk=
github.com/lestrrat-go/jwx/v3 v3.0.13/go.mod h1:2m0PV1A9tM4b/jVLMx8rh6rBl7F6WGb3EG2hufN9OQU=
github.com/lestrrat-go/jwx/v3 v3.1.0 h1:AyyLtxc0QM75F75JroWgt1phwC7X+wOb3XKhH7XBZWw=
github.com/lestrrat-go/jwx/v3 v3.1.0/go.mod h1:uw/MN2M/Xiu4FhwcIwH11Zsh9JWx9SWzgALl7/uIEkU=
github.com/lestrrat-go/option/v2 v2.0.0 h1:XxrcaJESE1fokHy3FpaQ/cXW8ZsIdWcdFzzLOcID3Ss=
github.com/lestrrat-go/option/v2 v2.0.0/go.mod h1:oSySsmzMoR0iRzCDCaUfsCzxQHUEuhOViQObyy7S6Vg=
github.com/lib/pq v1.12.3 h1:tTWxr2YLKwIvK90ZXEw8GP7UFHtcbTtty8zsI+YjrfQ=
@@ -325,8 +323,8 @@ github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXl
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/tinylib/msgp v1.6.3 h1:bCSxiTz386UTgyT1i0MSCvdbWjVW+8sG3PjkGsZQt4s=
github.com/tinylib/msgp v1.6.3/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA=
github.com/tinylib/msgp v1.6.4 h1:mOwYbyYDLPj35mkA2BjjYejgJk9BuHxDdvRnb6v2ZcQ=
github.com/tinylib/msgp v1.6.4/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=

View File

@@ -40,7 +40,10 @@ func initRouter(db *gorm.DB, svc *services) (utils.Service, error) {
if err != nil {
return nil, err
}
registerRoutes(r, db, svc)
err = registerRoutes(r, db, svc)
if err != nil {
return nil, err
}
serverConfig, err := initServer(r)
if err != nil {
@@ -70,15 +73,6 @@ func initEngine() (*gin.Engine, error) {
configureEngine(r)
registerGlobalMiddleware(r)
frontendRateLimitMiddleware := middleware.NewRateLimitMiddleware().Add(rate.Every(100*time.Millisecond), 300)
if err := frontend.RegisterFrontend(r, frontendRateLimitMiddleware); err != nil {
if errors.Is(err, frontend.ErrFrontendNotIncluded) {
slog.Warn("Frontend is not included in the build. Skipping frontend registration.")
return r, nil
}
return nil, fmt.Errorf("failed to register frontend: %w", err)
}
return r, nil
}
@@ -116,7 +110,16 @@ func registerGlobalMiddleware(r *gin.Engine) {
r.Use(middleware.NewErrorHandlerMiddleware().Add())
}
func registerRoutes(r *gin.Engine, db *gorm.DB, svc *services) {
func registerRoutes(r *gin.Engine, db *gorm.DB, svc *services) error {
err := frontend.RegisterFrontend(r, svc.oidcService)
if errors.Is(err, frontend.ErrFrontendNotIncluded) {
slog.Warn("Frontend is not included in the build. Skipping frontend registration.")
} else if err != nil {
return fmt.Errorf("failed to register frontend: %w", err)
}
// Initialize middleware for specific routes
authMiddleware := middleware.NewAuthMiddleware(svc.apiKeyService, svc.userService, svc.jwtService)
fileSizeLimitMiddleware := middleware.NewFileSizeLimitMiddleware()
apiRateLimitMiddleware := middleware.NewRateLimitMiddleware().Add(rate.Every(time.Second), 100)
@@ -142,6 +145,8 @@ func registerRoutes(r *gin.Engine, db *gorm.DB, svc *services) {
// These are not rate-limited.
controller.NewHealthzController(r)
return nil
}
func registerTestRoutes(apiGroup *gin.RouterGroup, db *gorm.DB, svc *services) {

View File

@@ -60,7 +60,7 @@ type EnvConfigSchema struct {
S3Region string `env:"S3_REGION"`
S3Endpoint string `env:"S3_ENDPOINT"`
S3AccessKeyID string `env:"S3_ACCESS_KEY_ID"`
S3SecretAccessKey string `env:"S3_SECRET_ACCESS_KEY"`
S3SecretAccessKey string `env:"S3_SECRET_ACCESS_KEY" options:"file"`
S3ForcePathStyle bool `env:"S3_FORCE_PATH_STYLE"`
S3DisableDefaultIntegrityChecks bool `env:"S3_DISABLE_DEFAULT_INTEGRITY_CHECKS"`

View File

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

View File

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

View File

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

View File

@@ -72,6 +72,7 @@ type AuthorizeOidcClientRequestDto struct {
CodeChallengeMethod string `json:"codeChallengeMethod"`
ReauthenticationToken string `json:"reauthenticationToken"`
Prompt string `json:"prompt"`
ResponseMode string `json:"responseMode" binding:"omitempty,response_mode"`
}
type AuthorizeOidcClientResponseDto struct {

View File

@@ -47,6 +47,9 @@ func init() {
"callback_url_pattern": func(fl validator.FieldLevel) bool {
return ValidateCallbackURLPattern(fl.Field().String())
},
"response_mode": func(fl validator.FieldLevel) bool {
return ValidateResponseMode(fl.Field().String())
},
}
for k, v := range validators {
err := engine.RegisterValidation(k, v)
@@ -87,3 +90,17 @@ func ValidateCallbackURLPattern(raw string) bool {
err := utils.ValidateCallbackURLPattern(raw)
return err == nil
}
// ValidateResponseMode validates response_mode parameter
// If responseMode is present, it must be "form_post" or "query"
// Empty responseMode is allowed (field not provided, use default)
func ValidateResponseMode(responseMode string) bool {
switch responseMode {
case "form_post", "query":
return true
case "":
return true
default:
return false
}
}

View File

@@ -58,6 +58,25 @@ func TestValidateClientID(t *testing.T) {
}
}
func TestValidateResponseMode(t *testing.T) {
tests := []struct {
name string
input string
expected bool
}{
{"valid form_post", "form_post", true},
{"valid query", "query", true},
{"valid empty", "", true},
{"invalid fragment", "fragment", false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
assert.Equal(t, tt.expected, ValidateResponseMode(tt.input))
})
}
}
func TestValidateCallbackURL(t *testing.T) {
tests := []struct {
name string

View File

@@ -3,6 +3,7 @@ package middleware
import (
"crypto/rand"
"encoding/base64"
"strings"
"github.com/gin-gonic/gin"
)
@@ -28,22 +29,39 @@ func (m *CspMiddleware) Add() gin.HandlerFunc {
// Generate a random base64 nonce for this request
nonce := generateNonce()
c.Set("csp_nonce", nonce)
c.Writer.Header().Set("Content-Security-Policy", BuildCSP(nonce))
csp := "default-src 'self'; " +
"base-uri 'self'; " +
"object-src 'none'; " +
"frame-ancestors 'none'; " +
"form-action 'self'; " +
"img-src * blob:;" +
"font-src 'self'; " +
"style-src 'self' 'unsafe-inline'; " +
"script-src 'self' 'nonce-" + nonce + "'"
c.Writer.Header().Set("Content-Security-Policy", csp)
c.Next()
}
}
func BuildCSP(nonce string, formActionExtra ...string) string {
formAction := "'self'"
if len(formActionExtra) > 0 {
b := strings.Builder{}
for _, extra := range formActionExtra {
if extra != "" {
b.WriteByte(' ')
b.WriteString(extra)
}
}
formAction += b.String()
}
return "default-src 'self'; " +
"base-uri 'self'; " +
"object-src 'none'; " +
"frame-ancestors 'none'; " +
"form-action " + formAction + "; " +
"img-src * blob:;" +
"font-src 'self'; " +
"style-src 'self' 'unsafe-inline'; " +
"script-src 'self' 'nonce-" + nonce + "'"
}
func generateNonce() string {
b := make([]byte, 16)
if _, err := rand.Read(b); err != nil {

View File

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

View File

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

View File

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

View File

@@ -151,22 +151,20 @@ func (s *OidcService) Authorize(ctx context.Context, input dto.AuthorizeOidcClie
// Parse prompt parameter (space-delimited list per OIDC spec)
promptValues := parsePromptParameter(input.Prompt)
hasPromptNone := contains(promptValues, "none")
hasPromptLogin := contains(promptValues, "login")
hasPromptConsent := contains(promptValues, "consent")
hasPromptSelectAccount := contains(promptValues, "select_account")
hasPromptNone := slices.Contains(promptValues, "none")
hasPromptLogin := slices.Contains(promptValues, "login")
hasPromptConsent := slices.Contains(promptValues, "consent")
hasPromptSelectAccount := slices.Contains(promptValues, "select_account")
// Validate prompt parameter conflicts early.
// Per OIDC Core §3.1.2.6, prompt=none must not be combined with any
// value that requires user interaction.
if hasPromptNone && (hasPromptConsent || hasPromptLogin || hasPromptSelectAccount) {
return "", "", &common.OidcInteractionRequiredError{}
return "", "", common.NewOidcInvalidRequestError("prompt type 'none' cannot be combined with others")
}
// Handle prompt=select_account early (not supported)
if hasPromptSelectAccount {
return "", "", &common.OidcInteractionRequiredError{}
}
// prompt=select_account is handled entirely in the UI
// Pocket ID holds one session per browser, so the frontend renders the current user as the sole selectable account and then calls Authorize normally.
// If prompt=login is specified or the client requires reauthentication, check the reauthentication token
if hasPromptLogin || client.RequiresReauthentication {
@@ -219,14 +217,24 @@ func (s *OidcService) Authorize(ctx context.Context, input dto.AuthorizeOidcClie
// Log the authorization event
if hasAlreadyAuthorizedClient {
s.auditLogService.Create(ctx, model.AuditLogEventClientAuthorization, ipAddress, userAgent, userID, model.AuditLogData{"clientName": client.Name}, tx)
s.auditLogService.Create(
ctx, model.AuditLogEventClientAuthorization,
ipAddress, userAgent, userID,
model.AuditLogData{"clientName": client.Name},
tx,
)
} else {
s.auditLogService.Create(ctx, model.AuditLogEventNewClientAuthorization, ipAddress, userAgent, userID, model.AuditLogData{"clientName": client.Name}, tx)
s.auditLogService.Create(
ctx, model.AuditLogEventNewClientAuthorization,
ipAddress, userAgent, userID,
model.AuditLogData{"clientName": client.Name},
tx,
)
}
err = tx.Commit().Error
if err != nil {
return "", "", err
return "", "", fmt.Errorf("failed to commit transaction: %w", err)
}
return code, callbackURL, nil
@@ -754,6 +762,27 @@ func (s *OidcService) GetClient(ctx context.Context, clientID string) (model.Oid
return s.getClientInternal(ctx, clientID, s.db, false)
}
func (s *OidcService) ResolveAllowedCallbackURL(ctx context.Context, clientID, inputCallbackURL string) (string, error) {
client, err := s.GetClient(ctx, clientID)
if err != nil {
return "", err
}
if inputCallbackURL == "" || len(client.CallbackURLs) == 0 {
return "", &common.OidcMissingCallbackURLError{}
}
matched, err := utils.GetCallbackURLFromList(client.CallbackURLs, inputCallbackURL)
if err != nil {
return "", err
}
if matched == "" {
return "", &common.OidcInvalidCallbackURLError{}
}
return matched, nil
}
func (s *OidcService) getClientInternal(ctx context.Context, clientID string, tx *gorm.DB, forUpdate bool) (model.OidcClient, error) {
var client model.OidcClient
q := tx.
@@ -1779,14 +1808,18 @@ func (s *OidcService) jwkSetForURL(ctx context.Context, url string) (set jwk.Set
// We set a timeout because otherwise Register will keep trying in case of errors
registerCtx, registerCancel := context.WithTimeout(ctx, 15*time.Second)
defer registerCancel()
// We need to register the URL
err = s.jwkCache.Register(
registerCtx,
url,
jwk.WithMaxInterval(24*time.Hour),
jwk.WithMinInterval(15*time.Minute),
registerOptions := []jwk.RegisterOption{
jwk.WithMaxInterval(24 * time.Hour),
jwk.WithMinInterval(15 * time.Minute),
jwk.WithWaitReady(true),
)
}
if s.httpClient != nil {
registerOptions = append(registerOptions, jwk.WithHTTPClient(s.httpClient))
}
// We need to register the URL
err = s.jwkCache.Register(registerCtx, url, registerOptions...)
// In case of race conditions (two goroutines calling jwkCache.Register at the same time), it's possible we can get a conflict anyways, so we ignore that error
if err != nil && !errors.Is(err, httprc.ErrResourceAlreadyExists()) {
return nil, fmt.Errorf("failed to register JWK set: %w", err)
@@ -2194,13 +2227,3 @@ func parsePromptParameter(prompt string) []string {
}
return strings.Fields(prompt)
}
// contains checks if a string slice contains a specific value
func contains(slice []string, value string) bool {
for _, item := range slice {
if item == value {
return true
}
}
return false
}

View File

@@ -10,6 +10,7 @@ import (
"encoding/json"
"io"
"net/http"
"slices"
"strconv"
"strings"
"testing"
@@ -895,6 +896,8 @@ func TestOidcService_updateClientLogoType(t *testing.T) {
}
func TestOidcService_downloadAndSaveLogoFromURL(t *testing.T) {
const publicLogoHost = "https://8.8.8.8"
// Create a test database
db := testutils.NewDatabaseForTest(t)
@@ -940,7 +943,7 @@ func TestOidcService_downloadAndSaveLogoFromURL(t *testing.T) {
// Create a mock HTTP client with responses
mockResponses := map[string]*http.Response{
//nolint:bodyclose
"https://example.com/logo.png": pngResponse,
publicLogoHost + "/logo.png": pngResponse,
}
httpClient := &http.Client{
Transport: &testutils.MockRoundTripper{
@@ -956,7 +959,7 @@ func TestOidcService_downloadAndSaveLogoFromURL(t *testing.T) {
}
// Download and save the logo
err := s.downloadAndSaveLogoFromURL(t.Context(), client.ID, "https://example.com/logo.png", true)
err := s.downloadAndSaveLogoFromURL(t.Context(), client.ID, publicLogoHost+"/logo.png", true)
require.NoError(t, err)
// Verify the file was saved
@@ -985,7 +988,7 @@ func TestOidcService_downloadAndSaveLogoFromURL(t *testing.T) {
mockResponses := map[string]*http.Response{
//nolint:bodyclose
"https://example.com/dark-logo.webp": webpResponse,
publicLogoHost + "/dark-logo.webp": webpResponse,
}
httpClient := &http.Client{
Transport: &testutils.MockRoundTripper{
@@ -1000,7 +1003,7 @@ func TestOidcService_downloadAndSaveLogoFromURL(t *testing.T) {
}
// Download and save the dark logo
err := s.downloadAndSaveLogoFromURL(t.Context(), client.ID, "https://example.com/dark-logo.webp", false)
err := s.downloadAndSaveLogoFromURL(t.Context(), client.ID, publicLogoHost+"/dark-logo.webp", false)
require.NoError(t, err)
// Verify the dark logo file was saved
@@ -1024,7 +1027,7 @@ func TestOidcService_downloadAndSaveLogoFromURL(t *testing.T) {
mockResponses := map[string]*http.Response{
//nolint:bodyclose
"https://example.com/icon.svg": testutils.NewMockResponse(http.StatusOK, string(svgContent)),
publicLogoHost + "/icon.svg": testutils.NewMockResponse(http.StatusOK, string(svgContent)),
}
httpClient := &http.Client{
Transport: &testutils.MockRoundTripper{
@@ -1038,7 +1041,7 @@ func TestOidcService_downloadAndSaveLogoFromURL(t *testing.T) {
httpClient: httpClient,
}
err := s.downloadAndSaveLogoFromURL(t.Context(), client.ID, "https://example.com/icon.svg", true)
err := s.downloadAndSaveLogoFromURL(t.Context(), client.ID, publicLogoHost+"/icon.svg", true)
require.NoError(t, err)
// Verify SVG file was saved
@@ -1055,7 +1058,7 @@ func TestOidcService_downloadAndSaveLogoFromURL(t *testing.T) {
mockResponses := map[string]*http.Response{
//nolint:bodyclose
"https://example.com/logo": jpgResponse,
publicLogoHost + "/logo": jpgResponse,
}
httpClient := &http.Client{
Transport: &testutils.MockRoundTripper{
@@ -1069,7 +1072,7 @@ func TestOidcService_downloadAndSaveLogoFromURL(t *testing.T) {
httpClient: httpClient,
}
err := s.downloadAndSaveLogoFromURL(t.Context(), client.ID, "https://example.com/logo", true)
err := s.downloadAndSaveLogoFromURL(t.Context(), client.ID, publicLogoHost+"/logo", true)
require.NoError(t, err)
// Verify JPG file was saved (jpeg extension is normalized to jpg)
@@ -1091,7 +1094,7 @@ func TestOidcService_downloadAndSaveLogoFromURL(t *testing.T) {
t.Run("Returns error for non-200 status code", func(t *testing.T) {
mockResponses := map[string]*http.Response{
//nolint:bodyclose
"https://example.com/not-found.png": testutils.NewMockResponse(http.StatusNotFound, "Not Found"),
publicLogoHost + "/not-found.png": testutils.NewMockResponse(http.StatusNotFound, "Not Found"),
}
httpClient := &http.Client{
Transport: &testutils.MockRoundTripper{
@@ -1105,7 +1108,7 @@ func TestOidcService_downloadAndSaveLogoFromURL(t *testing.T) {
httpClient: httpClient,
}
err := s.downloadAndSaveLogoFromURL(t.Context(), client.ID, "https://example.com/not-found.png", true)
err := s.downloadAndSaveLogoFromURL(t.Context(), client.ID, publicLogoHost+"/not-found.png", true)
require.Error(t, err)
require.ErrorContains(t, err, "failed to fetch logo")
})
@@ -1121,7 +1124,7 @@ func TestOidcService_downloadAndSaveLogoFromURL(t *testing.T) {
mockResponses := map[string]*http.Response{
//nolint:bodyclose
"https://example.com/large.png": largeResponse,
publicLogoHost + "/large.png": largeResponse,
}
httpClient := &http.Client{
Transport: &testutils.MockRoundTripper{
@@ -1135,7 +1138,7 @@ func TestOidcService_downloadAndSaveLogoFromURL(t *testing.T) {
httpClient: httpClient,
}
err := s.downloadAndSaveLogoFromURL(t.Context(), client.ID, "https://example.com/large.png", true)
err := s.downloadAndSaveLogoFromURL(t.Context(), client.ID, publicLogoHost+"/large.png", true)
require.Error(t, err)
require.ErrorIs(t, err, errLogoTooLarge)
})
@@ -1147,7 +1150,7 @@ func TestOidcService_downloadAndSaveLogoFromURL(t *testing.T) {
mockResponses := map[string]*http.Response{
//nolint:bodyclose
"https://example.com/file.txt": textResponse,
publicLogoHost + "/file.txt": textResponse,
}
httpClient := &http.Client{
Transport: &testutils.MockRoundTripper{
@@ -1161,7 +1164,7 @@ func TestOidcService_downloadAndSaveLogoFromURL(t *testing.T) {
httpClient: httpClient,
}
err := s.downloadAndSaveLogoFromURL(t.Context(), client.ID, "https://example.com/file.txt", true)
err := s.downloadAndSaveLogoFromURL(t.Context(), client.ID, publicLogoHost+"/file.txt", true)
require.Error(t, err)
var fileTypeErr *common.FileTypeNotSupportedError
require.ErrorAs(t, err, &fileTypeErr)
@@ -1174,7 +1177,7 @@ func TestOidcService_downloadAndSaveLogoFromURL(t *testing.T) {
mockResponses := map[string]*http.Response{
//nolint:bodyclose
"https://example.com/logo.png": pngResponse,
publicLogoHost + "/logo.png": pngResponse,
}
httpClient := &http.Client{
Transport: &testutils.MockRoundTripper{
@@ -1188,7 +1191,7 @@ func TestOidcService_downloadAndSaveLogoFromURL(t *testing.T) {
httpClient: httpClient,
}
err := s.downloadAndSaveLogoFromURL(t.Context(), "non-existent-client-id", "https://example.com/logo.png", true)
err := s.downloadAndSaveLogoFromURL(t.Context(), "non-existent-client-id", publicLogoHost+"/logo.png", true)
require.Error(t, err)
require.ErrorContains(t, err, "failed to look up client")
})
@@ -1236,30 +1239,13 @@ func TestPromptParameterConflicts(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
values := parsePromptParameter(tt.prompt)
hasNone := contains(values, "none")
hasConsent := contains(values, "consent")
hasLogin := contains(values, "login")
hasSelectAccount := contains(values, "select_account")
hasNone := slices.Contains(values, "none")
hasConsent := slices.Contains(values, "consent")
hasLogin := slices.Contains(values, "login")
hasSelectAccount := slices.Contains(values, "select_account")
conflict := hasNone && (hasConsent || hasLogin || hasSelectAccount)
assert.Equal(t, tt.expectConflict, conflict)
})
}
}
func TestContains(t *testing.T) {
t.Run("finds value in slice", func(t *testing.T) {
slice := []string{"none", "login", "consent"}
assert.True(t, contains(slice, "login"))
})
t.Run("returns false for missing value", func(t *testing.T) {
slice := []string{"none", "login"}
assert.False(t, contains(slice, "consent"))
})
t.Run("returns false for empty slice", func(t *testing.T) {
slice := []string{}
assert.False(t, contains(slice, "none"))
})
}

View File

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

1
depot.json Normal file
View File

@@ -0,0 +1 @@
{ "id": "c36t29j6bz" }

View File

@@ -1,3 +1,5 @@
# syntax=docker/dockerfile:1.7
# This file uses multi-stage builds to build the application from source, including the front-end
# Tags passed to "go build"
@@ -9,27 +11,33 @@ RUN corepack enable
WORKDIR /build
COPY pnpm-workspace.yaml pnpm-lock.yaml ./
COPY package.json pnpm-workspace.yaml pnpm-lock.yaml ./
COPY frontend/package.json ./frontend/
RUN pnpm --filter pocket-id-frontend install --frozen-lockfile
RUN --mount=type=cache,target=/root/.local/share/pnpm/store \
pnpm --filter pocket-id-frontend install --frozen-lockfile
COPY ./frontend ./frontend/
RUN BUILD_OUTPUT_PATH=dist pnpm --filter pocket-id-frontend run build
RUN --mount=type=cache,target=/build/frontend/node_modules/.vite \
--mount=type=cache,target=/build/frontend/.svelte-kit \
BUILD_OUTPUT_PATH=dist pnpm --filter pocket-id-frontend run build
# Stage 2: Build Backend
FROM golang:1.26-alpine AS backend-builder
ARG BUILD_TAGS
WORKDIR /build
COPY ./backend/go.mod ./backend/go.sum ./
RUN go mod download
RUN --mount=type=cache,target=/go/pkg/mod \
go mod download
COPY ./backend ./
COPY --from=frontend-builder /build/frontend/dist ./frontend/dist
COPY .version .version
WORKDIR /build/cmd
RUN VERSION=$(cat /build/.version) \
RUN --mount=type=cache,target=/go/pkg/mod \
--mount=type=cache,target=/root/.cache/go-build \
VERSION=$(cat /build/.version) && \
CGO_ENABLED=0 \
GOOS=linux \
go build \

View File

@@ -9,17 +9,17 @@
"export": "email export"
},
"dependencies": {
"@react-email/components": "1.0.1",
"@react-email/components": "1.0.12",
"react": "^19.2.5",
"react-dom": "^19.2.5",
"tailwind-merge": "^3.5.0"
},
"devDependencies": {
"@react-email/preview-server": "5.0.7",
"@types/node": "^24.12.2",
"@react-email/preview-server": "5.2.10",
"@types/node": "^25.6.0",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"react-email": "5.0.7",
"react-email": "6.0.0",
"tsx": "^4.21.0"
}
}

View File

@@ -53,6 +53,8 @@
"webauthn_not_supported_by_browser": "Les claus d'accés no són compatibles amb aquest navegador. Utilitza un mètode alternatiu per iniciar la sessió.",
"critical_error_occurred_contact_administrator": "S'ha produït un error crític. Contacta amb l'administrador.",
"sign_in_to": "Inicia la sessió a {name}",
"account_selection_signin_confirmation": "Vols utilitzar el compte següent per continuar a <b>{name}</b>?",
"use_a_different_account": "Utilitza un compte diferent",
"client_not_found": "Client no trobat",
"client_wants_to_access_the_following_information": "<b>{client}</b> vol accedir a la informació següent:",
"do_you_want_to_sign_in_to_client_with_your_app_name_account": "Vols iniciar la sessió a <b>{client}</b> amb el teu compte de {appName}?",
@@ -433,7 +435,7 @@
"active": "Actiu",
"usage": "Ús",
"created": "Creat",
"token": "Token",
"token": "Token\n",
"loading": "Carregant",
"delete_signup_token": "Elimina token d'alta",
"are_you_sure_you_want_to_delete_this_signup_token": "Segur que vols eliminar aquest token d'alta? Aquesta acció no es pot desfer.",

View File

@@ -53,6 +53,8 @@
"webauthn_not_supported_by_browser": "Tento prohlížeč nepodporuje přístupové klíče. Použijte prosím alternativní způsob přihlášení.",
"critical_error_occurred_contact_administrator": "Došlo k kritické chybě. Obraťte se na správce.",
"sign_in_to": "Přihlásit se k {name}",
"account_selection_signin_confirmation": "Chcete pokračovat pomocí následujícího účtu <b>{name}</b>?",
"use_a_different_account": "Použijte jiný účet",
"client_not_found": "Klient nebyl nalezen",
"client_wants_to_access_the_following_information": "<b>{client}</b> chce získat přístup k následujícím informacím:",
"do_you_want_to_sign_in_to_client_with_your_app_name_account": "Chcete se přihlásit do <b>{client}</b> s vaším {appName} účtem?",

View File

@@ -53,6 +53,8 @@
"webauthn_not_supported_by_browser": "Passkeys understøttes ikke af denne browser. Brug en alternativ login-metode.",
"critical_error_occurred_contact_administrator": "En kritisk fejl opstod. Kontakt venligst din administrator.",
"sign_in_to": "Log ind på {name}",
"account_selection_signin_confirmation": "Vil du bruge følgende konto til at fortsætte <b>{name}</b>?",
"use_a_different_account": "Brug en anden konto",
"client_not_found": "Klient ikke fundet",
"client_wants_to_access_the_following_information": "{client} ønsker at få adgang til følgende oplysninger:",
"do_you_want_to_sign_in_to_client_with_your_app_name_account": "Vil du logge ind på {client} med din {appName}-konto?",
@@ -70,7 +72,7 @@
"do_you_want_to_sign_out_of_pocketid_with_the_account": "Vil du logge ud af {appName} med kontoen <b>{username}</b>?",
"sign_in_to_appname": "Log ind på {appName}",
"please_try_to_sign_in_again": "Prøv at logge ind igen.",
"authenticate_with_passkey_to_access_account": "Bekræft din identitet med din adgangskode for at få adgang til din konto.",
"authenticate_with_passkey_to_access_account": "Bekræft din identitet med din adgangsnøgle for at få adgang til din konto.",
"authenticate": "Bekræft identitet",
"please_try_again": "Prøv venligst igen.",
"continue": "Fortsæt",
@@ -118,7 +120,7 @@
"account_details": "Kontooplysninger",
"passkeys": "Adgangsnøgler",
"manage_your_passkeys_that_you_can_use_to_authenticate_yourself": "Administrér dine adgangsnøgler, som du kan bruge til at godkende dig selv.",
"manage_this_users_passkeys": "Administrer denne brugers adgangskoder.",
"manage_this_users_passkeys": "Administrer denne brugers adgangsnøgler.",
"add_passkey": "Tilføj adgangsnøgle",
"create_a_one_time_login_code_to_sign_in_from_a_different_device_without_a_passkey": "Opret en engangskode for at logge ind fra en anden enhed uden en adgangsnøgle.",
"create": "Opret",
@@ -343,7 +345,7 @@
"device_code_authorization": "Autorisation af enhedskode",
"new_device_code_authorization": "Autorisation af ny enhedskode",
"passkey_added": "Passkey tilføjet",
"passkey_removed": "Adgangskode fjernet",
"passkey_removed": "Adgangsnøgle fjernet",
"disable_animations": "Deaktiver animationer",
"turn_off_ui_animations": "Slå animationer fra for hele brugergrænsefladen.",
"user_disabled": "Konto deaktiveret",
@@ -415,9 +417,9 @@
"go_to_login": "Gå til login",
"signup_to_appname": "Tilmeld dig {appName}",
"create_your_account_to_get_started": "Opret din konto for at komme i gang.",
"initial_account_creation_description": "Opret din konto for at komme i gang. Du kan oprette en adgangskode senere.",
"setup_your_passkey": "Opret din adgangskode",
"create_a_passkey_to_securely_access_your_account": "Opret en adgangskode for at få sikker adgang til din konto. Dette bliver din primære måde at logge ind på.",
"initial_account_creation_description": "Opret din konto for at komme i gang. Du kan oprette en adgangsnøgle senere.",
"setup_your_passkey": "Opret din adgangsnøgle",
"create_a_passkey_to_securely_access_your_account": "Opret en adgangsnøgle for at få sikker adgang til din konto. Dette bliver din primære måde at logge ind på.",
"skip_for_now": "Spring over for nu",
"account_created": "Konto oprettet",
"enable_user_signups": "Aktiver brugerregistrering",
@@ -443,7 +445,7 @@
"signup_open_description": "Alle kan oprette en ny konto uden begrænsninger.",
"of": "af",
"skip_passkey_setup": "Spring Passkey-opsætning over",
"skip_passkey_setup_description": "Det anbefales stærkt at oprette en adgangskode, da du ellers bliver låst ude af din konto, så snart sessionen udløber.",
"skip_passkey_setup_description": "Det anbefales stærkt at oprette en adgangsnøgle, da du ellers bliver låst ude af din konto, så snart sessionen udløber.",
"my_apps": "Mine apps",
"no_apps_available": "Ingen apps tilgængelige",
"contact_your_administrator_for_app_access": "Kontakt din administrator for at få adgang til applikationer.",
@@ -524,5 +526,5 @@
"email_verification_sent": "Bekræftelses-e-mail sendt med succes.",
"emails_verified_by_default": "E-mails verificeret som standard",
"emails_verified_by_default_description": "Når denne funktion er aktiveret, vil brugernes e-mailadresser som standard blive markeret som verificerede ved tilmelding eller når deres e-mailadresse ændres.",
"user_has_no_passkeys_yet": "Denne bruger har endnu ingen adgangskoder."
"user_has_no_passkeys_yet": "Denne bruger har endnu ingen adgangsnøgler."
}

View File

@@ -53,6 +53,8 @@
"webauthn_not_supported_by_browser": "Passkeys werden von diesem Browser nicht unterstützt. Bitte probier eine andere Anmeldemethode aus.",
"critical_error_occurred_contact_administrator": "Ein kritischer Fehler ist aufgetreten. Bitte kontaktiere deinen Administrator.",
"sign_in_to": "Bei {name} anmelden",
"account_selection_signin_confirmation": "Möchtest du mit dem folgenden Konto fortfahren <b>{name}</b>?",
"use_a_different_account": "Verwende ein anderes Konto",
"client_not_found": "Client nicht gefunden",
"client_wants_to_access_the_following_information": "<b>{client}</b> möchte auf die folgenden Informationen zugreifen:",
"do_you_want_to_sign_in_to_client_with_your_app_name_account": "Möchtest du dich bei <b>{client}</b> mit deinem {appName} Konto anmelden?",

View File

@@ -53,6 +53,8 @@
"webauthn_not_supported_by_browser": "Passkeys are not supported by this browser. Please use an alternative sign in method.",
"critical_error_occurred_contact_administrator": "A critical error occurred. Please contact your administrator.",
"sign_in_to": "Sign in to {name}",
"account_selection_signin_confirmation": "Do you want to use the following account to continue to <b>{name}</b>?",
"use_a_different_account": "Use a different account",
"client_not_found": "Client not found",
"client_wants_to_access_the_following_information": "<b>{client}</b> wants to access the following information:",
"do_you_want_to_sign_in_to_client_with_your_app_name_account": "Do you want to sign in to <b>{client}</b> with your {appName} account?",

View File

@@ -53,6 +53,8 @@
"webauthn_not_supported_by_browser": "Este navegador no admite claves de acceso. Utiliza otro método para iniciar sesión.",
"critical_error_occurred_contact_administrator": "Ha ocurrido un error crítico. Por favor, contacte a su administrador.",
"sign_in_to": "Iniciar sesión en {name}",
"account_selection_signin_confirmation": "¿Quieres usar la siguiente cuenta para seguir <b>{name}</b>?",
"use_a_different_account": "Usa otra cuenta",
"client_not_found": "Cliente no encontrado",
"client_wants_to_access_the_following_information": "<b>{client}</b> quiere acceder a la siguiente información:",
"do_you_want_to_sign_in_to_client_with_your_app_name_account": "¿Quieres iniciar sesión en <b>{client}</b> con tu cuenta {appName}?",

View File

@@ -53,6 +53,8 @@
"webauthn_not_supported_by_browser": "Passkeys are not supported by this browser. Please use an alternative sign in method.",
"critical_error_occurred_contact_administrator": "A critical error occurred. Please contact your administrator.",
"sign_in_to": "Sign in to {name}",
"account_selection_signin_confirmation": "Kas soovite jätkata järgmise kontoga <b>{name}</b>?",
"use_a_different_account": "Kasuta teist kontot",
"client_not_found": "Client not found",
"client_wants_to_access_the_following_information": "<b>{client}</b> wants to access the following information:",
"do_you_want_to_sign_in_to_client_with_your_app_name_account": "Do you want to sign in to <b>{client}</b> with your {appName} account?",

View File

@@ -53,6 +53,8 @@
"webauthn_not_supported_by_browser": "Tämä selain ei tue salasanan sijaan käytettäviä tunnuksia. Käytä vaihtoehtoista kirjautumistapaa.",
"critical_error_occurred_contact_administrator": "Kriittinen virhe tapahtui. Ota yhteyttä järjestelmänvalvojaan.",
"sign_in_to": "Kirjaudu palveluun {name}",
"account_selection_signin_confirmation": "Haluatko jatkaa seuraavalla tilillä <b>{name}</b>?",
"use_a_different_account": "Käytä toista tiliä",
"client_not_found": "Asiakasta ei löydy",
"client_wants_to_access_the_following_information": "<b>{client}</b> haluaa käyttää seuraavia tietoja:",
"do_you_want_to_sign_in_to_client_with_your_app_name_account": "Haluatko kirjautua sisään palveluun <b>{client}</b> {appName} -tililläsi?",

View File

@@ -53,6 +53,8 @@
"webauthn_not_supported_by_browser": "Les clés d'accès ne sont pas prises en charge par ce navigateur. Essaie une autre méthode de connexion.",
"critical_error_occurred_contact_administrator": "Une erreur critique s'est produite. Veuillez contacter votre administrateur.",
"sign_in_to": "Connexion à {name}",
"account_selection_signin_confirmation": "Veux-tu utiliser le compte suivant pour continuer <b>{name}</b>?",
"use_a_different_account": "Utilise un autre compte",
"client_not_found": "Client introuvable",
"client_wants_to_access_the_following_information": "<b>{client}</b> souhaite accéder aux informations suivantes :",
"do_you_want_to_sign_in_to_client_with_your_app_name_account": "Voulez-vous vous connecter à <b>{client}</b> avec votre compte {appName}?",

View File

@@ -53,6 +53,8 @@
"webauthn_not_supported_by_browser": "Questo browser non supporta le passkey. Prova a usare un altro modo per accedere.",
"critical_error_occurred_contact_administrator": "Si è verificato un errore critico. Contatta il tuo amministratore.",
"sign_in_to": "Accedi a {name}",
"account_selection_signin_confirmation": "Vuoi usare il seguente account per continuare a <b>{name}</b>?",
"use_a_different_account": "Usa un altro account",
"client_not_found": "Client non trovato",
"client_wants_to_access_the_following_information": "<b>{client}</b> vuole accedere alle seguenti informazioni:",
"do_you_want_to_sign_in_to_client_with_your_app_name_account": "Vuoi accedere a <b>{client}</b> con il tuo account {appName}?",

View File

@@ -53,6 +53,8 @@
"webauthn_not_supported_by_browser": "このブラウザではパスキーはサポートされていません。別のサインイン方法をご利用ください。",
"critical_error_occurred_contact_administrator": "重大なエラーが発生しました。管理者にお問い合わせください。",
"sign_in_to": "{name} にサインイン",
"account_selection_signin_confirmation": "以下のアカウントを使用して続行しますか <b>{name}</b>",
"use_a_different_account": "別のアカウントを使用する",
"client_not_found": "クライアントが見つかりません",
"client_wants_to_access_the_following_information": "<b>{client}</b> は以下の情報にアクセスしようとしています:",
"do_you_want_to_sign_in_to_client_with_your_app_name_account": "{appName} アカウントで <b>{client}</b> にサインインしますか?",

View File

@@ -53,6 +53,8 @@
"webauthn_not_supported_by_browser": "이 브라우저에서는 패스키를 지원하지 않습니다. 다른 로그인 방법을 사용해 주세요.",
"critical_error_occurred_contact_administrator": "치명적인 오류가 발생했습니다. 관리자에게 연락해주세요.",
"sign_in_to": "{name}에 로그인",
"account_selection_signin_confirmation": "다음 계정으로 계속 진행하시겠습니까? <b>{name}</b>계속하시겠습니까?",
"use_a_different_account": "다른 계정을 사용하세요",
"client_not_found": "클라이언트를 찾을 수 없습니다",
"client_wants_to_access_the_following_information": "<b>{client}</b>이(가) 다음 정보에 접근하려고 합니다:",
"do_you_want_to_sign_in_to_client_with_your_app_name_account": "{appName} 계정으로 <b>{client}</b>에 로그인하시겠습니까?",

View File

@@ -53,6 +53,8 @@
"webauthn_not_supported_by_browser": "Šis pārlūks neatbalsta piekļuves atslēgas. Lūdzu, izmantojiet alternatīvu pierakstīšanās metodi.",
"critical_error_occurred_contact_administrator": "Radās kritiska kļūda. Lūdzu, sazinieties ar administratoru.",
"sign_in_to": "Pierakstīties {name}",
"account_selection_signin_confirmation": "Vai vēlaties izmantot šo kontu, lai turpinātu <b>{name}</b>?",
"use_a_different_account": "Izmanto citu kontu",
"client_not_found": "Klients nav atrasts",
"client_wants_to_access_the_following_information": "<b>{client}</b> vēlas piekļūt šādai informācijai:",
"do_you_want_to_sign_in_to_client_with_your_app_name_account": "Vai vēlaties pierakstīties <b>{client}</b> ar savu {appName} kontu?",

View File

@@ -53,6 +53,8 @@
"webauthn_not_supported_by_browser": "Passkeys worden niet ondersteund door deze browser. Probeer een andere manier om in te loggen.",
"critical_error_occurred_contact_administrator": "Er is een kritieke fout opgetreden. Neem contact op met de beheerder.",
"sign_in_to": "Meld je aan bij {name}",
"account_selection_signin_confirmation": "Wil je met het volgende account verdergaan <b>{name}</b>?",
"use_a_different_account": "Gebruik een ander account",
"client_not_found": "Client niet gevonden",
"client_wants_to_access_the_following_information": "<b>{client}</b> wil toegang tot de volgende informatie:",
"do_you_want_to_sign_in_to_client_with_your_app_name_account": "Wil je je aanmelden bij <b>{client}</b> met je {appName} account?",

View File

@@ -53,6 +53,8 @@
"webauthn_not_supported_by_browser": "Passkeys are not supported by this browser. Please use an alternative sign in method.",
"critical_error_occurred_contact_administrator": "A critical error occurred. Please contact your administrator.",
"sign_in_to": "Logg inn til {name}",
"account_selection_signin_confirmation": "Do you want to use the following account to continue to <b>{name}</b>?",
"use_a_different_account": "Use a different account",
"client_not_found": "Fant ikke klient",
"client_wants_to_access_the_following_information": "<b>{client}</b> ønsker tilgang til følgende informasjon:",
"do_you_want_to_sign_in_to_client_with_your_app_name_account": "Do you want to sign in to <b>{client}</b> with your {appName} account?",

View File

@@ -53,6 +53,8 @@
"webauthn_not_supported_by_browser": "Ta przeglądarka nie obsługuje kluczy dostępu. Proszę skorzystać z alternatywnej metody logowania.",
"critical_error_occurred_contact_administrator": "Wystąpił krytyczny błąd. Skontaktuj się z administratorem.",
"sign_in_to": "Zaloguj się do {name}",
"account_selection_signin_confirmation": "Czy chcesz użyć poniższego konta, aby kontynuować <b>{name}</b>?",
"use_a_different_account": "Zaloguj się na inne konto",
"client_not_found": "Nie znaleziono klienta",
"client_wants_to_access_the_following_information": "<b>{client}</b> chce uzyskać dostęp do następujących informacji:",
"do_you_want_to_sign_in_to_client_with_your_app_name_account": "Czy chcesz zalogować się do <b>{client}</b> używając konta {appName}?",

View File

@@ -53,6 +53,8 @@
"webauthn_not_supported_by_browser": "As chaves de acesso não são suportadas por este navegador. Por favor, use um método alternativo de login.",
"critical_error_occurred_contact_administrator": "Ocorreu um erro grave. Por favor, entre em contato com o administrador.",
"sign_in_to": "Entrar em {name}",
"account_selection_signin_confirmation": "Queres usar a seguinte conta para continuar <b>{name}</b>?",
"use_a_different_account": "Usa outra conta",
"client_not_found": "Cliente não encontrado",
"client_wants_to_access_the_following_information": "<b>{client}</b> quer acessar as seguintes informações:",
"do_you_want_to_sign_in_to_client_with_your_app_name_account": "Deseja entrar em <b>{client}</b> com a sua conta {appName}?",

View File

@@ -53,6 +53,8 @@
"webauthn_not_supported_by_browser": "Passkeys are not supported by this browser. Please use an alternative sign in method.",
"critical_error_occurred_contact_administrator": "A critical error occurred. Please contact your administrator.",
"sign_in_to": "Sign in to {name}",
"account_selection_signin_confirmation": "Queres usar a seguinte conta para continuar a <b>{name}</b>?",
"use_a_different_account": "Usa outra conta",
"client_not_found": "Client not found",
"client_wants_to_access_the_following_information": "<b>{client}</b> wants to access the following information:",
"do_you_want_to_sign_in_to_client_with_your_app_name_account": "Do you want to sign in to <b>{client}</b> with your {appName} account?",

View File

@@ -53,6 +53,8 @@
"webauthn_not_supported_by_browser": "Этот браузер не поддерживает ключи доступа. Попробуйте войти другим способом.",
"critical_error_occurred_contact_administrator": "Произошла критическая ошибка. Обратитесь к администратору.",
"sign_in_to": "Войти в {name}",
"account_selection_signin_confirmation": "Хочешь использовать следующую учетную запись, чтобы продолжить <b>{name}</b>?",
"use_a_different_account": "Воспользуйся другой учетной записью",
"client_not_found": "Клиент не найден",
"client_wants_to_access_the_following_information": "<b>{client}</b> запрашивает доступ к следующей информации:",
"do_you_want_to_sign_in_to_client_with_your_app_name_account": "Вы хотите войти в <b>{client}</b> с помощью вашей учетной записи {appName}?",
@@ -106,7 +108,7 @@
"ip_address": "IP-адрес",
"device": "Устройство",
"client": "Клиент",
"actor": "Актёр",
"actor": "Актор",
"unknown": "Неизвестно",
"account_details_updated_successfully": "Данные учетной записи успешно обновлены",
"profile_picture_updated_successfully": "Изображение профиля успешно обновлено. Обновление может занять несколько минут.",
@@ -118,7 +120,7 @@
"account_details": "Детали учетной записи",
"passkeys": "Ключи доступа",
"manage_your_passkeys_that_you_can_use_to_authenticate_yourself": "Управляйте своими ключами доступа, которые вы можете использовать для аутентификации.",
"manage_this_users_passkeys": "Управлять паролями этого пользователя.",
"manage_this_users_passkeys": "Управление ключами доступа этого пользователя.",
"add_passkey": "Добавить ключ доступа",
"create_a_one_time_login_code_to_sign_in_from_a_different_device_without_a_passkey": "Создайте одноразовый код входа, чтобы войти с другого устройства без ключа доступа.",
"create": "Создать",
@@ -367,7 +369,7 @@
"enter_code_displayed_in_previous_step": "Введите код, который был отображен на предыдущем шаге.",
"authorize": "Авторизовать",
"federated_client_credentials": "Федеративные учетные данные клиента",
"federated_client_credentials_description": "Федеративные учетные данные клиента позволяют аутентифицировать клиентов OIDC без необходимости управления долгосрочными секретами. Они используют токены JWT, выданные сторонними органами для утверждений клиента, например токены идентификации рабочей нагрузки.",
"federated_client_credentials_description": "Федеративные учетные данные клиента позволяют аутентифицировать OIDC-клиентов без необходимости управления долгоживущими секретами. Для подтверждения клиента (client assertions) они используют JWT-токены, выпущенные сторонними поставщиками, например, токены идентификации рабочих нагрузок.",
"add_federated_client_credential": "Добавить федеративные учетные данные клиента",
"add_another_federated_client_credential": "Добавить другие федеративные учетные данные клиента",
"oidc_allowed_group_count": "Число разрешенных групп",
@@ -524,5 +526,5 @@
"email_verification_sent": "Письмо с подтверждением успешно отправлено.",
"emails_verified_by_default": "Электронные почты подтверждены по умолчанию",
"emails_verified_by_default_description": "Если эта функция включена, адреса электронной почты пользователей будут по умолчанию помечаться как подтверждённые при регистрации или при смене адреса.",
"user_has_no_passkeys_yet": "У этого пользователя пока нет паролей."
"user_has_no_passkeys_yet": "У этого пользователя пока нет ключей доступа."
}

View File

@@ -53,6 +53,8 @@
"webauthn_not_supported_by_browser": "Passkeys stöds inte av denna webbläsare. Använd en alternativ inloggningsmetod.",
"critical_error_occurred_contact_administrator": "Ett kritiskt fel har inträffat. Kontakta din administratör.",
"sign_in_to": "Logga in på {name}",
"account_selection_signin_confirmation": "Vill du använda följande konto för att fortsätta till <b>{name}</b>?",
"use_a_different_account": "Använd ett annat konto",
"client_not_found": "Klienten hittades inte",
"client_wants_to_access_the_following_information": "<b>{client}</b> vill få åtkomst till följande information:",
"do_you_want_to_sign_in_to_client_with_your_app_name_account": "Vill du logga in på <b>{client}</b> med ditt {appName} -konto?",

View File

@@ -53,6 +53,8 @@
"webauthn_not_supported_by_browser": "Bu tarayıcıda geçiş anahtarları desteklenmemektedir. Lütfen alternatif bir oturum açma yöntemi kullanın.",
"critical_error_occurred_contact_administrator": "Kritik bir hata oluştu. Lütfen sistem yöneticinizle iletişime geçin.",
"sign_in_to": "{name} hesabına giriş yap",
"account_selection_signin_confirmation": "Devam etmek için aşağıdaki hesabı kullanmak ister misiniz <b>{name}</b>?",
"use_a_different_account": "Başka bir hesap kullanın",
"client_not_found": "İstemci bulunamadı",
"client_wants_to_access_the_following_information": "<b>{client}</b> aşağıdaki bilgilere erişmek istiyor:",
"do_you_want_to_sign_in_to_client_with_your_app_name_account": "{appName} hesabınla <b>{client}</b> uygulamasına giriş yapmak istiyor musun?",

View File

@@ -53,6 +53,8 @@
"webauthn_not_supported_by_browser": "Цей браузер не підтримує ключі доступу. Будь ласка, скористайтеся альтернативним методом входу.",
"critical_error_occurred_contact_administrator": "Виникла критична помилка. Будь ласка, зверніться до адміністратора.",
"sign_in_to": "Увійти в {name}",
"account_selection_signin_confirmation": "Ви хочете продовжити, використовуючи цей обліковий запис <b>{name}</b>?",
"use_a_different_account": "Використовуйте інший обліковий запис",
"client_not_found": "Клієнт не знайдений",
"client_wants_to_access_the_following_information": "<b>{client}</b> хоче отримати доступ до наступної інформації:",
"do_you_want_to_sign_in_to_client_with_your_app_name_account": "Бажаєте увійти до <b>{client}</b> за допомогою облікового запису {appName}?",

View File

@@ -53,6 +53,8 @@
"webauthn_not_supported_by_browser": "Chìa khóa truy cập không được hỗ trợ bởi trình duyệt này. Vui lòng sử dụng phương thức đăng nhập thay thế.",
"critical_error_occurred_contact_administrator": "Đã xảy ra lỗi nghiêm trọng. Vui lòng liên hệ với quản trị viên.",
"sign_in_to": "Đăng nhập {name}",
"account_selection_signin_confirmation": "Bạn có muốn sử dụng tài khoản sau đây để tiếp tục <b>{name}</b>?",
"use_a_different_account": "Sử dụng tài khoản khác",
"client_not_found": "Không tìm thấy client.",
"client_wants_to_access_the_following_information": "<b>{client}</b> muốn truy cập các thông tin sau:",
"do_you_want_to_sign_in_to_client_with_your_app_name_account": "Bạn có muốn đăng nhập vào <b>{client}</b> với tài khoản {appName} của bạn?",

View File

@@ -53,6 +53,8 @@
"webauthn_not_supported_by_browser": "此浏览器不支持密钥登录。请使用其他登录方式。",
"critical_error_occurred_contact_administrator": "发生严重错误。请联系您的管理员。",
"sign_in_to": "登录到 {name}",
"account_selection_signin_confirmation": "您是否要使用以下账户继续 <b>{name}</b>",
"use_a_different_account": "请使用另一个账户",
"client_not_found": "客户端未找到",
"client_wants_to_access_the_following_information": "<b>{client}</b> 希望访问以下信息:",
"do_you_want_to_sign_in_to_client_with_your_app_name_account": "您是否希望使用您的 {appName} 账户登录到 <b>{client}</b>",

View File

@@ -53,6 +53,8 @@
"webauthn_not_supported_by_browser": "此瀏覽器不支援通行密鑰。請使用其他登入方式。",
"critical_error_occurred_contact_administrator": "發生嚴重錯誤,請聯絡您的管理員。",
"sign_in_to": "登入 {name}",
"account_selection_signin_confirmation": "您要使用以下帳戶繼續 <b>{name}</b>",
"use_a_different_account": "請使用其他帳戶",
"client_not_found": "找不到客戶端",
"client_wants_to_access_the_following_information": "<b>{client}</b> 想要存取下列資訊:",
"do_you_want_to_sign_in_to_client_with_your_app_name_account": "您想要使用您的 {appName} 帳號登入至 <b>{client}</b> 嗎?",

View File

@@ -1,6 +1,6 @@
{
"name": "pocket-id-frontend",
"version": "2.6.1",
"version": "2.6.2",
"private": true,
"type": "module",
"scripts": {
@@ -15,8 +15,8 @@
},
"dependencies": {
"@simplewebauthn/browser": "^13.3.0",
"@tailwindcss/vite": "^4.2.2",
"axios": "^1.15.0",
"@tailwindcss/vite": "^4.2.4",
"axios": "^1.15.2",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
"jose": "^6.2.2",
@@ -27,38 +27,38 @@
"zod": "^4.3.6"
},
"devDependencies": {
"@inlang/paraglide-js": "^2.15.3",
"@inlang/plugin-m-function-matcher": "^2.2.4",
"@inlang/paraglide-js": "^2.16.1",
"@inlang/plugin-m-function-matcher": "^2.2.5",
"@inlang/plugin-message-format": "^4.4.0",
"@internationalized/date": "^3.12.0",
"@lucide/svelte": "^0.559.0",
"@internationalized/date": "^3.12.1",
"@lucide/svelte": "^1.11.0",
"@sveltejs/adapter-static": "^3.0.10",
"@sveltejs/kit": "^2.57.1",
"@sveltejs/vite-plugin-svelte": "^6.2.4",
"@sveltejs/kit": "^2.58.0",
"@sveltejs/vite-plugin-svelte": "^7.0.0",
"@types/eslint": "^9.6.1",
"@types/node": "^24.12.2",
"@types/node": "^25.6.0",
"@types/qrcode": "^1.5.6",
"bits-ui": "^2.17.3",
"eslint": "^9.39.4",
"bits-ui": "^2.18.0",
"eslint": "^10.2.1",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-svelte": "^3.17.0",
"eslint-plugin-svelte": "^3.17.1",
"formsnap": "^2.0.1",
"globals": "^16.5.0",
"globals": "^17.5.0",
"mode-watcher": "^1.1.0",
"prettier": "^3.8.2",
"prettier": "^3.8.3",
"prettier-plugin-svelte": "^3.5.1",
"prettier-plugin-tailwindcss": "^0.7.2",
"rollup": "^4.60.1",
"svelte": "^5.55.3",
"prettier-plugin-tailwindcss": "^0.7.3",
"rollup": "^4.60.2",
"svelte": "^5.55.5",
"svelte-check": "^4.4.6",
"svelte-sonner": "^1.1.0",
"svelte-sonner": "^1.1.1",
"tailwind-variants": "^3.2.2",
"tailwindcss": "^4.2.2",
"tailwindcss": "^4.2.4",
"tslib": "^2.8.1",
"tw-animate-css": "^1.4.0",
"typescript": "^5.9.3",
"typescript-eslint": "^8.58.1",
"vite": "^7.3.2",
"typescript": "^6.0.3",
"typescript-eslint": "^8.59.0",
"vite": "^8.0.10",
"vite-plugin-compression": "^0.5.1"
}
}

View File

@@ -17,7 +17,7 @@
</script>
<DropdownMenu.Root>
<DropdownMenu.Trigger
<DropdownMenu.Trigger aria-label={m.my_account()}
><Avatar.Root class="size-9">
<Avatar.Image src={cachedProfilePicture.getUrl($userStore!.id)} />
</Avatar.Root></DropdownMenu.Trigger

View File

@@ -22,7 +22,7 @@
<div class=" w-full {isAuthPage ? 'absolute top-0 z-10 mt-3 lg:mt-8 pr-2 lg:pr-3' : 'border-b'}">
<div
class="{!isAuthPage
? 'max-w-[1640px]'
? 'max-w-410'
: ''} mx-auto flex w-full items-center justify-between px-4 md:px-10"
>
<div class="flex h-16 items-center">

View File

@@ -77,7 +77,11 @@
const delayUpdateLink = () => `${layout().total * ROW_STAGGER}ms`;
</script>
<nav class="text-muted-foreground grid gap-2 text-sm">
<nav
class="text-muted-foreground grid gap-2 text-sm"
aria-label={m.settings()}
data-sveltekit-keepfocus
>
{#each items as item, i}
{#if item.children?.length}
{@const id = groupId(item, i)}

View File

@@ -223,7 +223,16 @@
{/if}
{#each visibleColumns as column}
<Table.Head class={cn(column.sortable && 'p-0')}>
<Table.Head
class={cn(column.sortable && 'p-0')}
aria-sort={column.sortable
? requestOptions.sort?.column === column.column
? requestOptions.sort?.direction === 'asc'
? 'ascending'
: 'descending'
: 'none'
: undefined}
>
{#if column.sortable}
<Button
variant="ghost"

View File

@@ -13,6 +13,7 @@
<th
bind:this={ref}
data-slot="table-head"
scope="col"
class={cn(
'text-foreground h-12 px-4 text-left align-middle font-medium whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]',
className

View File

@@ -23,6 +23,7 @@ class OidcService extends APIService {
codeChallenge?: string,
codeChallengeMethod?: string,
reauthenticationToken?: string,
responseMode?: string,
prompt?: string
) => {
const res = await this.api.post('/oidc/authorize', {
@@ -33,6 +34,7 @@ class OidcService extends APIService {
codeChallenge,
codeChallengeMethod,
reauthenticationToken,
responseMode,
prompt
});

View File

@@ -1,7 +1,9 @@
<script lang="ts">
import { invalidateAll } from '$app/navigation';
import FormattedMessage from '$lib/components/formatted-message.svelte';
import SignInWrapper from '$lib/components/login-wrapper.svelte';
import ScopeList from '$lib/components/scope-list.svelte';
import * as Avatar from '$lib/components/ui/avatar';
import { Button } from '$lib/components/ui/button';
import * as Card from '$lib/components/ui/card';
import { m } from '$lib/paraglide/messages';
@@ -9,6 +11,7 @@
import WebAuthnService from '$lib/services/webauthn-service';
import appConfigStore from '$lib/stores/application-configuration-store';
import userStore from '$lib/stores/user-store';
import { cachedProfilePicture } from '$lib/utils/cached-image-util';
import { getWebauthnErrorMessage } from '$lib/utils/error-util';
import { startAuthentication, type AuthenticationResponseJSON } from '@simplewebauthn/browser';
import { onMount } from 'svelte';
@@ -28,7 +31,8 @@
codeChallenge,
codeChallengeMethod,
authorizeState,
prompt
prompt,
responseMode
} = data;
let isLoading = $state(false);
@@ -36,8 +40,22 @@
let errorMessage: string | null = $state(null);
let authorizationRequired = $state(false);
let authorizationConfirmed = $state(false);
let accountSelectionRequired = $state(false);
let userSignedInAt: Date | undefined;
const fullName = $derived.by(() => {
if (!$userStore) {
return '';
}
if ($userStore.displayName) {
return $userStore.displayName;
}
return [$userStore.firstName, $userStore.lastName].filter(Boolean).join(' ').trim();
});
const primaryName = $derived(fullName || $userStore?.email || '');
// Parse prompt parameter once (space-delimited per OIDC spec)
const promptValues = prompt ? prompt.split(' ') : [];
const hasPromptNone = promptValues.includes('none');
@@ -58,11 +76,27 @@
return;
}
// prompt=select_account: if the user is already signed in, pause so they can
// confirm the current account before proceeding. If they're not signed in,
// the normal login flow below is selection enough.
if (hasPromptSelectAccount && $userStore) {
accountSelectionRequired = true;
return;
}
if ($userStore) {
authorize();
}
});
async function useDifferentAccount() {
try {
await webauthnService.logout();
} finally {
await invalidateAll();
}
}
async function authorize() {
isLoading = true;
@@ -118,6 +152,7 @@
codeChallenge,
codeChallengeMethod,
reauthToken,
responseMode,
prompt
);
@@ -164,7 +199,46 @@
success = true;
setTimeout(() => {
window.location.href = redirectURL.toString();
if (responseMode === 'form_post') {
// Create a hidden form and submit it via POST
const form = document.createElement('form');
form.method = 'POST';
form.action = callbackURL;
// Add code parameter
const codeInput = document.createElement('input');
codeInput.type = 'hidden';
codeInput.name = 'code';
codeInput.value = code;
form.appendChild(codeInput);
// Add state parameter
if (authorizeState) {
const stateInput = document.createElement('input');
stateInput.type = 'hidden';
stateInput.name = 'state';
stateInput.value = authorizeState;
form.appendChild(stateInput);
}
// Add issuer parameter
const issInput = document.createElement('input');
issInput.type = 'hidden';
issInput.name = 'iss';
issInput.value = issuer;
form.appendChild(issInput);
document.body.appendChild(form);
form.submit();
} else {
// Default query parameter redirect (response_mode=query or not specified)
const redirectURL = new URL(callbackURL);
redirectURL.searchParams.append('code', code);
redirectURL.searchParams.append('state', authorizeState);
redirectURL.searchParams.append('iss', issuer);
window.location.href = redirectURL.toString();
}
}, 1000);
}
</script>
@@ -186,7 +260,39 @@
{errorMessage}.
</p>
{/if}
{#if !authorizationRequired && !errorMessage}
{#if accountSelectionRequired && $userStore && !errorMessage}
<div transition:slide={{ duration: 300 }} class="flex flex-col items-center">
<p class="text-muted-foreground mt-2 mb-8">
<FormattedMessage m={m.account_selection_signin_confirmation({ name: client.name })} />
</p>
<Card.Root class="mb-2 py-4 w-sm" data-testid="account-selection">
<Card.Content class="flex items-center gap-4">
<Avatar.Root class="size-11 shrink-0">
<Avatar.Image src={cachedProfilePicture.getUrl($userStore.id)} />
</Avatar.Root>
<div class="flex min-w-0 flex-col text-start">
<p class="truncate text-base leading-tight font-medium">
{primaryName}
</p>
{#if fullName && $userStore.email}
<p class="text-muted-foreground mt-1 truncate text-sm leading-tight">
{$userStore.email}
</p>
{/if}
</div>
</Card.Content>
</Card.Root>
<div class="mb-10 flex justify-center">
<button
type="button"
class="text-muted-foreground text-xs transition-colors hover:underline"
onclick={useDifferentAccount}
>
{m.use_a_different_account()}
</button>
</div>
</div>
{:else if !authorizationRequired && !errorMessage}
<p class="text-muted-foreground mt-2 mb-10">
<FormattedMessage
m={m.do_you_want_to_sign_in_to_client_with_your_app_name_account({
@@ -196,7 +302,7 @@
/>
</p>
{:else if authorizationRequired}
<div class="w-full max-w-[450px]" transition:slide={{ duration: 300 }}>
<div class="w-full max-w-md" transition:slide={{ duration: 300 }}>
<Card.Root class="mt-6 mb-10">
<Card.Header>
<p class="text-muted-foreground text-start">
@@ -212,7 +318,7 @@
</div>
{/if}
<!-- Flex flow is reversed so the sign in button, which has auto-focus, is the first one in the DOM, for a11y -->
<div class="flex w-full max-w-[450px] flex-row-reverse gap-2">
<div class="flex w-full max-w-md flex-row-reverse gap-2">
{#if !errorMessage}
<Button class="flex-1" {isLoading} onclick={authorize} autofocus={true}>
{m.sign_in()}

View File

@@ -15,6 +15,7 @@ export const load: PageLoad = async ({ url }) => {
client,
codeChallenge: url.searchParams.get('code_challenge')!,
codeChallengeMethod: url.searchParams.get('code_challenge_method')!,
prompt: url.searchParams.get('prompt') || undefined
prompt: url.searchParams.get('prompt') || undefined,
responseMode: url.searchParams.get('response_mode') || undefined
};
};

View File

@@ -68,7 +68,14 @@
<p class="text-muted-foreground mt-2">{m.enter_the_code_you_received_to_sign_in()}</p>
{/if}
<form onsubmit={preventDefault(authenticate)} class="w-full max-w-[450px]">
<Input id="Code" class="mt-7" placeholder={m.code()} bind:value={code} type="text" />
<Input
id="Code"
class="mt-7"
placeholder={m.code()}
aria-label={m.code()}
bind:value={code}
type="text"
/>
<div class="mt-8 flex justify-between gap-2">
<Button variant="secondary" class="flex-1" href={backHref}>{m.go_back()}</Button>
<Button class="flex-1" type="submit" {isLoading}>{m.submit()}</Button>

View File

@@ -63,7 +63,14 @@
<p class="text-muted-foreground mt-2" in:fade>
{m.enter_your_email_address_to_receive_an_email_with_a_login_code()}
</p>
<Input id="Email" class="mt-7" placeholder={m.your_email()} bind:value={email} type="email" />
<Input
id="Email"
class="mt-7"
placeholder={m.your_email()}
aria-label={m.email()}
bind:value={email}
type="email"
/>
<div class="mt-8 flex justify-between gap-2">
<Button variant="secondary" class="flex-1" href={'/login/alternative' + page.url.search}
>{m.go_back()}</Button

View File

@@ -12,5 +12,5 @@
"test": "pnpm --filter pocket-id-tests test",
"format": "pnpm --filter pocket-id-frontend format"
},
"packageManager": "pnpm@10.30.1+sha512.3590e550d5384caa39bd5c7c739f72270234b2f6059e13018f975c313b1eb9fefcc09714048765d4d9efe961382c312e624572c0420762bdc5d5940cdf9be73a"
"packageManager": "pnpm@10.33.0+sha512.10568bb4a6afb58c9eb3630da90cc9516417abebd3fabbe6739f0ae795728da1491e9db5a544c76ad8eb7570f5c4bb3d6c637b2cb41bfdcdb47fa823c8649319"
}

2670
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,3 +1,5 @@
#!/bin/bash
set -eu
cd backend
mkdir -p .bin

View File

@@ -1,3 +1,5 @@
#!/bin/bash
# Check if the script is being run from the root of the project
if [ ! -f .version ] || [ ! -f frontend/package.json ] || [ ! -f CHANGELOG.md ]; then
echo "Error: This script must be run from the root of the project."
@@ -16,6 +18,12 @@ if ! command -v gh &>/dev/null; then
exit 1
fi
# Check if Snyk CLI is installed
if ! command -v snyk &>/dev/null; then
echo "Error: Snyk CLI is not installed. Please install it and authenticate using 'snyk auth'."
exit 1
fi
# Check if we're on the main branch
if [ "$(git rev-parse --abbrev-ref HEAD)" != "main" ]; then
echo "Error: This script must be run on the main branch."
@@ -76,6 +84,12 @@ else
fi
fi
echo "Running Snyk dependency scan..."
if ! snyk test --all-projects --dev --detection-depth=3 --strict-out-of-sync=false --severity-threshold=high; then
echo "Error: Snyk detected high-severity vulnerable dependencies. Release creation aborted."
exit 1
fi
# Increment the version based on the release type
if [ "$RELEASE_TYPE" == "major" ]; then
echo "Performing major release..."

View File

@@ -1 +1,3 @@
#!/bin/bash
docker buildx build --push --file docker/Dockerfile --tag ghcr.io/pocket-id/pocket-id:development --platform linux/amd64,linux/arm64 .

View File

@@ -0,0 +1,583 @@
#!/usr/bin/env node
import { spawnSync } from 'node:child_process';
import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { exit, stdin as input, stdout as output } from 'node:process';
import readline from 'node:readline/promises';
const ROOT_DIR = path.resolve(import.meta.dirname, '..', '..');
const BACKEND_DIR = path.join(ROOT_DIR, 'backend');
const TEMP_DIR = mkdtempSync(path.join(tmpdir(), 'pocket-id-deps-'));
const CONFIG = {
minimumReleaseAgeDays: 7,
snykArgs: [
'test',
'--all-projects',
'--detection-depth=1',
'--severity-threshold=medium',
],
pnpmProjects: [
{ dir: '.', label: 'root workspace' },
{ dir: 'frontend', label: 'frontend' },
{ dir: 'tests', label: 'tests' },
{ dir: 'email-templates', label: 'email-templates' },
],
};
const ASSUME_YES = process.argv.includes('--yes') || process.argv.includes('-y');
const RELEASE_CUTOFF_MS = Date.now() - CONFIG.minimumReleaseAgeDays * 24 * 60 * 60 * 1000;
const COLOR = {
red: '\x1b[31m',
reset: '\x1b[0m',
};
const packagePublishTimelineCache = new Map();
const packagePublishTimeCache = new Map();
const goModuleVersionTimeCache = new Map();
const goModuleVersionsCache = new Map();
process.on('exit', () => {
rmSync(TEMP_DIR, { recursive: true, force: true });
});
function printSection(title) {
console.log(`\n== ${title} ==`);
}
function formatDate(value) {
return new Date(value).toISOString();
}
function withColor(text, color) {
return output.isTTY ? `${color}${text}${COLOR.reset}` : text;
}
function parseMajor(version) {
const match = String(version).trim().replace(/^[^\d]*/, '').match(/^(\d+)/);
return match ? Number(match[1]) : null;
}
function isMajorUpgrade(currentVersion, nextVersion) {
const currentMajor = parseMajor(currentVersion);
const nextMajor = parseMajor(nextVersion);
return currentMajor !== null && nextMajor !== null && nextMajor > currentMajor;
}
function isSameMajorLine(currentVersion, candidateVersion) {
const currentMajor = parseMajor(currentVersion);
const candidateMajor = parseMajor(candidateVersion);
return currentMajor !== null && currentMajor === candidateMajor;
}
function highlightUpgrade(text, currentVersion, nextVersion) {
return isMajorUpgrade(currentVersion, nextVersion) ? withColor(text, COLOR.red) : text;
}
function isOlderThanMinimumAge(releaseTime) {
return new Date(releaseTime).getTime() <= RELEASE_CUTOFF_MS;
}
function run(command, args, options = {}) {
const result = spawnSync(command, args, {
cwd: options.cwd ?? ROOT_DIR,
encoding: 'utf8',
stdio: options.stdio ?? ['ignore', 'pipe', 'pipe'],
});
if (result.error) {
throw new Error(`Failed to run '${command}': ${result.error.message}`);
}
return result;
}
function requireCommand(command) {
const result = run('bash', ['-lc', `command -v ${command}`]);
if (result.status !== 0) {
throw new Error(`Required command '${command}' is not installed.`);
}
}
function parseJson(stdout, errorContext) {
try {
return JSON.parse(stdout);
} catch {
throw new Error(`Failed to parse JSON for ${errorContext}.`);
}
}
function partitionRows(rows, predicate) {
const eligibleRows = [];
const heldBackRows = [];
for (const row of rows) {
if (predicate(row)) {
eligibleRows.push(row);
} else {
heldBackRows.push(row);
}
}
return { eligibleRows, heldBackRows };
}
function parsePnpmOutdated(rawText) {
if (!rawText.trim()) {
return [];
}
const raw = parseJson(rawText, 'pnpm outdated output');
const collectRows = (value) => {
if (!value || typeof value !== 'object') return [];
if (Array.isArray(value)) return value.flatMap(collectRows);
if (Array.isArray(value.results)) return collectRows(value.results);
if ('current' in value || 'latest' in value || 'wanted' in value) return [value];
return Object.entries(value).flatMap(([name, info]) => {
if (Array.isArray(info)) {
return info.flatMap((entry) => collectRows({ name, ...entry }));
}
if (info && typeof info === 'object') {
return collectRows({ name, ...info });
}
return [];
});
};
return collectRows(raw)
.map((row) => ({
name: row.name || row.package || row.packageName || 'unknown',
current: row.current || 'unknown',
latest: row.latest || row.wanted || 'unknown',
type: row.dependencyType || row.type || '',
}))
.filter((row) => row.current !== row.latest)
.sort((left, right) => left.name.localeCompare(right.name));
}
function getPnpmPackagePublishTime(name, version) {
const cacheKey = `${name}@${version}`;
const cachedPublishTime = packagePublishTimeCache.get(cacheKey);
if (cachedPublishTime) {
return cachedPublishTime;
}
let timeline = packagePublishTimelineCache.get(name);
if (!timeline) {
const result = run('pnpm', ['view', name, 'time', '--json']);
if (result.status !== 0 || !result.stdout.trim()) {
throw new Error(`Failed to get publish timeline for ${name}\n${result.stderr}`.trim());
}
timeline = parseJson(result.stdout, `pnpm publish timeline for ${name}`);
if (!timeline || typeof timeline !== 'object') {
throw new Error(`Publish timeline for ${name} is unavailable.`);
}
packagePublishTimelineCache.set(name, timeline);
}
const publishTime = timeline[version];
if (!publishTime) {
throw new Error(`Publish time for ${cacheKey} is unavailable.`);
}
packagePublishTimeCache.set(cacheKey, publishTime);
return publishTime;
}
function getPnpmUpgradeSummary(project) {
const result = run('pnpm', ['outdated', '--format', 'json'], {
cwd: path.join(ROOT_DIR, project.dir),
});
if (result.status !== 0 && !result.stdout.trim()) {
throw new Error(`${project.label}: failed to collect pnpm updates\n${result.stderr}`.trim());
}
const rows = parsePnpmOutdated(result.stdout).map((row) => ({
...row,
publishTime: getPnpmPackagePublishTime(row.name, row.latest),
}));
return {
project,
...partitionRows(rows, (row) => isOlderThanMinimumAge(row.publishTime)),
};
}
function parseGoListJsonStream(rawText) {
const objects = [];
let depth = 0;
let start = -1;
let inString = false;
let escaped = false;
for (let index = 0; index < rawText.length; index += 1) {
const char = rawText[index];
if (escaped) {
escaped = false;
continue;
}
if (char === '\\') {
escaped = true;
continue;
}
if (char === '"') {
inString = !inString;
continue;
}
if (inString) {
continue;
}
if (char === '{') {
if (depth === 0) {
start = index;
}
depth += 1;
continue;
}
if (char === '}') {
depth -= 1;
if (depth === 0 && start !== -1) {
objects.push(parseJson(rawText.slice(start, index + 1), 'go module JSON stream'));
start = -1;
}
}
}
return objects;
}
function getGoModuleVersionTime(name, version) {
const cacheKey = `${name}@${version}`;
const cachedTime = goModuleVersionTimeCache.get(cacheKey);
if (cachedTime) {
return cachedTime;
}
const result = run('go', ['list', '-m', '-json', `${name}@${version}`], {
cwd: BACKEND_DIR,
});
if (result.status !== 0 || !result.stdout.trim()) {
throw new Error(`Failed to get release time for Go module ${cacheKey}\n${result.stderr}`.trim());
}
const moduleInfo = parseJson(result.stdout, `Go module ${cacheKey}`);
if (!moduleInfo.Time) {
throw new Error(`Release time for Go module ${cacheKey} is unavailable.`);
}
goModuleVersionTimeCache.set(cacheKey, moduleInfo.Time);
return moduleInfo.Time;
}
function getGoModuleVersions(name) {
const cachedVersions = goModuleVersionsCache.get(name);
if (cachedVersions) {
return cachedVersions;
}
const result = run('go', ['list', '-m', '-versions', '-json', name], {
cwd: BACKEND_DIR,
});
if (result.status !== 0 || !result.stdout.trim()) {
throw new Error(`Failed to get versions for Go module ${name}\n${result.stderr}`.trim());
}
const moduleInfo = parseJson(result.stdout, `Go module versions for ${name}`);
const versions = Array.isArray(moduleInfo.Versions) ? moduleInfo.Versions : [];
goModuleVersionsCache.set(name, versions);
return versions;
}
function resolveGoUpgradeTarget(name, currentVersion) {
const versions = getGoModuleVersions(name);
const currentVersionIndex = versions.indexOf(currentVersion);
const candidateVersions = (currentVersionIndex === -1 ? versions : versions.slice(currentVersionIndex + 1))
.filter((version) => isSameMajorLine(currentVersion, version));
if (candidateVersions.length === 0) {
return null;
}
const latest = candidateVersions.at(-1);
const latestPublishTime = getGoModuleVersionTime(name, latest);
for (let index = candidateVersions.length - 1; index >= 0; index -= 1) {
const target = candidateVersions[index];
const targetPublishTime = getGoModuleVersionTime(name, target);
if (isOlderThanMinimumAge(targetPublishTime)) {
return {
latest,
latestPublishTime,
target,
targetPublishTime,
};
}
}
return {
latest,
latestPublishTime,
target: null,
targetPublishTime: null,
};
}
function getGoUpgradeSummary() {
const result = run('go', ['list', '-m', '-u', '-json', 'all'], {
cwd: BACKEND_DIR,
});
if (result.status !== 0) {
throw new Error(`backend/go.mod: failed to collect Go updates\n${result.stderr}`.trim());
}
const rows = parseGoListJsonStream(result.stdout)
.filter((moduleInfo) => !moduleInfo.Main && moduleInfo.Update?.Version && moduleInfo.Version && !moduleInfo.Indirect)
.map((moduleInfo) => {
const resolution = resolveGoUpgradeTarget(moduleInfo.Path, moduleInfo.Version);
if (!resolution) {
return null;
}
return {
name: moduleInfo.Path,
current: moduleInfo.Version,
...resolution,
};
})
.filter(Boolean)
.sort((left, right) => left.name.localeCompare(right.name));
return partitionRows(rows, (row) => Boolean(row.target));
}
function formatPnpmRow(row, statusLabel) {
const suffix = row.type ? ` (${row.type})` : '';
return highlightUpgrade(
` - ${row.name}: ${row.current} -> ${row.latest}${suffix} [${statusLabel}, published ${formatDate(row.publishTime)}]`,
row.current,
row.latest
);
}
function printPnpmSummaries(summaries) {
printSection(`Planned pnpm Upgrades (minimum age: ${CONFIG.minimumReleaseAgeDays} days)`);
for (const { project, eligibleRows, heldBackRows } of summaries) {
if (eligibleRows.length === 0 && heldBackRows.length === 0) {
console.log(`${project.label}: no pnpm upgrades available`);
continue;
}
console.log(
`${project.label}: ${eligibleRows.length} eligible pnpm upgrade(s), ${heldBackRows.length} held back`
);
for (const row of eligibleRows) {
console.log(formatPnpmRow(row, 'eligible'));
}
for (const row of heldBackRows) {
console.log(formatPnpmRow(row, 'held back'));
}
}
}
function formatGoRow(row) {
const details = row.target === row.latest
? `[eligible, published ${formatDate(row.targetPublishTime)}]`
: `[eligible fallback, latest ${row.latest} published ${formatDate(row.latestPublishTime)}, selected ${row.target} published ${formatDate(row.targetPublishTime)}]`;
return highlightUpgrade(
` - ${row.name}: ${row.current} -> ${row.target} ${details}`,
row.current,
row.target
);
}
function formatHeldBackGoRow(row) {
return highlightUpgrade(
` - ${row.name}: ${row.current} -> ${row.latest} [held back, latest published ${formatDate(row.latestPublishTime)}]`,
row.current,
row.latest
);
}
function printGoSummary(summary) {
printSection(`Planned Go Upgrades (minimum age: ${CONFIG.minimumReleaseAgeDays} days)`);
if (summary.eligibleRows.length === 0 && summary.heldBackRows.length === 0) {
console.log('backend/go.mod: no Go upgrades available');
return;
}
console.log(
`backend/go.mod: ${summary.eligibleRows.length} eligible Go upgrade(s), ${summary.heldBackRows.length} held back`
);
for (const row of summary.eligibleRows) {
console.log(formatGoRow(row));
}
for (const row of summary.heldBackRows) {
console.log(formatHeldBackGoRow(row));
}
}
function parseSnykResults(rawText) {
const raw = parseJson(rawText, 'Snyk results');
const results = Array.isArray(raw) ? raw : Array.isArray(raw.results) ? raw.results : [raw];
const totals = { critical: 0, high: 0, medium: 0 };
let projects = 0;
let affectedProjects = 0;
for (const result of results) {
if (!result || typeof result !== 'object') {
continue;
}
projects += 1;
const vulnerabilities = Array.isArray(result.vulnerabilities)
? result.vulnerabilities
: Array.isArray(result.issues?.vulnerabilities)
? result.issues.vulnerabilities
: [];
if (vulnerabilities.length > 0) {
affectedProjects += 1;
}
for (const vulnerability of vulnerabilities) {
const severity = String(vulnerability.severity || '').toLowerCase();
if (severity in totals) {
totals[severity] += 1;
}
}
}
return { totals, projects, affectedProjects };
}
function printSnykStatus(label) {
console.log(`\nCollecting Snyk vulnerability status for ${label.toLowerCase()}...`);
const outputFile = path.join(TEMP_DIR, `${label.toLowerCase().replaceAll(' ', '-')}.json`);
const result = run('snyk', [...CONFIG.snykArgs, `--json-file-output=${outputFile}`]);
if (![0, 1].includes(result.status)) {
throw new Error(`${label}: failed to collect Snyk status\n${result.stderr}`.trim());
}
const summary = parseSnykResults(readFileSync(outputFile, 'utf8'));
printSection(`${label} Vulnerability Status`);
console.log(`${label}: ${summary.projects} project scan(s), ${summary.affectedProjects} with vulnerabilities`);
console.log(` - critical: ${summary.totals.critical}`);
console.log(` - high: ${summary.totals.high}`);
console.log(` - medium: ${summary.totals.medium}`);
console.log(` - total: ${Object.values(summary.totals).reduce((sum, count) => sum + count, 0)}`);
return result.status;
}
async function confirmUpgrade() {
if (ASSUME_YES) {
return;
}
const rl = readline.createInterface({ input, output });
const answer = await rl.question('\nProceed with dependency upgrades? (y/n) ');
rl.close();
if (answer !== 'y') {
throw new Error('Dependency upgrade canceled.');
}
}
function applyPnpmUpgrades() {
printSection('Applying pnpm Upgrades');
const result = run('pnpm', ['update', '-r', '--latest'], { stdio: 'inherit' });
if (result.status !== 0) {
throw new Error('pnpm workspace upgrade failed.');
}
}
function applyGoUpgrades(goSummary) {
printSection('Applying Go Upgrades');
if (goSummary.eligibleRows.length === 0) {
console.log(`No Go upgrades met the ${CONFIG.minimumReleaseAgeDays}-day minimum age.`);
} else {
const moduleSpecs = goSummary.eligibleRows.map((row) => `${row.name}@${row.target}`);
const result = run('go', ['get', '-t', ...moduleSpecs], {
cwd: BACKEND_DIR,
stdio: 'inherit',
});
if (result.status !== 0) {
throw new Error('Go dependency upgrade failed.');
}
}
const tidyResult = run('go', ['mod', 'tidy'], {
cwd: BACKEND_DIR,
stdio: 'inherit',
});
if (tidyResult.status !== 0) {
throw new Error('go mod tidy failed.');
}
}
async function main() {
requireCommand('pnpm');
requireCommand('go');
requireCommand('snyk');
const pnpmSummaries = CONFIG.pnpmProjects.map(getPnpmUpgradeSummary);
const goSummary = getGoUpgradeSummary();
printPnpmSummaries(pnpmSummaries);
printGoSummary(goSummary);
printSnykStatus('Before Upgrade');
const hasEligibleUpgrades =
pnpmSummaries.some((summary) => summary.eligibleRows.length > 0) ||
goSummary.eligibleRows.length > 0;
if (!hasEligibleUpgrades) {
console.log(`\nNo dependency upgrades met the ${CONFIG.minimumReleaseAgeDays}-day minimum age.`);
exit(printSnykStatus('After Upgrade'));
}
await confirmUpgrade();
applyPnpmUpgrades();
applyGoUpgrades(goSummary);
exit(printSnykStatus('After Upgrade'));
}
main().catch((error) => {
console.error(error.message);
exit(1);
});

View File

@@ -9,10 +9,10 @@
"devDependencies": {
"@playwright/test": "^1.59.1",
"@types/adm-zip": "^0.5.8",
"@types/node": "^22.19.17",
"dotenv": "^17.4.1",
"@types/node": "^25.6.0",
"dotenv": "^17.4.2",
"jose": "^6.2.2",
"prettier": "^3.8.2"
"prettier": "^3.8.3"
},
"dependencies": {
"adm-zip": "^0.5.17"

View File

@@ -3,11 +3,8 @@ FROM lldap/lldap:2025-05-19
WORKDIR /app
RUN curl -o /bin/lldap-cli https://raw.githubusercontent.com/Zepmann/lldap-cli/e383494b4dd89ae4e028958b268e200fd85a7a64/lldap-cli
COPY seed-lldap.sh .
RUN chmod +x ./seed-lldap.sh /bin/lldap-cli
RUN chmod +x ./seed-lldap.sh
RUN cp lldap_set_password /bin
ENTRYPOINT /docker-entrypoint.sh run --config-file /data/lldap_config.toml & ./seed-lldap.sh && wait

View File

@@ -8,22 +8,23 @@ services:
file: docker-compose.yml
service: scim-test-server
localstack-s3:
image: localstack/localstack:s3-latest
healthcheck:
test: ["CMD", "curl", "-f", "http://localstack-s3:4566"]
interval: 1s
timeout: 3s
retries: 10
create-bucket:
image: amazon/aws-cli:latest
image: localstack/localstack:4.14.0
environment:
SERVICES: s3
AWS_ACCESS_KEY_ID: test
AWS_SECRET_ACCESS_KEY: test
AWS_DEFAULT_REGION: us-east-1
depends_on:
localstack-s3:
condition: service_healthy
entrypoint: "aws --endpoint-url=http://localstack-s3:4566 s3 mb s3://pocket-id-test"
volumes:
- ./localstack-init-s3.py:/etc/localstack/init/ready.d/10-init-s3.py
healthcheck:
test:
[
"CMD-SHELL",
'curl -fs http://localstack-s3:4566/_localstack/init/ready | grep -q ''"completed": true''',
]
interval: 1s
timeout: 3s
retries: 10
pocket-id:
extends:
file: docker-compose.yml
@@ -37,8 +38,8 @@ services:
S3_SECRET_ACCESS_KEY: test
S3_FORCE_PATH_STYLE: true
depends_on:
create-bucket:
condition: service_completed_successfully
localstack-s3:
condition: service_healthy
volumes:
pocket-id-test-data:

View File

@@ -0,0 +1,22 @@
import boto3
from botocore.exceptions import ClientError
BUCKET_NAME = "pocket-id-test"
def main() -> None:
s3 = boto3.client(
"s3",
endpoint_url="http://localhost:4566",
aws_access_key_id="test",
aws_secret_access_key="test",
region_name="us-east-1",
)
try:
s3.head_bucket(Bucket=BUCKET_NAME)
except ClientError:
s3.create_bucket(Bucket=BUCKET_NAME)
main()

View File

@@ -1,87 +1,202 @@
#!/bin/sh
set -e
set -eu
LLDAP_HTTP_URL="http://localhost:17170"
LLDAP_ADMIN_USERNAME="admin"
LLDAP_ADMIN_PASSWORD="admin_password"
LLDAP_TOKEN=""
login() {
response="$(
jq -n \
--arg username "$LLDAP_ADMIN_USERNAME" \
--arg password "$LLDAP_ADMIN_PASSWORD" \
'{username: $username, password: $password}' |
curl -fsS \
-X POST \
-H 'Content-Type: application/json' \
--data-binary @- \
"$LLDAP_HTTP_URL/auth/simple/login"
)"
LLDAP_TOKEN="$(printf '%s' "$response" | jq -r '.token // empty')"
if [ -z "$LLDAP_TOKEN" ]; then
echo "Failed to authenticate to LLDAP" >&2
exit 1
fi
}
graphql() {
query="$1"
if [ "$#" -ge 2 ]; then
variables="$2"
else
variables="{}"
fi
response="$(
jq -cn \
--arg query "$query" \
--argjson variables "$variables" \
'{query: $query, variables: $variables}' |
curl -fsS \
-H 'Content-Type: application/json' \
-H "Authorization: Bearer $LLDAP_TOKEN" \
--data-binary @- \
"$LLDAP_HTTP_URL/api/graphql"
)"
errors="$(printf '%s' "$response" | jq -r '.errors[]?.message')"
if [ -n "$errors" ]; then
echo "$errors" >&2
return 1
fi
printf '%s\n' "$response"
}
user_exists() {
response="$(graphql '{users{id}}' '{}')"
printf '%s' "$response" | jq -e --arg id "$1" '.data.users[]? | select(.id == $id)' >/dev/null
}
create_user() {
id="$1"
email="$2"
display_name="$3"
first_name="$4"
last_name="$5"
password="$6"
variables="$(
jq -cn \
--arg id "$id" \
--arg email "$email" \
--arg displayName "$display_name" \
--arg firstName "$first_name" \
--arg lastName "$last_name" \
'{user: {id: $id, email: $email, displayName: $displayName, firstName: $firstName, lastName: $lastName, avatar: ""}}'
)"
graphql 'mutation createUser($user:CreateUserInput!){createUser(user:$user){id}}' "$variables" >/dev/null
lldap_set_password -b "$LLDAP_HTTP_URL" --token="$LLDAP_TOKEN" -u "$id" -p "$password"
echo "Created user: $id"
}
create_group() {
name="$1"
variables="$(jq -cn --arg name "$name" '{group: $name}')"
graphql 'mutation createGroup($group:String!){createGroup(name:$group){id}}' "$variables" >/dev/null
echo "Created group: $name"
}
get_group_id() {
name="$1"
response="$(graphql '{groups{id displayName}}' '{}')"
group_id="$(printf '%s' "$response" | jq -r --arg name "$name" '.data.groups[]? | select(.displayName == $name) | .id' | head -n 1)"
if [ -z "$group_id" ]; then
echo "Failed to retrieve group ID for group: $name" >&2
return 1
fi
printf '%s\n' "$group_id"
}
update_group_display_name() {
name="$1"
display_name="$2"
group_id="$(get_group_id "$name")"
variables="$(
jq -cn \
--argjson id "$group_id" \
--arg displayName "$display_name" \
'{group: {id: $id, insertAttributes: {name: "display_name", value: $displayName}}}'
)"
graphql 'mutation updateGroup($group:UpdateGroupInput!){updateGroup(group:$group){ok}}' "$variables" >/dev/null
echo "Attribute set for group: $name, attribute: display_name, value: $display_name"
}
add_user_to_group() {
user_id="$1"
group_name="$2"
group_id="$(get_group_id "$group_name")"
variables="$(
jq -cn \
--arg userId "$user_id" \
--argjson groupId "$group_id" \
'{userId: $userId, groupId: $groupId}'
)"
graphql 'mutation addUserToGroup($userId:String!,$groupId:Int!){addUserToGroup(userId:$userId,groupId:$groupId){ok}}' "$variables" >/dev/null
}
add_user_to_group_with_retry() {
user_id="$1"
group_name="$2"
i=1
while [ "$i" -le 3 ]; do
echo "Attempt $i to add $user_id to $group_name"
if add_user_to_group "$user_id" "$group_name"; then
echo "Successfully added $user_id to $group_name"
return 0
fi
if [ "$i" -eq 3 ]; then
echo "Warning: Could not add $user_id to $group_name after 3 attempts"
return 0
fi
echo "Failed to add $user_id to $group_name, retrying in 2 seconds..."
sleep 2
i=$((i + 1))
done
}
# Wait for LLDAP to start
for i in {1..15}; do
if curl -s --fail http://localhost:17170/api/healthcheck >/dev/null; then
i=1
while [ "$i" -le 15 ]; do
if curl -s --fail "$LLDAP_HTTP_URL/api/healthcheck" >/dev/null; then
echo "LLDAP is ready"
break
fi
if [ $i -eq 15 ]; then
if [ "$i" -eq 15 ]; then
echo "LLDAP failed to start in time"
exit 1
fi
echo "Waiting for LLDAP... ($i/15)"
sleep 3
i=$((i + 1))
done
# Configure LLDAP CLI connection via environment variables
export LLDAP_HTTPURL="http://localhost:17170"
export LLDAP_USERNAME="admin"
export LLDAP_PASSWORD="admin_password"
login
echo "Checking if data is already seeded..."
if lldap-cli user list | grep -q "testuser1"; then
if user_exists "testuser1"; then
echo "Data already seeded, skipping setup."
exit 0
fi
echo "Setting up LLDAP test data..."
# Create test users using the user add command
echo "Creating test users..."
lldap-cli user add "testuser1" "testuser1@pocket-id.org" \
-p "password123" \
-d "Test User 1" \
-f "Test" \
-l "User"
create_user "testuser1" "testuser1@pocket-id.org" "Test User 1" "Test" "User" "password123"
create_user "testuser2" "testuser2@pocket-id.org" "Test User 2" "Test2" "User2" "password123"
lldap-cli user add "testuser2" "testuser2@pocket-id.org" \
-p "password123" \
-d "Test User 2" \
-f "Test2" \
-l "User2"
# Create test groups
echo "Creating test groups..."
lldap-cli group add "test_group"
create_group "test_group"
sleep 1
lldap-cli group update set "test_group" "display_name" "test_group"
update_group_display_name "test_group" "test_group"
lldap-cli group add "admin_group"
create_group "admin_group"
sleep 1
lldap-cli group update set "admin_group" "display_name" "admin_group"
update_group_display_name "admin_group" "admin_group"
# Add users to groups with retry logic
echo "Adding users to groups..."
for i in {1..3}; do
echo "Attempt $i to add testuser1 to test_group"
if lldap-cli user group add "testuser1" "test_group"; then
echo "Successfully added testuser1 to test_group"
break
else
echo "Failed to add testuser1 to test_group, retrying in 2 seconds..."
sleep 2
fi
if [ $i -eq 3 ]; then
echo "Warning: Could not add testuser1 to test_group after 3 attempts"
fi
done
for i in {1..3}; do
echo "Attempt $i to add testuser2 to admin_group"
if lldap-cli user group add "testuser2" "admin_group"; then
echo "Successfully added testuser2 to admin_group"
break
else
echo "Failed to add testuser2 to admin_group, retrying in 2 seconds..."
sleep 2
fi
if [ $i -eq 3 ]; then
echo "Warning: Could not add testuser2 to admin_group after 3 attempts"
fi
done
add_user_to_group_with_retry "testuser1" "test_group"
add_user_to_group_with_retry "testuser2" "admin_group"
echo "LLDAP test data setup complete"

View File

@@ -0,0 +1,22 @@
import test, { expect } from '@playwright/test';
import { cleanupBackend } from '../utils/cleanup.util';
test.beforeEach(async () => await cleanupBackend());
test('settings sidebar has an accessible name', async ({ page }) => {
await page.goto('/settings/account');
const nav = page.getByRole('navigation', { name: 'Settings' });
await expect(nav).toBeVisible();
});
test('keyboard focus stays on sidebar link after navigating', async ({ page }) => {
await page.goto('/settings/account');
const auditLog = page.getByRole('link', { name: 'Audit Log' });
await auditLog.focus();
await page.keyboard.press('Enter');
await page.waitForURL('**/settings/audit-log');
await expect(auditLog).toBeFocused();
});

View File

@@ -1,4 +1,4 @@
import test, { expect } from '@playwright/test';
import test, { expect, type Page, type Request } from '@playwright/test';
import { oidcClients, refreshTokens, users } from '../data';
import { cleanupBackend } from '../utils/cleanup.util';
import { generateIdToken, generateOauthAccessToken } from '../utils/jwt.util';
@@ -14,7 +14,10 @@ test('Authorize existing client', async ({ page }) => {
// Ignore DNS resolution error as the callback URL is not reachable
await page.waitForURL(oidcClient.callbackUrl).catch((e) => {
if (!e.message.includes('net::ERR_NAME_NOT_RESOLVED') && !e.message.includes('net::ERR_CERT_AUTHORITY_INVALID')) {
if (
!e.message.includes('net::ERR_NAME_NOT_RESOLVED') &&
!e.message.includes('net::ERR_CERT_AUTHORITY_INVALID')
) {
throw e;
}
});
@@ -31,7 +34,10 @@ test('Authorize existing client while not signed in', async ({ page }) => {
// Ignore DNS resolution error as the callback URL is not reachable
await page.waitForURL(oidcClient.callbackUrl).catch((e) => {
if (!e.message.includes('net::ERR_NAME_NOT_RESOLVED') && !e.message.includes('net::ERR_CERT_AUTHORITY_INVALID')) {
if (
!e.message.includes('net::ERR_NAME_NOT_RESOLVED') &&
!e.message.includes('net::ERR_CERT_AUTHORITY_INVALID')
) {
throw e;
}
});
@@ -49,7 +55,10 @@ test('Authorize new client', async ({ page }) => {
// Ignore DNS resolution error as the callback URL is not reachable
await page.waitForURL(oidcClient.callbackUrl).catch((e) => {
if (!e.message.includes('net::ERR_NAME_NOT_RESOLVED') && !e.message.includes('net::ERR_CERT_AUTHORITY_INVALID')) {
if (
!e.message.includes('net::ERR_NAME_NOT_RESOLVED') &&
!e.message.includes('net::ERR_CERT_AUTHORITY_INVALID')
) {
throw e;
}
});
@@ -71,7 +80,10 @@ test('Authorize new client while not signed in', async ({ page }) => {
// Ignore DNS resolution error as the callback URL is not reachable
await page.waitForURL(oidcClient.callbackUrl).catch((e) => {
if (!e.message.includes('net::ERR_NAME_NOT_RESOLVED') && !e.message.includes('net::ERR_CERT_AUTHORITY_INVALID')) {
if (
!e.message.includes('net::ERR_NAME_NOT_RESOLVED') &&
!e.message.includes('net::ERR_CERT_AUTHORITY_INVALID')
) {
throw e;
}
});
@@ -125,7 +137,10 @@ test('End session with id token hint redirects to callback URL', async ({ page }
`/api/oidc/end-session?id_token_hint=${idToken}&post_logout_redirect_uri=${client.logoutCallbackUrl}`
)
.catch((e) => {
if (e.message.includes('net::ERR_NAME_NOT_RESOLVED') || e.message.includes('net::ERR_CERT_AUTHORITY_INVALID')) {
if (
e.message.includes('net::ERR_NAME_NOT_RESOLVED') ||
e.message.includes('net::ERR_CERT_AUTHORITY_INVALID')
) {
redirectedCorrectly = true;
} else {
throw e;
@@ -617,7 +632,10 @@ test('Forces reauthentication when client requires it', async ({ page, request }
await expect(page.getByTestId('scopes')).not.toBeVisible();
await page.waitForURL(oidcClients.nextcloud.callbackUrl).catch((e) => {
if (!e.message.includes('net::ERR_NAME_NOT_RESOLVED') && !e.message.includes('net::ERR_CERT_AUTHORITY_INVALID')) {
if (
!e.message.includes('net::ERR_NAME_NOT_RESOLVED') &&
!e.message.includes('net::ERR_CERT_AUTHORITY_INVALID')
) {
throw e;
}
});
@@ -625,6 +643,49 @@ test('Forces reauthentication when client requires it', async ({ page, request }
expect(webauthnStartCalled).toBe(true);
});
test('Authorize existing client while not signed in with response_mode=form_post', async ({
page
}) => {
const oidcClient = oidcClients.nextcloud;
const urlParams = createUrlParams(oidcClient);
urlParams.set('response_mode', 'form_post');
await page.context().clearCookies();
const formPostRequestPromise = waitForFormPostRequest(page, oidcClient.callbackUrl);
await page.goto(`/authorize?${urlParams.toString()}`);
await (await passkeyUtil.init(page)).addPasskey();
await page.getByRole('button', { name: 'Sign in' }).click();
await expectFormPostRequest(formPostRequestPromise);
});
test('Authorize existing client with response_mode=form_post', async ({ page }) => {
const oidcClient = oidcClients.nextcloud;
const urlParams = createUrlParams(oidcClient);
urlParams.set('response_mode', 'form_post');
const formPostRequestPromise = waitForFormPostRequest(page, oidcClient.callbackUrl);
await page.goto(`/authorize?${urlParams.toString()}`);
await expectFormPostRequest(formPostRequestPromise);
});
function waitForFormPostRequest(page: Page, callbackUrl: string): Promise<Request> {
return page.waitForRequest(
(request) => request.method() === 'POST' && request.url() === callbackUrl
);
}
async function expectFormPostRequest(formPostRequestPromise: Promise<Request>) {
const request = await formPostRequestPromise;
const formData = new URLSearchParams(request.postData() ?? '');
expect(formData.get('code')).toBeTruthy();
expect(formData.get('state')).toBe('nXx-6Qr-owc1SHBa');
expect(formData.get('iss')).toBeTruthy();
}
test.describe('OIDC prompt parameter', () => {
test('prompt=none redirects with login_required when user not authenticated', async ({
page
@@ -635,10 +696,8 @@ test.describe('OIDC prompt parameter', () => {
urlParams.set('prompt', 'none');
// Should redirect to callback URL with error
const redirectUrl = await oidcUtil.interceptCallbackRedirect(
page,
'/auth/callback',
() => page.goto(`/authorize?${urlParams.toString()}`).then(() => {})
const redirectUrl = await oidcUtil.interceptCallbackRedirect(page, '/auth/callback', () =>
page.goto(`/authorize?${urlParams.toString()}`).then(() => {})
);
expect(redirectUrl.searchParams.get('error')).toBe('login_required');
@@ -653,10 +712,8 @@ test.describe('OIDC prompt parameter', () => {
urlParams.set('prompt', 'none');
// Should redirect to callback URL with error
const redirectUrl = await oidcUtil.interceptCallbackRedirect(
page,
'/auth/callback',
() => page.goto(`/authorize?${urlParams.toString()}`).then(() => {})
const redirectUrl = await oidcUtil.interceptCallbackRedirect(page, '/auth/callback', () =>
page.goto(`/authorize?${urlParams.toString()}`).then(() => {})
);
expect(redirectUrl.searchParams.get('error')).toBe('consent_required');
@@ -672,7 +729,10 @@ test.describe('OIDC prompt parameter', () => {
// Should redirect successfully to callback URL with code
await page.waitForURL(oidcClient.callbackUrl).catch((e) => {
if (!e.message.includes('net::ERR_NAME_NOT_RESOLVED') && !e.message.includes('net::ERR_CERT_AUTHORITY_INVALID')) {
if (
!e.message.includes('net::ERR_NAME_NOT_RESOLVED') &&
!e.message.includes('net::ERR_CERT_AUTHORITY_INVALID')
) {
throw e;
}
});
@@ -686,14 +746,19 @@ test.describe('OIDC prompt parameter', () => {
await page.goto(`/authorize?${urlParams.toString()}`);
// Should show consent UI even though client was already authorized
await expect(page.getByTestId('scopes').getByRole('heading', { name: 'Profile' })).toBeVisible();
await expect(
page.getByTestId('scopes').getByRole('heading', { name: 'Profile' })
).toBeVisible();
await expect(page.getByTestId('scopes').getByRole('heading', { name: 'Email' })).toBeVisible();
await page.getByRole('button', { name: 'Sign in' }).click();
// Should redirect successfully after consent
await page.waitForURL(oidcClient.callbackUrl).catch((e) => {
if (!e.message.includes('net::ERR_NAME_NOT_RESOLVED') && !e.message.includes('net::ERR_CERT_AUTHORITY_INVALID')) {
if (
!e.message.includes('net::ERR_NAME_NOT_RESOLVED') &&
!e.message.includes('net::ERR_CERT_AUTHORITY_INVALID')
) {
throw e;
}
});
@@ -715,7 +780,10 @@ test.describe('OIDC prompt parameter', () => {
// Should require reauthentication even though user is signed in
await page.waitForURL(oidcClient.callbackUrl).catch((e) => {
if (!e.message.includes('net::ERR_NAME_NOT_RESOLVED') && !e.message.includes('net::ERR_CERT_AUTHORITY_INVALID')) {
if (
!e.message.includes('net::ERR_NAME_NOT_RESOLVED') &&
!e.message.includes('net::ERR_CERT_AUTHORITY_INVALID')
) {
throw e;
}
});
@@ -723,17 +791,56 @@ test.describe('OIDC prompt parameter', () => {
expect(reauthCalled).toBe(true);
});
test('prompt=select_account returns interaction_required error', async ({ page }) => {
test('prompt=select_account shows current user and continues on confirm', async ({ page }) => {
const oidcClient = oidcClients.nextcloud;
const urlParams = createUrlParams(oidcClient);
urlParams.set('prompt', 'select_account');
await page.goto(`/authorize?${urlParams.toString()}`);
// Should show error since account selection is not supported
await expect(
page.getByRole('paragraph').filter({ hasText: 'interaction_required' })
).toBeVisible();
// Account selection card with the signed-in user should appear
const selectionCard = page.getByTestId('account-selection');
await expect(selectionCard).toBeVisible();
await expect(selectionCard).toContainText('Tim Cook');
await page.getByRole('button', { name: 'Sign In' }).click();
// Should redirect successfully to callback URL with code
await page.waitForURL(oidcClient.callbackUrl).catch((e) => {
if (
!e.message.includes('net::ERR_NAME_NOT_RESOLVED') &&
!e.message.includes('net::ERR_CERT_AUTHORITY_INVALID')
) {
throw e;
}
});
});
test('prompt=select_account account can be changed', async ({ page }) => {
const oidcClient = oidcClients.nextcloud;
const urlParams = createUrlParams(oidcClient);
urlParams.set('prompt', 'select_account');
await page.goto(`/authorize?${urlParams.toString()}`);
await page.getByRole('button', { name: 'Use a different account' }).click();
await expect(page.getByText('Do you want to sign in to Nextcloud')).toBeVisible();
(await passkeyUtil.init(page)).addPasskey('craig');
await page.getByRole('button', { name: 'Sign In' }).click();
await page.getByRole('button', { name: 'Sign In' }).click();
// Should redirect successfully to callback URL with code
await page.waitForURL(oidcClient.callbackUrl).catch((e) => {
if (
!e.message.includes('net::ERR_NAME_NOT_RESOLVED') &&
!e.message.includes('net::ERR_CERT_AUTHORITY_INVALID')
) {
throw e;
}
});
});
test('prompt=none with prompt=consent returns interaction_required', async ({ page }) => {
@@ -742,10 +849,8 @@ test.describe('OIDC prompt parameter', () => {
urlParams.set('prompt', 'none consent');
// Should redirect with error since both can't be satisfied
const redirectUrl = await oidcUtil.interceptCallbackRedirect(
page,
'/auth/callback',
() => page.goto(`/authorize?${urlParams.toString()}`).then(() => {})
const redirectUrl = await oidcUtil.interceptCallbackRedirect(page, '/auth/callback', () =>
page.goto(`/authorize?${urlParams.toString()}`).then(() => {})
);
expect(redirectUrl.searchParams.get('error')).toBe('interaction_required');
@@ -758,10 +863,8 @@ test.describe('OIDC prompt parameter', () => {
urlParams.set('prompt', 'none login');
// Should redirect with error since both can't be satisfied
const redirectUrl = await oidcUtil.interceptCallbackRedirect(
page,
'/auth/callback',
() => page.goto(`/authorize?${urlParams.toString()}`).then(() => {})
const redirectUrl = await oidcUtil.interceptCallbackRedirect(page, '/auth/callback', () =>
page.goto(`/authorize?${urlParams.toString()}`).then(() => {})
);
expect(redirectUrl.searchParams.get('error')).toBe('interaction_required');
@@ -774,10 +877,8 @@ test.describe('OIDC prompt parameter', () => {
urlParams.set('prompt', 'none select_account');
// Should redirect with error since both can't be satisfied
const redirectUrl = await oidcUtil.interceptCallbackRedirect(
page,
'/auth/callback',
() => page.goto(`/authorize?${urlParams.toString()}`).then(() => {})
const redirectUrl = await oidcUtil.interceptCallbackRedirect(page, '/auth/callback', () =>
page.goto(`/authorize?${urlParams.toString()}`).then(() => {})
);
expect(redirectUrl.searchParams.get('error')).toBe('interaction_required');