Compare commits

...

14 Commits

Author SHA1 Message Date
Elias Schneider
37c568b66c Merge remote-tracking branch 'origin/main' 2026-06-16 08:48:35 +02:00
github-actions[bot]
212c574a68 chore: update AAGUIDs (#1511)
Co-authored-by: stonith404 <58886915+stonith404@users.noreply.github.com>
2026-06-08 09:22:50 +02:00
Elias Schneider
420fd02c8c fix: PAR parameters not respected by authorize page 2026-06-05 11:39:14 +02:00
Elias Schneider
4f97cd4188 refactor: fix linter issues 2026-06-02 14:08:33 +02:00
Thibault NORMAND
68a5abdcca feat(oauth): add support for Pushed Authorization Requests (RFC9126) (#1404)
Co-authored-by: Elias Schneider <login@eliasschneider.com>
2026-06-02 14:02:12 +02:00
wollew
2eada149af docs: fix link to watchtower repo (#1500) 2026-06-01 11:42:00 +02:00
Elias Schneider
81e5aa2168 chore(translations): update translations via Crowdin (#1498) 2026-06-01 11:17:12 +02:00
Melvin Snijders
e88ff03ea2 fix: issues with loading new font (#1496) 2026-06-01 11:16:55 +02:00
Elias Schneider
fc42f6211d docs: add "needs more upvotes" label as default 2026-05-31 21:08:08 +02:00
Elias Schneider
bf9f76bbd5 docs: add PR template 2026-05-31 21:04:33 +02:00
Elias Schneider
fea933b62d docs: add AI Usage Policy to CONTRIBUTING.md 2026-05-31 20:55:05 +02:00
Elias Schneider
cdc77062f0 release: 2.8.0 2026-05-31 20:04:59 +02:00
Elias Schneider
7027296632 fix: restore cross-platform binary builds 2026-05-31 20:04:30 +02:00
Elias Schneider
e046a03364 chore: use fixed minor version of Go 2026-05-31 19:09:06 +02:00
58 changed files with 1285 additions and 279 deletions

View File

@@ -1,7 +1,8 @@
name: 🚀 Feature
description: "Submit a proposal for a new feature"
title: "🚀 Feature: "
type: 'Feature'
type: "Feature"
labels: ["needs more upvotes"]
body:
- type: textarea
id: feature-description

12
.github/pull_request_template.md vendored Normal file
View File

@@ -0,0 +1,12 @@
## What does this PR do?
## Screenshots
## AI Usage
## Contributing Guidelines
Have you read the [Contributing Guidelines](https://github.com/pocket-id/pocket-id/blob/main/CONTRIBUTING.md)?
- [ ] If I'm implementing a new feature, I have opened an issue first, or commented on an existing one, to discuss the implementation details.
- [ ] I have read the [AI Usage Guidelines](https://github.com/pocket-id/pocket-id/blob/main/CONTRIBUTING.md#ai-usage-policy).

View File

@@ -42,7 +42,7 @@ jobs:
# PR Template Checks
require-pr-template: true
strict-pr-template-sections: ""
strict-pr-template-sections: "Contributing Guidelines"
optional-pr-template-sections: "Issues"
max-additional-pr-template-sections: 3

View File

@@ -1 +1 @@
2.7.0
2.8.0

View File

@@ -1,3 +1,46 @@
## v2.8.0
### Bug Fixes
- delete refresh tokens on end-session to prevent reuse after logout ([#1458](https://github.com/pocket-id/pocket-id/pull/1458) by @wucm667)
- return 404 status code for `.well-known` routes if not found ([714b5b3](https://github.com/pocket-id/pocket-id/commit/714b5b3307bb012b94e66e8dcb1de93a2d62eceb) by @stonith404)
- add `email_verified` to reserved claims list ([8b22fca](https://github.com/pocket-id/pocket-id/commit/8b22fcaaa475984bb4c42306487035073d7c47e3) by @stonith404)
- reject unknown PKCE code challenge methods ([ce6bdb9](https://github.com/pocket-id/pocket-id/commit/ce6bdb9d7e6c0f1eaaa21ddb6d808e41088a38b8) by @stonith404)
- make stream of downloaded logos seekable for S3 checksum calculation ([cc9163f](https://github.com/pocket-id/pocket-id/commit/cc9163f577280d7c278dc744e20d0505dbe83ede) by @stonith404)
- scope confirmation wasn't shown if account selection was prompted ([272d147](https://github.com/pocket-id/pocket-id/commit/272d1479bd64b4a068bfe4dfa5ba4cd3c5e90505) by @stonith404)
- add support for unix socket mode in healthcheck ([a712196](https://github.com/pocket-id/pocket-id/commit/a71219637af1746e9faba5274b6095da676ec555) by @stonith404)
- restore cross-platform binary builds ([7027296](https://github.com/pocket-id/pocket-id/commit/7027296632ab82cd9930f2fbe60054c2421688c4) by @stonith404)
### Documentation
- update SECURITY.md ([bb5a111](https://github.com/pocket-id/pocket-id/commit/bb5a111e3d51ad56fc074df7083022e3d4e25369) by @stonith404)
### Features
- delete OAuth refresh token on RP initiated logout ([#1480](https://github.com/pocket-id/pocket-id/pull/1480) by @stonith404)
- remove EXIF/XMP metadata from uploaded images ([#1477](https://github.com/pocket-id/pocket-id/pull/1477) by @stonith404)
- add support for `response_mode=fragment` ([0c95b7c](https://github.com/pocket-id/pocket-id/commit/0c95b7c3cc171ed77b51f236009b5ff659da0bec) by @stonith404)
- add support for systemd socket activation ([#1479](https://github.com/pocket-id/pocket-id/pull/1479) by @deviant)
- improve design trough the whole application ([b3d40a4](https://github.com/pocket-id/pocket-id/commit/b3d40a476b35cfa2451749e9a3d307b7babaeb6b) by @stonith404)
### Other
- update AAGUIDs ([#1476](https://github.com/pocket-id/pocket-id/pull/1476) by @github-actions[bot])
- upgrade dependencies ([91c2ea2](https://github.com/pocket-id/pocket-id/commit/91c2ea2a6616a500922365d1789f1fa1ba66c112) by @stonith404)
- remove deprecated http2 package ([e56dc12](https://github.com/pocket-id/pocket-id/commit/e56dc124cee4d3c176c89e7d574ddfde089bcbd1) by @stonith404)
- apply go 1.26.0 syntax updated ([8ad95b8](https://github.com/pocket-id/pocket-id/commit/8ad95b8af17ba8cbb62168ed0bb54c87e3df379c) by @stonith404)
- delete refresh tokens on end-session to prevent reuse after logout ([b27a52a](https://github.com/pocket-id/pocket-id/commit/b27a52a5915228383dbdbf0a2c353452cd351d0f) by @stonith404)
- use dependabot for automatic dependency upgrades ([b9fdd53](https://github.com/pocket-id/pocket-id/commit/b9fdd530c0f370f2fccb6251d700aa6f9bdf4124) by @stonith404)
- fix invalid schema ([e8c398f](https://github.com/pocket-id/pocket-id/commit/e8c398ffbd6545f76c983f01556bb3b73d40a728) by @stonith404)
- update AAGUIDs ([#1487](https://github.com/pocket-id/pocket-id/pull/1487) by @github-actions[bot])
- use custom Playwright route for callback URL checks ([f134247](https://github.com/pocket-id/pocket-id/commit/f13424720b7f5da3978988f0f9169cf303971a13) by @stonith404)
- fix linter issues ([bc4f75c](https://github.com/pocket-id/pocket-id/commit/bc4f75c44f12a71380042ad4c140d41f4dddc7dd) by @stonith404)
- don't compare hashes of profile pictures ([9ad2bfc](https://github.com/pocket-id/pocket-id/commit/9ad2bfc7b30e0eb2d7ae1406874b8caba5a1f121) by @stonith404)
- run formatter ([0616aba](https://github.com/pocket-id/pocket-id/commit/0616abaf7f079d0a35543258e13aa5b4acb4adc0) by @stonith404)
- use fixed minor version of Go ([e046a03](https://github.com/pocket-id/pocket-id/commit/e046a03364f00001f8699aaa7e902b33c2661118) by @stonith404)
**Full Changelog**: https://github.com/pocket-id/pocket-id/compare/v2.7.0...v2.8.0
## v2.7.0
### Bug Fixes

View File

@@ -1,12 +1,35 @@
# Contributing
I am happy that you want to contribute to Pocket ID and help to make it better! All contributions are welcome, including issues, suggestions, pull requests and more.
We are happy that you want to contribute to Pocket ID and help to make it better!
## Before you start
Before starting to work on a new feature, please open an issue first, or comment on an existing one, to discuss the implementation details. This saves you time and avoids the disappointment of having a PR closed later because the change doesn't fit the project's direction.
### AI Usage Policy
We have nothing against using AI tools to assist your development. But we've seen a growing number of pull requests that appear to be fully or largely AI-generated and submitted without genuine human review. These are difficult and time-consuming for maintainers to review, and they often waste everyone's time.
To keep contributions reviewable and high-quality, please follow these guidelines when using AI tools.
#### Guidelines for Using AI Tools
1. **Understand every line.** You must be able to explain what your code does and why, in your own words. "The AI wrote it" is not an acceptable answer to a reviewer's question.
2. **Test before submitting.** Review and test all code manually, as a human, before opening a PR. Don't trust the AI's claim that it works.
3. **Write your own words.** Don't paste AI-generated text into issues, comments, or PR descriptions. Walls of generated text make discussions harder, not easier.
4. **Disclose your usage.** Note in the PR description how you used AI (see below).
PRs that appear to be low effort AI output may be closed without a detailed review.
#### Example disclosure
> Used GitHub Copilot for autocomplete, and asked an LLM to help draft the test cases in `parser_test.go`. I reviewed, edited, and tested everything myself and understand how it works.
> Used an LLM to generate the initial implementation of the new API endpoint, but I manually reviewed and tested it before submitting.
## Getting started
You've found a bug, have suggestion or something else, just create an issue on GitHub and we can get in touch.
## Submit a Pull Request
### Submit a Pull Request
Before you submit the pull request for review please ensure that
@@ -29,15 +52,15 @@ Before you submit the pull request for review please ensure that
- Your pull request has a detailed description
- You run `pnpm format` to format the code
## Development Environment
### Development Environment
Pocket ID consists of a frontend and backend. In production the frontend gets statically served by the backend, but in development they run as separate processes to enable hot reloading.
There are two ways to get the development environment setup:
### 1. Install required tools
#### 1. Install required tools
#### With Dev Containers
##### With Dev Containers
If you use [Dev Containers](https://code.visualstudio.com/docs/remote/containers) in VS Code, you don't need to install anything manually, just follow the steps below.
@@ -46,7 +69,7 @@ If you use [Dev Containers](https://code.visualstudio.com/docs/remote/containers
3. VS Code will detect .devcontainer and will prompt you to open the folder in devcontainer
4. If the auto prompt does not work, hit `F1` and select `Dev Containers: Open Folder in Container.`, then select the pocket-id repo root folder and it'll open in container.
#### Without Dev Containers
##### Without Dev Containers
If you don't use Dev Containers, you need to install the following tools manually:
@@ -54,9 +77,9 @@ If you don't use Dev Containers, you need to install the following tools manuall
- [Go](https://golang.org/doc/install) >= 1.26
- [Git](https://git-scm.com/downloads)
### 2. Setup
#### 2. Setup
#### Backend
##### Backend
The backend is built with [Gin](https://gin-gonic.com) and written in Go. To set it up, follow these steps:
@@ -64,7 +87,7 @@ The backend is built with [Gin](https://gin-gonic.com) and written in Go. To set
2. Copy the `.env.development-example` file to `.env` and edit the variables as needed
3. Start the backend with `go run -tags exclude_frontend ./cmd`
### Frontend
##### Frontend
The frontend is built with [SvelteKit](https://kit.svelte.dev) and written in TypeScript. To set it up, follow these steps:
@@ -77,9 +100,13 @@ You're all set! The application is now listening on `localhost:3000`. The backen
### Testing
If you are contributing to a new feature please ensure that you add tests for it.
#### End-to-end tests
We are using [Playwright](https://playwright.dev) for end-to-end testing.
If you are contributing to a new feature please ensure that you add tests for it. The tests are located in the `tests` folder at the root of the project.
The tests are located in the `tests` folder at the root of the project.
The tests can be run like this:
@@ -93,3 +120,9 @@ The tests can be run like this:
5. Run the tests with `pnpm dlx playwright test` or from the root project folder `pnpm test`
If you make any changes to the application, you have to rebuild the test environment by running `docker compose up -d --build` again.
#### Unit tests
In the backend we are using unit tests with the built-in Go testing framework. The tests are located in the same folder as the code they are testing and have the `_test.go` suffix.
To run the tests, simply run `go test ./...` from the root of the `backend` folder.

View File

@@ -5,7 +5,7 @@
It's recommended to always use the latest version of Pocket ID. We will provide security updates for the latest version.
> [!NOTE]
> Updates can be automated with e.g [Watchtower](https://github.com/containrrr/watchtower). Upgrading between non major versions is safe but you shouldn't upgrade between major versions before checking the release notes.
> Updates can be automated with e.g [Watchtower](https://github.com/nicholas-fedor/watchtower/). Upgrading between non major versions is safe but you shouldn't upgrade between major versions before checking the release notes.
## Reporting a Vulnerability

View File

@@ -6,4 +6,7 @@ APP_ENV=development
# In the development environment the backend gets proxied by the frontend
APP_URL=http://localhost:3000
PORT=1411
MAXMIND_LICENSE_KEY=your_license_key
MAXMIND_LICENSE_KEY=your_license_key
# Set the ENCRYPTION_KEY to a random sequence of characters, for example generated with `openssl rand -base64 32`
ENCRYPTION_KEY=

View File

@@ -87,10 +87,13 @@ func RegisterFrontend(router *gin.Engine, oidcService *service.OidcService) erro
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")
// For an authorization request that responds via response_mode=form_post, the
// consent page auto-submits a form to the client's callback, so that callback
// must be allowed in the CSP form-action directive
isAuthPostRequest, redirectURI := isOAuth2AuthorizationPostRequest(c, oidcService)
if isAuthPostRequest {
clientID := c.Query("client_id")
// In that case, we need to validate and allow form submissions to the redirect_uri
validatedRedirectURI, err := oidcService.ResolveAllowedCallbackURL(c.Request.Context(), clientID, redirectURI)
if err == nil {
c.Header("Content-Security-Policy", middleware.BuildCSP(nonce, validatedRedirectURI))
@@ -113,17 +116,21 @@ func RegisterFrontend(router *gin.Engine, oidcService *service.OidcService) erro
}
rateLimitMiddleware := middleware.NewRateLimitMiddleware().Add(rate.Every(300*time.Millisecond), 50)
router.NoRoute(rateLimitOnlyForOAuth2AuthorizationPostRequest(rateLimitMiddleware, distFS), handler)
router.NoRoute(rateLimitOnlyForOAuth2AuthorizationPostRequest(rateLimitMiddleware, oidcService, distFS), handler)
return nil
}
func rateLimitOnlyForOAuth2AuthorizationPostRequest(rateLimitMiddleware gin.HandlerFunc, distFS fs.FS) gin.HandlerFunc {
func rateLimitOnlyForOAuth2AuthorizationPostRequest(rateLimitMiddleware gin.HandlerFunc, oidcService *service.OidcService, distFS fs.FS) gin.HandlerFunc {
return func(c *gin.Context) {
path := strings.TrimPrefix(c.Request.URL.Path, "/")
if isSPARequest(path, distFS) && isOAuth2AuthorizationPostRequest(c) {
rateLimitMiddleware(c)
return
if isSPARequest(path, distFS) {
isAuthPostRequest, _ := isOAuth2AuthorizationPostRequest(c, oidcService)
if isAuthPostRequest {
rateLimitMiddleware(c)
return
}
}
c.Next()
@@ -132,12 +139,29 @@ func rateLimitOnlyForOAuth2AuthorizationPostRequest(rateLimitMiddleware gin.Hand
// 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 {
func isOAuth2AuthorizationPostRequest(c *gin.Context, oidcService *service.OidcService) (bool, string) {
responseMode := c.Query("response_mode")
redirectURI := c.Query("redirect_uri")
clientID := c.Query("client_id")
requestUri := c.Query("request_uri")
return responseMode == "form_post" && redirectURI != "" && clientID != ""
if c.Request.URL.Path != "/authorize" {
return false, ""
}
if requestUri != "" && clientID != "" {
par, err := oidcService.GetPushedAuthorizationRequest(c.Request.Context(), clientID, requestUri)
if err != nil {
return false, ""
}
responseMode = par.Parameters.ResponseMode
redirectURI = par.Parameters.RedirectURI
}
ok := responseMode == "form_post" && redirectURI != "" && clientID != ""
return ok, redirectURI
}
func isSPARequest(path string, distFS fs.FS) bool {

View File

@@ -43,7 +43,7 @@ func TestRateLimitOnlyForOAuth2AuthorizationPostRequest(t *testing.T) {
middleware := rateLimitOnlyForOAuth2AuthorizationPostRequest(func(c *gin.Context) {
rateLimited = true
c.Abort()
}, distFS)
}, nil, distFS)
router := gin.New()
router.NoRoute(
@@ -67,7 +67,7 @@ func TestRateLimitOnlyForOAuth2AuthorizationPostRequest(t *testing.T) {
middleware := rateLimitOnlyForOAuth2AuthorizationPostRequest(func(c *gin.Context) {
rateLimited = true
c.Abort()
}, distFS)
}, nil, distFS)
router := gin.New()
router.NoRoute(
@@ -91,7 +91,7 @@ func TestRateLimitOnlyForOAuth2AuthorizationPostRequest(t *testing.T) {
middleware := rateLimitOnlyForOAuth2AuthorizationPostRequest(func(c *gin.Context) {
rateLimited = true
c.Abort()
}, distFS)
}, nil, distFS)
router := gin.New()
router.NoRoute(
@@ -108,4 +108,30 @@ func TestRateLimitOnlyForOAuth2AuthorizationPostRequest(t *testing.T) {
assert.False(t, rateLimited)
assert.True(t, nextCalled)
})
t.Run("does not rate limit non-authorize spa path with form_post params", func(t *testing.T) {
rateLimited := false
nextCalled := false
// oidcService is nil: a non-/authorize path is rejected by the path guard
// before the request_uri branch that would dereference it.
middleware := rateLimitOnlyForOAuth2AuthorizationPostRequest(func(c *gin.Context) {
rateLimited = true
c.Abort()
}, nil, distFS)
router := gin.New()
router.NoRoute(
middleware,
func(c *gin.Context) {
nextCalled = true
},
)
recorder := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/settings?response_mode=form_post&client_id=test&redirect_uri=https://example.com/callback", nil)
router.ServeHTTP(recorder, req)
assert.False(t, rateLimited)
assert.True(t, nextCalled)
})
}

View File

@@ -1,6 +1,6 @@
module github.com/pocket-id/pocket-id/backend
go 1.26.0
go 1.26
require (
github.com/aws/aws-sdk-go-v2 v1.41.7

View File

@@ -9,13 +9,11 @@ import (
"net"
"net/http"
"os"
"strconv"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/coreos/go-systemd/activation"
"github.com/fsnotify/fsnotify"
sloggin "github.com/gin-contrib/slog"
"github.com/gin-gonic/gin"
@@ -211,64 +209,6 @@ func initServerProtocols() (*http.Protocols, *tls.Config, *tlsCertProvider, erro
return protocols, tlsConfig, certProvider, nil
}
type socket struct {
addr string
listener net.Listener
}
func systemdSocket() (*socket, error) {
listeners, err := activation.Listeners()
if err != nil {
return nil, fmt.Errorf("failed to receive socket from systemd: %w", err)
}
if len(listeners) == 0 {
return nil, errors.New("did not receive any sockets from systemd")
}
if len(listeners) > 1 {
return nil, errors.New("received too many sockets from systemd")
}
return &socket{"(systemd)", listeners[0]}, nil
}
func unixSocket() (*socket, error) {
addr := common.EnvConfig.UnixSocket
os.Remove(addr) // remove dangling the socket file to avoid file-exist error
listener, err := net.Listen("unix", addr) //nolint:noctx
if err != nil {
return nil, fmt.Errorf("failed to create UNIX socket: %w", err)
}
if common.EnvConfig.UnixSocketMode != "" {
mode, err := strconv.ParseUint(common.EnvConfig.UnixSocketMode, 8, 32)
if err != nil {
listener.Close()
return nil, fmt.Errorf("failed to parse UNIX socket mode '%s': %w", common.EnvConfig.UnixSocketMode, err)
}
if err := os.Chmod(addr, os.FileMode(mode)); err != nil {
listener.Close()
return nil, fmt.Errorf("failed to set UNIX socket mode '%s': %w", common.EnvConfig.UnixSocketMode, err)
}
}
return &socket{addr, listener}, nil
}
func tcpSocket() (*socket, error) {
addr := net.JoinHostPort(common.EnvConfig.Host, common.EnvConfig.Port)
listener, err := net.Listen("tcp", addr) //nolint:noctx
if err != nil {
return nil, fmt.Errorf("failed to create TCP socket: %w", err)
}
return &socket{addr, listener}, nil
}
func newHTTPServer(r *gin.Engine, protocols *http.Protocols) *http.Server {
return &http.Server{
MaxHeaderBytes: 1 << 20,

View File

@@ -0,0 +1,51 @@
package bootstrap
import (
"fmt"
"net"
"os"
"strconv"
"github.com/pocket-id/pocket-id/backend/internal/common"
)
type socket struct {
addr string
listener net.Listener
}
func unixSocket() (*socket, error) {
addr := common.EnvConfig.UnixSocket
os.Remove(addr) // remove dangling the socket file to avoid file-exist error
listener, err := net.Listen("unix", addr) //nolint:noctx
if err != nil {
return nil, fmt.Errorf("failed to create UNIX socket: %w", err)
}
if common.EnvConfig.UnixSocketMode != "" {
mode, err := strconv.ParseUint(common.EnvConfig.UnixSocketMode, 8, 32)
if err != nil {
listener.Close()
return nil, fmt.Errorf("failed to parse UNIX socket mode '%s': %w", common.EnvConfig.UnixSocketMode, err)
}
if err := os.Chmod(addr, os.FileMode(mode)); err != nil {
listener.Close()
return nil, fmt.Errorf("failed to set UNIX socket mode '%s': %w", common.EnvConfig.UnixSocketMode, err)
}
}
return &socket{addr, listener}, nil
}
func tcpSocket() (*socket, error) {
addr := net.JoinHostPort(common.EnvConfig.Host, common.EnvConfig.Port)
listener, err := net.Listen("tcp", addr) //nolint:noctx
if err != nil {
return nil, fmt.Errorf("failed to create TCP socket: %w", err)
}
return &socket{addr, listener}, nil
}

View File

@@ -0,0 +1,27 @@
//go:build linux
package bootstrap
import (
"errors"
"fmt"
"github.com/coreos/go-systemd/activation"
)
func systemdSocket() (*socket, error) {
listeners, err := activation.Listeners()
if err != nil {
return nil, fmt.Errorf("failed to receive socket from systemd: %w", err)
}
if len(listeners) == 0 {
return nil, errors.New("did not receive any sockets from systemd")
}
if len(listeners) > 1 {
return nil, errors.New("received too many sockets from systemd")
}
return &socket{"(systemd)", listeners[0]}, nil
}

View File

@@ -0,0 +1,9 @@
//go:build !linux
package bootstrap
import "errors"
func systemdSocket() (*socket, error) {
return nil, errors.New("systemd socket activation is only supported on Linux")
}

View File

@@ -354,6 +354,29 @@ func (e ImageNotFoundError) Error() string { return "Image not found" }
func (e ImageNotFoundError) HttpStatusCode() int { return http.StatusNotFound }
type OidcPARNotSupportedForPublicClientsError struct{}
func (e OidcPARNotSupportedForPublicClientsError) Error() string {
return "pushed authorization requests are not supported for public clients"
}
func (e OidcPARNotSupportedForPublicClientsError) HttpStatusCode() int {
return http.StatusBadRequest
}
type OidcInvalidRequestURIError struct{}
func (e OidcInvalidRequestURIError) Error() string {
return "invalid or expired request_uri"
}
func (e OidcInvalidRequestURIError) HttpStatusCode() int { return http.StatusBadRequest }
type OidcPARRequiredError struct{}
func (e OidcPARRequiredError) Error() string {
return "this client requires pushed authorization requests"
}
func (e OidcPARRequiredError) HttpStatusCode() int { return http.StatusBadRequest }
type InvalidEmailVerificationTokenError struct{}
func (e InvalidEmailVerificationTokenError) Error() string { return "Invalid email verification token" }

View File

@@ -33,8 +33,10 @@ func NewOidcController(group *gin.RouterGroup, authMiddleware *middleware.AuthMi
group.POST("/oidc/authorize", authMiddleware.WithAdminNotRequired().Add(), oc.authorizeHandler)
group.POST("/oidc/authorization-required", authMiddleware.WithAdminNotRequired().Add(), oc.authorizationConfirmationRequiredHandler)
group.GET("/oidc/par-request-info", authMiddleware.WithAdminNotRequired().Add(), oc.parRequestInfoHandler)
group.POST("/oidc/token", oc.createTokensHandler)
group.POST("/oidc/par", oc.pushedAuthorizationRequestHandler)
group.GET("/oidc/userinfo", oc.userInfoHandler)
group.POST("/oidc/userinfo", oc.userInfoHandler)
group.POST("/oidc/end-session", authMiddleware.WithAdminNotRequired().WithSuccessOptional().Add(), oc.EndSessionHandler)
@@ -154,13 +156,50 @@ func (oc *OidcController) authorizationConfirmationRequiredHandler(c *gin.Contex
return
}
hasAuthorizedClient, err := oc.oidcService.HasAuthorizedClient(c.Request.Context(), input.ClientID, c.GetString("userID"), input.Scope)
authorizationRequired, scope, err := oc.oidcService.AuthorizationRequired(c.Request.Context(), input.ClientID, c.GetString("userID"), input.Scope, input.RequestURI)
if err != nil {
_ = c.Error(err)
return
}
c.JSON(http.StatusOK, gin.H{"authorizationRequired": !hasAuthorizedClient})
c.JSON(http.StatusOK, gin.H{"authorizationRequired": authorizationRequired, "scope": scope})
}
// parRequestInfoHandler godoc
// @Summary Resolve stored authorization request parameters
// @Description Resolve the parameters of a stored request_uri request so the consent page can render the requested scope and build the final redirect. Does not consume the request.
// @Tags OIDC
// @Produce json
// @Param client_id query string true "Client ID"
// @Param request_uri query string true "Request URI returned from the PAR endpoint"
// @Success 200 {object} dto.OidcAuthorizeRequestInfoDto "Resolved authorization request parameters"
// @Router /api/oidc/par-request-info [get]
func (oc *OidcController) parRequestInfoHandler(c *gin.Context) {
clientID := c.Query("client_id")
requestURI := c.Query("request_uri")
if clientID == "" {
_ = c.Error(&common.ValidationError{Message: "client_id is required"})
return
}
if requestURI == "" {
_ = c.Error(&common.ValidationError{Message: "request_uri is required"})
return
}
info, err := oc.oidcService.GetPushedAuthorizationRequest(c.Request.Context(), clientID, requestURI)
if err != nil {
_ = c.Error(err)
return
}
var infoDto dto.OidcAuthorizeRequestInfoDto
err = dto.MapStruct(info.Parameters, &infoDto)
if err != nil {
_ = c.Error(err)
return
}
c.JSON(http.StatusOK, infoDto)
}
// createTokensHandler godoc
@@ -232,6 +271,49 @@ func (oc *OidcController) createTokensHandler(c *gin.Context) {
})
}
// pushedAuthorizationRequestHandler godoc
// @Summary Pushed Authorization Request (PAR)
// @Description RFC 9126: Push authorization request parameters and receive a request_uri. Only confidential clients may use this endpoint.
// @Tags OIDC
// @Accept application/x-www-form-urlencoded
// @Produce json
// @Success 201 {object} dto.OidcPARResponseDto
// @Router /api/oidc/par [post]
func (oc *OidcController) pushedAuthorizationRequestHandler(c *gin.Context) {
// Per RFC 9126, parameters MUST be passed in the request body
c.Request.URL.RawQuery = ""
var input dto.OidcPARRequestDto
if err := c.ShouldBind(&input); err != nil {
_ = c.Error(err)
return
}
// Client id and secret can also be passed over the Authorization header
if input.ClientID == "" && input.ClientSecret == "" {
input.ClientID, input.ClientSecret, _ = utils.OAuthClientBasicAuth(c.Request)
}
creds := service.ClientAuthCredentials{
ClientID: input.ClientID,
ClientSecret: input.ClientSecret,
ClientAssertion: input.ClientAssertion,
ClientAssertionType: input.ClientAssertionType,
}
requestURI, expiresIn, err := oc.oidcService.CreatePushedAuthorizationRequest(c.Request.Context(), creds, input)
if err != nil {
_ = c.Error(err)
return
}
// RFC 9126 §2.2 requires HTTP 201 Created for successful PAR responses
c.JSON(http.StatusCreated, dto.OidcPARResponseDto{
RequestURI: requestURI,
ExpiresIn: expiresIn,
})
}
// userInfoHandler godoc
// @Summary Get user information
// @Description Get user information based on the access token

View File

@@ -92,7 +92,9 @@ func (wkc *WellKnownController) computeOIDCConfiguration() ([]byte, error) {
"authorization_response_iss_parameter_supported": true,
"code_challenge_methods_supported": []string{"plain", "S256"},
"prompt_values_supported": []string{"none", "login", "consent", "select_account"},
"token_endpoint_auth_methods_supported": []string{"client_secret_basic", "client_secret_post", "none"},
"token_endpoint_auth_methods_supported": []string{"client_secret_basic", "client_secret_post", "private_key_jwt", "none"},
"pushed_authorization_request_endpoint": internalAppUrl + "/api/oidc/par",
"require_pushed_authorization_requests": false,
}
return json.Marshal(config)
}

View File

@@ -13,12 +13,13 @@ type OidcClientMetaDataDto struct {
type OidcClientDto struct {
OidcClientMetaDataDto
CallbackURLs []string `json:"callbackURLs"`
LogoutCallbackURLs []string `json:"logoutCallbackURLs"`
IsPublic bool `json:"isPublic"`
PkceEnabled bool `json:"pkceEnabled"`
Credentials OidcClientCredentialsDto `json:"credentials"`
IsGroupRestricted bool `json:"isGroupRestricted"`
CallbackURLs []string `json:"callbackURLs"`
LogoutCallbackURLs []string `json:"logoutCallbackURLs"`
IsPublic bool `json:"isPublic"`
PkceEnabled bool `json:"pkceEnabled"`
RequiresPushedAuthorizationRequests bool `json:"requiresPushedAuthorizationRequests"`
Credentials OidcClientCredentialsDto `json:"credentials"`
IsGroupRestricted bool `json:"isGroupRestricted"`
}
type OidcClientWithAllowedUserGroupsDto struct {
@@ -32,19 +33,20 @@ type OidcClientWithAllowedGroupsCountDto struct {
}
type OidcClientUpdateDto struct {
Name string `json:"name" binding:"required,max=50" unorm:"nfc"`
CallbackURLs []string `json:"callbackURLs" binding:"omitempty,dive,callback_url_pattern"`
LogoutCallbackURLs []string `json:"logoutCallbackURLs" binding:"omitempty,dive,callback_url_pattern"`
IsPublic bool `json:"isPublic"`
PkceEnabled bool `json:"pkceEnabled"`
RequiresReauthentication bool `json:"requiresReauthentication"`
Credentials OidcClientCredentialsDto `json:"credentials"`
LaunchURL *string `json:"launchURL" binding:"omitempty,url"`
HasLogo bool `json:"hasLogo"`
HasDarkLogo bool `json:"hasDarkLogo"`
LogoURL *string `json:"logoUrl"`
DarkLogoURL *string `json:"darkLogoUrl"`
IsGroupRestricted bool `json:"isGroupRestricted"`
Name string `json:"name" binding:"required,max=50" unorm:"nfc"`
CallbackURLs []string `json:"callbackURLs" binding:"omitempty,dive,callback_url_pattern"`
LogoutCallbackURLs []string `json:"logoutCallbackURLs" binding:"omitempty,dive,callback_url_pattern"`
IsPublic bool `json:"isPublic"`
PkceEnabled bool `json:"pkceEnabled"`
RequiresReauthentication bool `json:"requiresReauthentication"`
RequiresPushedAuthorizationRequests bool `json:"requiresPushedAuthorizationRequests"`
Credentials OidcClientCredentialsDto `json:"credentials"`
LaunchURL *string `json:"launchURL" binding:"omitempty,url"`
HasLogo bool `json:"hasLogo"`
HasDarkLogo bool `json:"hasDarkLogo"`
LogoURL *string `json:"logoUrl"`
DarkLogoURL *string `json:"darkLogoUrl"`
IsGroupRestricted bool `json:"isGroupRestricted"`
}
type OidcClientCreateDto struct {
@@ -65,14 +67,36 @@ type OidcClientFederatedIdentityDto struct {
type AuthorizeOidcClientRequestDto struct {
ClientID string `json:"clientID" binding:"required"`
Scope string `json:"scope" binding:"required"`
CallbackURL string `json:"callbackURL" binding:"omitempty,callback_url"`
Scope string `json:"scope" binding:"required_without=RequestURI"`
CallbackURL string `json:"callbackURL"`
Nonce string `json:"nonce"`
CodeChallenge string `json:"codeChallenge"`
CodeChallengeMethod string `json:"codeChallengeMethod"`
ReauthenticationToken string `json:"reauthenticationToken"`
Prompt string `json:"prompt"`
ResponseMode string `json:"responseMode" binding:"omitempty,response_mode"`
RequestURI string `json:"requestURI"`
}
type OidcPARRequestDto struct {
ClientID string `form:"client_id"`
ClientSecret string `form:"client_secret"`
ClientAssertion string `form:"client_assertion"`
ClientAssertionType string `form:"client_assertion_type"`
ResponseType string `form:"response_type" binding:"required"`
Scope string `form:"scope" binding:"required"`
RedirectURI string `form:"redirect_uri"`
State string `form:"state"`
Nonce string `form:"nonce"`
CodeChallenge string `form:"code_challenge"`
CodeChallengeMethod string `form:"code_challenge_method"`
Prompt string `form:"prompt"`
ResponseMode string `form:"response_mode" binding:"omitempty,response_mode"`
}
type OidcPARResponseDto struct {
RequestURI string `json:"request_uri"`
ExpiresIn int `json:"expires_in"`
}
type AuthorizeOidcClientResponseDto struct {
@@ -82,8 +106,18 @@ type AuthorizeOidcClientResponseDto struct {
}
type AuthorizationRequiredDto struct {
ClientID string `json:"clientID" binding:"required"`
Scope string `json:"scope" binding:"required"`
ClientID string `json:"clientID" binding:"required"`
Scope string `json:"scope"`
RequestURI string `json:"requestURI"`
}
type OidcAuthorizeRequestInfoDto struct {
Scope string `json:"scope"`
RedirectURI string `json:"redirectURI"`
State string `json:"state,omitempty"`
Nonce string `json:"nonce,omitempty"`
ResponseMode string `json:"responseMode,omitempty"`
Prompt string `json:"prompt,omitempty"`
}
type OidcCreateTokensDto struct {

View File

@@ -38,6 +38,7 @@ func (s *Scheduler) RegisterDbCleanupJobs(ctx context.Context, db *gorm.DB) erro
s.RegisterJob(ctx, "ClearOidcRefreshTokens", jobDefWithJitter(24*time.Hour), jobs.clearOidcRefreshTokens, service.RegisterJobOpts{RunImmediately: true, BackOff: newBackOff()}),
s.RegisterJob(ctx, "ClearReauthenticationTokens", jobDefWithJitter(24*time.Hour), jobs.clearReauthenticationTokens, service.RegisterJobOpts{RunImmediately: true, BackOff: newBackOff()}),
s.RegisterJob(ctx, "ClearAuditLogs", jobDefWithJitter(24*time.Hour), jobs.clearAuditLogs, service.RegisterJobOpts{RunImmediately: true, BackOff: newBackOff()}),
s.RegisterJob(ctx, "ClearOidcPushedAuthorizationRequests", jobDefWithJitter(24*time.Hour), jobs.clearOidcPushedAuthorizationRequests, service.RegisterJobOpts{RunImmediately: true, BackOff: newBackOff()}),
)
}
@@ -146,6 +147,20 @@ func (j *DbCleanupJobs) clearAuditLogs(ctx context.Context) error {
return nil
}
// clearOidcPushedAuthorizationRequests deletes PAR records that have expired without being consumed
func (j *DbCleanupJobs) clearOidcPushedAuthorizationRequests(ctx context.Context) error {
st := j.db.
WithContext(ctx).
Delete(&model.OidcPushedAuthorizationRequest{}, "expires_at < ?", datatype.DateTime(time.Now()))
if st.Error != nil {
return fmt.Errorf("failed to clean expired pushed authorization requests: %w", st.Error)
}
slog.InfoContext(ctx, "Cleaned expired pushed authorization requests", slog.Int64("count", st.RowsAffected))
return nil
}
// ClearEmailVerificationTokens deletes email verification tokens that have expired
func (j *DbCleanupJobs) clearEmailVerificationTokens(ctx context.Context) error {
st := j.db.

View File

@@ -48,18 +48,19 @@ type OidcAuthorizationCode struct {
type OidcClient struct {
Base
Name string `sortable:"true"`
Secret string
CallbackURLs UrlList
LogoutCallbackURLs UrlList
ImageType *string
DarkImageType *string
IsPublic bool
PkceEnabled bool `sortable:"true" filterable:"true"`
RequiresReauthentication bool `sortable:"true" filterable:"true"`
Credentials OidcClientCredentials
LaunchURL *string
IsGroupRestricted bool `sortable:"true" filterable:"true"`
Name string `sortable:"true"`
Secret string
CallbackURLs UrlList
LogoutCallbackURLs UrlList
ImageType *string
DarkImageType *string
IsPublic bool
PkceEnabled bool `sortable:"true" filterable:"true"`
RequiresReauthentication bool `sortable:"true" filterable:"true"`
RequiresPushedAuthorizationRequests bool `sortable:"true" filterable:"true"`
Credentials OidcClientCredentials
LaunchURL *string
IsGroupRestricted bool `sortable:"true" filterable:"true"`
AllowedUserGroups []UserGroup `gorm:"many2many:oidc_clients_allowed_user_groups;"`
CreatedByID *string
@@ -157,3 +158,32 @@ type OidcDeviceCode struct {
ClientID string
Client OidcClient
}
type OidcPushedAuthorizationRequest struct {
Base
RequestURI string
ClientID string
Parameters OidcAuthorizationRequestParameters
ExpiresAt datatype.DateTime
}
type OidcAuthorizationRequestParameters struct { //nolint:recvcheck
Scope string `json:"scope,omitempty"`
RedirectURI string `json:"redirect_uri,omitempty"`
State string `json:"state,omitempty"`
Nonce string `json:"nonce,omitempty"`
CodeChallenge string `json:"code_challenge,omitempty"`
CodeChallengeMethod string `json:"code_challenge_method,omitempty"`
ResponseType string `json:"response_type,omitempty"`
Prompt string `json:"prompt,omitempty"`
ResponseMode string `json:"response_mode,omitempty"`
}
func (p *OidcAuthorizationRequestParameters) Scan(value any) error {
return utils.UnmarshalJSONFromDatabase(p, value)
}
func (p OidcAuthorizationRequestParameters) Value() (driver.Value, error) {
return json.Marshal(p)
}

View File

@@ -236,6 +236,15 @@ func (s *TestService) SeedDatabase(baseURL string) error {
userGroups[1],
},
},
{
Base: model.Base{
ID: "a1b2c3d4-e5f6-7890-abcd-ef0000000001",
},
Name: "PAR Test Client",
Secret: "$2a$10$9dypwot8nGuCjT6wQWWpJOckZfRprhe2EkwpKizxS/fpVHrOLEJHC", // w2mUeZISmEvIDMEDvpY0PnxQIpj1m3zY
CallbackURLs: model.UrlList{"http://par-client/auth/callback"},
CreatedByID: new(users[0].ID),
},
}
for _, client := range oidcClients {
if err := tx.Create(&client).Error; err != nil {
@@ -310,6 +319,12 @@ func (s *TestService) SeedDatabase(baseURL string) error {
ClientID: oidcClients[3].ID,
LastUsedAt: datatype.DateTime(time.Date(2025, 8, 12, 12, 0, 0, 0, time.UTC)),
},
{
Scope: "openid profile email",
UserID: users[0].ID,
ClientID: oidcClients[5].ID,
LastUsedAt: datatype.DateTime(time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC)),
},
}
for _, userAuthorizedClient := range userAuthorizedClients {
if err := tx.Create(&userAuthorizedClient).Error; err != nil {

View File

@@ -48,6 +48,9 @@ const (
AccessTokenDuration = time.Hour
RefreshTokenDuration = 30 * 24 * time.Hour // 30 days
DeviceCodeDuration = 15 * time.Minute
PARDuration = 90 * time.Second
parRequestURIPrefix = "urn:ietf:params:oauth:request_uri:"
)
type OidcService struct {
@@ -138,6 +141,18 @@ func (s *OidcService) Authorize(ctx context.Context, input dto.AuthorizeOidcClie
return "", "", err
}
// If the client requires PAR, a request_uri must be provided
if client.RequiresPushedAuthorizationRequests && input.RequestURI == "" {
return "", "", &common.OidcPARRequiredError{}
}
// If a request_uri is provided, consume the stored PAR and overwrite input fields
if input.RequestURI != "" {
if err := s.applyPushedAuthorizationRequest(ctx, tx, &input); err != nil {
return "", "", err
}
}
// If the client is not public, the code challenge must be provided
if client.IsPublic && input.CodeChallenge == "" {
return "", "", &common.OidcMissingCodeChallengeError{}
@@ -241,11 +256,48 @@ func (s *OidcService) Authorize(ctx context.Context, input dto.AuthorizeOidcClie
return code, callbackURL, nil
}
// applyPushedAuthorizationRequest consumes the stored PAR for the given request_uri
// and overwrites the corresponding fields on input.
func (s *OidcService) applyPushedAuthorizationRequest(ctx context.Context, tx *gorm.DB, input *dto.AuthorizeOidcClientRequestDto) error {
parMeta, err := s.getAndConsumePushedAuthorizationRequest(ctx, tx, input.ClientID, input.RequestURI)
if err != nil {
return err
}
par := parMeta.Parameters
input.Scope = par.Scope
input.CallbackURL = par.RedirectURI
input.Nonce = par.Nonce
input.Prompt = par.Prompt
input.CodeChallenge = par.CodeChallenge
input.CodeChallengeMethod = par.CodeChallengeMethod
return nil
}
// HasAuthorizedClient checks if the user has already authorized the client with the given scope
func (s *OidcService) HasAuthorizedClient(ctx context.Context, clientID, userID, scope string) (bool, error) {
return s.hasAuthorizedClientInternal(ctx, clientID, userID, scope, s.db)
}
// AuthorizationRequired reports whether the user must confirm authorization for the client.
func (s *OidcService) AuthorizationRequired(ctx context.Context, clientID, userID, scope, requestURI string) (required bool, resolvedScope string, err error) {
if requestURI != "" {
par, err := s.getPushedAuthorizationRequestInternal(ctx, s.db, clientID, requestURI)
if err != nil {
return false, "", err
}
scope = par.Parameters.Scope
}
hasAuthorized, err := s.hasAuthorizedClientInternal(ctx, clientID, userID, scope, s.db)
if err != nil {
return false, "", err
}
return !hasAuthorized, scope, nil
}
func (s *OidcService) hasAuthorizedClientInternal(ctx context.Context, clientID, userID, scope string, tx *gorm.DB) (bool, error) {
var userAuthorizedOidcClient model.UserAuthorizedOidcClient
err := tx.
@@ -925,6 +977,8 @@ func updateOIDCClientModelFromDto(client *model.OidcClient, input *dto.OidcClien
// PKCE is required for public clients
client.PkceEnabled = input.IsPublic || input.PkceEnabled
client.RequiresReauthentication = input.RequiresReauthentication
// PAR is not available for public clients, so ignore the flag if the client is public
client.RequiresPushedAuthorizationRequests = !input.IsPublic && input.RequiresPushedAuthorizationRequests
client.LaunchURL = input.LaunchURL
client.IsGroupRestricted = input.IsGroupRestricted
@@ -1318,6 +1372,116 @@ func codeChallengeMethodIsSha256(codeChallengeMethod string) (bool, error) {
}
}
// CreatePushedAuthorizationRequest validates and stores authorization parameters for PAR (RFC 9126).
// Only confidential clients (non-public) may use this endpoint.
func (s *OidcService) CreatePushedAuthorizationRequest(ctx context.Context, creds ClientAuthCredentials, input dto.OidcPARRequestDto) (requestURI string, expiresIn int, err error) {
// Public clients are not allowed, but we allow them in this step for better error messages
client, err := s.verifyClientCredentialsInternal(ctx, s.db, creds, true)
if err != nil {
return "", 0, err
}
// Reject public clients here
if client.IsPublic {
return "", 0, &common.OidcPARNotSupportedForPublicClientsError{}
}
if input.ResponseType != "code" {
return "", 0, common.NewOidcInvalidRequestError("unsupported response_type: only 'code' is supported")
}
// Validate redirect_uri at push time (BCP requirement)
if _, err = s.getCallbackURL(client, input.RedirectURI, s.db, ctx); err != nil {
return "", 0, err
}
randomSuffix, err := utils.GenerateRandomAlphanumericString(32)
if err != nil {
return "", 0, fmt.Errorf("failed to generate request URI: %w", err)
}
requestURI = parRequestURIPrefix + randomSuffix
par := model.OidcPushedAuthorizationRequest{
RequestURI: requestURI,
ClientID: client.ID,
ExpiresAt: datatype.DateTime(time.Now().Add(PARDuration)),
Parameters: model.OidcAuthorizationRequestParameters{
Scope: input.Scope,
RedirectURI: input.RedirectURI,
State: input.State,
Nonce: input.Nonce,
CodeChallenge: input.CodeChallenge,
CodeChallengeMethod: input.CodeChallengeMethod,
ResponseType: input.ResponseType,
Prompt: input.Prompt,
ResponseMode: input.ResponseMode,
},
}
if err = s.db.WithContext(ctx).Create(&par).Error; err != nil {
return "", 0, fmt.Errorf("failed to store pushed authorization request: %w", err)
}
return requestURI, int(PARDuration.Seconds()), nil
}
// GetPushedAuthorizationRequest retrieves a PAR record without consuming it.
func (s *OidcService) GetPushedAuthorizationRequest(ctx context.Context, clientID, requestURI string) (model.OidcPushedAuthorizationRequest, error) {
return s.getPushedAuthorizationRequestInternal(ctx, s.db, clientID, requestURI)
}
// getPushedAuthorizationRequestInternal retrieves a PAR record without consuming it.
func (s *OidcService) getPushedAuthorizationRequestInternal(ctx context.Context, tx *gorm.DB, clientID, requestURI string) (model.OidcPushedAuthorizationRequest, error) {
var par model.OidcPushedAuthorizationRequest
err := tx.
WithContext(ctx).
Where(
"request_uri = ? AND client_id = ? AND expires_at > ?",
requestURI,
clientID,
datatype.DateTime(time.Now()),
).
First(&par).
Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return par, &common.OidcInvalidRequestURIError{}
}
return par, err
}
return par, nil
}
// getAndConsumePushedAuthorizationRequest atomically retrieves and deletes a PAR record.
// Returns OidcInvalidRequestURIError if the record is not found, expired, or belongs to a different client.
func (s *OidcService) getAndConsumePushedAuthorizationRequest(ctx context.Context, tx *gorm.DB, clientID, requestURI string) (model.OidcPushedAuthorizationRequest, error) {
var par model.OidcPushedAuthorizationRequest
err := tx.
WithContext(ctx).
Clauses(clause.Returning{}).
Where(
"request_uri = ? AND client_id = ? AND expires_at > ?",
requestURI,
clientID,
datatype.DateTime(time.Now()),
).
Delete(&par).
Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return par, &common.OidcInvalidRequestURIError{}
}
return par, err
}
// After DELETE … RETURNING, check if a row was actually deleted
if par.ID == "" {
return par, &common.OidcInvalidRequestURIError{}
}
return par, nil
}
func validateCodeVerifier(codeVerifier, codeChallenge string, codeChallengeMethodSha256 bool) bool {
if codeVerifier == "" || codeChallenge == "" {
return false

View File

@@ -96,7 +96,7 @@ func stripWEBPMetadata(data []byte) []byte {
// WEBP image can max be 4GB in size
riffSize := out.Len() - 8
if riffSize < 0 || riffSize > int(maxRIFFSize) {
if riffSize < 0 || uint64(riffSize) > uint64(maxRIFFSize) {
return data
}

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,3 @@
DROP TABLE IF EXISTS oidc_pushed_authorization_requests;
ALTER TABLE oidc_clients DROP COLUMN requires_pushed_authorization_requests;

View File

@@ -0,0 +1,12 @@
CREATE TABLE oidc_pushed_authorization_requests (
id UUID NOT NULL PRIMARY KEY,
created_at TIMESTAMPTZ NOT NULL,
request_uri TEXT NOT NULL UNIQUE,
client_id TEXT NOT NULL REFERENCES oidc_clients(id) ON DELETE CASCADE,
parameters JSONB NOT NULL DEFAULT '{}',
expires_at TIMESTAMPTZ NOT NULL
);
CREATE INDEX idx_oidc_par_expires_at ON oidc_pushed_authorization_requests (expires_at);
ALTER TABLE oidc_clients ADD COLUMN requires_pushed_authorization_requests BOOLEAN NOT NULL DEFAULT FALSE;

View File

@@ -0,0 +1,3 @@
DROP TABLE IF EXISTS oidc_pushed_authorization_requests;
ALTER TABLE oidc_clients DROP COLUMN requires_pushed_authorization_requests;

View File

@@ -0,0 +1,12 @@
CREATE TABLE oidc_pushed_authorization_requests (
id TEXT NOT NULL PRIMARY KEY,
created_at INTEGER NOT NULL,
request_uri TEXT NOT NULL UNIQUE,
client_id TEXT NOT NULL REFERENCES oidc_clients(id) ON DELETE CASCADE,
parameters TEXT NOT NULL DEFAULT '{}',
expires_at INTEGER NOT NULL
);
CREATE INDEX idx_oidc_par_expires_at ON oidc_pushed_authorization_requests (expires_at);
ALTER TABLE oidc_clients ADD COLUMN requires_pushed_authorization_requests BOOLEAN NOT NULL DEFAULT FALSE;

View File

@@ -288,6 +288,9 @@
"public_key_code_exchange_is_a_security_feature_to_prevent_csrf_and_authorization_code_interception_attacks": "Public Key Code Exchange is a security feature to prevent CSRF and authorization code interception attacks.",
"requires_reauthentication": "Requires Re-Authentication",
"requires_users_to_authenticate_again_on_each_authorization": "Requires users to authenticate again on each authorization, even if already signed in",
"par": "PAR",
"requires_pushed_authorization_requests": "Requires Pushed Authorization Requests",
"requires_pushed_authorization_requests_description": "Requires clients to use the PAR endpoint (/api/oidc/par) to pre-register authorization parameters before initiating the flow. Not available for public clients.",
"name_logo": "{name} logo",
"change_logo": "Change Logo",
"upload_logo": "Upload Logo",

View File

@@ -48,7 +48,7 @@
"authenticator_does_not_support_any_of_the_requested_algorithms": "L'authentificateur ne supporte aucun des algorithmes requis",
"webauthn_error_invalid_rp_id": "L'ID de la partie de confiance configurée n'est pas valide.",
"webauthn_error_invalid_domain": "Le domaine configuré n'est pas valide.",
"contact_administrator_to_fix": "Contacte ton administrateur pour régler ce problème.",
"contact_administrator_to_fix": "Contactez votre administrateur pour régler ce problème.",
"webauthn_operation_not_allowed_or_timed_out": "L'opération n'a pas été autorisée ou a expiré.",
"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.",
@@ -288,6 +288,9 @@
"public_key_code_exchange_is_a_security_feature_to_prevent_csrf_and_authorization_code_interception_attacks": "Le Public Key Code Exchange est une fonctionnalité de sécurité conçue pour prévenir les attaques CSRF et dinterception de code dautorisation.",
"requires_reauthentication": "Nécessite une nouvelle authentification",
"requires_users_to_authenticate_again_on_each_authorization": "Demande aux utilisateurs de se connecter à nouveau à chaque autorisation, même s'ils sont déjà connectés.",
"par": "PAR",
"requires_pushed_authorization_requests": "Nécessite Pushed Authorization Requests (PAR)",
"requires_pushed_authorization_requests_description": "Les clients doivent utiliser le point de terminaison PAR (/api/oidc/par) pour préenregistrer les paramètres d'autorisation avant de démarrer le flux. Non disponible pour les clients publics.",
"name_logo": "Logo {name}",
"change_logo": "Changer le logo",
"upload_logo": "Télécharger un logo",

View File

@@ -108,14 +108,14 @@
"ip_address": "IPアドレス",
"device": "デバイス",
"client": "クライアント",
"actor": "俳優",
"actor": "実行者",
"unknown": "不明",
"account_details_updated_successfully": "アカウントの詳細が正常に更新されました",
"profile_picture_updated_successfully": "プロフィール画像が正常に更新されました。反映まで数分かかる場合があります。",
"account_settings": "アカウント設定",
"passkey_missing": "パスキーが見つかりません",
"please_provide_a_passkey_to_prevent_losing_access_to_your_account": "アカウントへのアクセスを失わないように、パスキーを追加してください。",
"single_passkey_configured": "Single Passkey Configured",
"single_passkey_configured": "シングルパスキーが設定済み",
"it_is_recommended_to_add_more_than_one_passkey": "アカウントへのアクセスを失わないように、複数のパスキーを追加することをお勧めします。",
"account_details": "アカウントの詳細",
"passkeys": "パスキー",
@@ -132,7 +132,7 @@
"username_must_start_with": "ユーザー名は英数字で始まる必要があります",
"username_must_end_with": "ユーザー名は英数字で終わる必要があります",
"sign_in_using_the_following_code_the_code_will_expire_in_minutes": "以下のコードを使用してサインインしてください。コードは15分後に失効します。",
"or_visit": "or visit",
"or_visit": "または次を訪問",
"added_on": "追加日",
"rename": "名前を変更",
"delete": "削除",
@@ -162,7 +162,7 @@
"api_key_revoked_successfully": "API キーが正常に削除されました",
"are_you_sure_you_want_to_revoke_the_api_key_apikeyname": "APIキー \"{apiKeyName}\" を本当に削除しますかこのAPI キーを使用しているすべての連携が停止します。",
"last_used": "最終使用日",
"actions": "Actions",
"actions": "操作",
"images_updated_successfully": "画像の更新が正常に完了しました。更新には数分かかる場合があります。",
"general": "一般",
"configure_smtp_to_send_emails": "新しいデバイスや場所からのログインが検出された際にユーザーに警告するメール通知を有効にします。",
@@ -205,15 +205,15 @@
"ldap_sync_finished": "LDAP同期が完了しました",
"client_configuration": "クライアントの設定",
"ldap_url": "LDAP URL",
"ldap_bind_dn": "LDAP Bind DN",
"ldap_bind_password": "LDAP Bind Password",
"ldap_base_dn": "LDAP Base DN",
"ldap_bind_dn": "LDAP バインド DN",
"ldap_bind_password": "LDAP バインドパスワード",
"ldap_base_dn": "LDAP ベース DN",
"user_search_filter": "ユーザー検索フィルター",
"the_search_filter_to_use_to_search_or_sync_users": "ユーザーの検索、同期に使用する検索フィルター。",
"groups_search_filter": "グループ検索フィルター",
"the_search_filter_to_use_to_search_or_sync_groups": "グループの検索、同期に使用する検索フィルター。",
"attribute_mapping": "属性マッピング",
"user_unique_identifier_attribute": "User Unique Identifier Attribute",
"user_unique_identifier_attribute": "ユーザー固有識別子属性",
"the_value_of_this_attribute_should_never_change": "この属性の値は決して変更されてはなりません。",
"username_attribute": "ユーザー名属性",
"user_mail_attribute": "ユーザーメール属性",
@@ -245,7 +245,7 @@
"admin": "管理者",
"user": "ユーザー",
"local": "ローカル",
"toggle_menu": "Toggle menu",
"toggle_menu": "メニューの表示/非表示を切り替える",
"edit": "編集",
"user_groups_updated_successfully": "ユーザーグループが正常に更新されました",
"user_updated_successfully": "ユーザーは正常に更新されました",
@@ -261,7 +261,7 @@
"add_group": "グループを追加",
"manage_user_groups": "ユーザーグループの管理",
"friendly_name": "Friendly Name",
"name_that_will_be_displayed_in_the_ui": "Name that will be displayed in the UI",
"name_that_will_be_displayed_in_the_ui": "UIに表示される名前",
"name_that_will_be_in_the_groups_claim": "Name that will be in the \"groups\" claim",
"delete_name": "{name} を削除",
"are_you_sure_you_want_to_delete_this_user_group": "このユーザーグループを削除しますか?",
@@ -282,12 +282,12 @@
"add": "追加",
"callback_urls": "Callback URLs",
"logout_callback_urls": "Logout Callback URLs",
"public_client": "Public Client",
"public_clients_description": "Public clients do not have a client secret. They are designed for mobile, web, and native applications where secrets cannot be securely stored.",
"public_client": "パブリッククライアント",
"public_clients_description": "パブリッククライアントにはクライアントシークレットがありません。これらは、シークレットを安全に保存できないモバイル、Web、およびネイティブアプリケーション向けに設計されています。",
"pkce": "PKCE",
"public_key_code_exchange_is_a_security_feature_to_prevent_csrf_and_authorization_code_interception_attacks": "Public Key Code Exchange is a security feature to prevent CSRF and authorization code interception attacks.",
"public_key_code_exchange_is_a_security_feature_to_prevent_csrf_and_authorization_code_interception_attacks": "公開鍵コード交換は、CSRFおよび認証コード傍受攻撃を防ぐためのセキュリティ機能です。",
"requires_reauthentication": "再認証が必要",
"requires_users_to_authenticate_again_on_each_authorization": "Requires users to authenticate again on each authorization, even if already signed in",
"requires_users_to_authenticate_again_on_each_authorization": "すでにログイン済みの場合でも、認証のたびにユーザーに再認証を求めます",
"name_logo": "{name} ロゴ",
"change_logo": "ロゴの変更",
"upload_logo": "ロゴをアップロード",
@@ -305,7 +305,7 @@
"oidc_client_updated_successfully": "OIDC クライアントが正常に更新されました",
"create_new_client_secret": "新しいクライアントシークレットを作成する",
"are_you_sure_you_want_to_create_a_new_client_secret": "新しいクライアントシークレットを作成してもよろしいですか?古いシークレットは無効化されます。",
"generate": "Generate",
"generate": "生成",
"new_client_secret_created_successfully": "新しいクライアントシークレットが正常に作成されました",
"oidc_client_name": "OIDC クライアント {name}",
"client_id": "Client ID",
@@ -331,8 +331,8 @@
"profile_picture_has_been_reset": "プロフィール画像がリセットされました。更新には数分かかる場合があります。",
"select_the_language_you_want_to_use": "使用する言語を選択してください。一部のテキストは自動翻訳により、不正確な場合がありますのでご注意ください。",
"contribute_to_translation": "問題が見つかった場合は、 <link href='https://crowdin.com/project/pocket-id'>Crowdin</link> で翻訳に貢献してください。",
"personal": "Personal",
"global": "Global",
"personal": "個人",
"global": "グローバル",
"all_users": "すべてのユーザー",
"all_events": "すべてのイベント",
"all_clients": "すべてのクライアント",
@@ -340,21 +340,21 @@
"global_audit_log": "グローバル監査ログ",
"see_all_recent_account_activities": "設定された保持期間中の全ユーザーのアカウント活動を閲覧する。",
"token_sign_in": "トークンサインイン",
"client_authorization": "Client Authorization",
"new_client_authorization": "New Client Authorization",
"client_authorization": "クライアント認証",
"new_client_authorization": "新規クライアント認証",
"device_code_authorization": "デバイスコード認証",
"new_device_code_authorization": "新規デバイス認証コード",
"passkey_added": "パスキー追加",
"passkey_removed": "パスキー削除済み",
"disable_animations": "アニメーションの無効化",
"turn_off_ui_animations": "Turn off animations throughout the UI.",
"turn_off_ui_animations": "UI全体のアニメーションを無効にします。",
"user_disabled": "アカウントの無効化",
"disabled_users_cannot_log_in_or_use_services": "無効化されたユーザーはログインやサービスを利用できません。",
"user_disabled_successfully": "ユーザーが正常に無効化されました。",
"user_enabled_successfully": "ユーザーが正常に有効化されました。",
"status": "Status",
"status": "ステータス",
"disable_firstname_lastname": "無効化 {firstName} {lastName}",
"are_you_sure_you_want_to_disable_this_user": "Are you sure you want to disable this user? They will not be able to log in or access any services.",
"are_you_sure_you_want_to_disable_this_user": "このユーザーを無効化してもよろしいですか?無効化すると、そのユーザーはログインやサービスの利用ができなくなります。",
"ldap_soft_delete_users": "LDAPから無効なユーザーを保持します。",
"ldap_soft_delete_users_description": "有効にすると、LDAPから削除されたユーザーはシステムから削除されるのではなく、無効化されます。",
"login_code_email_success": "ログインコードがユーザーに送信されました。",
@@ -367,38 +367,38 @@
"authorize_device": "デバイスの認証",
"the_device_has_been_authorized": "デバイスは認証されました。",
"enter_code_displayed_in_previous_step": "前のステップで表示されたコードを入力してください。",
"authorize": "Authorize",
"authorize": "承認",
"federated_client_credentials": "連携クライアントの資格情報",
"federated_client_credentials_description": "フェデレーテッドクライアント認証情報は、長期にわたるシークレットを管理せずにOIDCクライアントを認証することを可能にします。これらは、クライアントアサーションワークロードIDトークンのためにサードパーティ機関が発行するJWTトークンを活用します。",
"add_federated_client_credential": "Add Federated Client Credential",
"add_another_federated_client_credential": "Add another federated client credential",
"add_federated_client_credential": "連携クライアントの資格情報を追加",
"add_another_federated_client_credential": "他の連携クライアントの資格情報を追加",
"oidc_allowed_group_count": "許可されたグループ数",
"unrestricted": "制限なし",
"show_advanced_options": "詳細設定を表示",
"hide_advanced_options": "詳細設定を隠す",
"oidc_data_preview": "OIDC Data Preview",
"preview_the_oidc_data_that_would_be_sent_for_different_users": "Preview the OIDC data that would be sent for different users",
"id_token": "ID Token",
"access_token": "Access Token",
"userinfo": "Userinfo",
"id_token_payload": "ID Token Payload",
"access_token_payload": "Access Token Payload",
"userinfo_endpoint_response": "Userinfo Endpoint Response",
"oidc_data_preview": "OIDC データプレビュー",
"preview_the_oidc_data_that_would_be_sent_for_different_users": "異なるユーザーに対して送信されるOIDCデータのプレビュー",
"id_token": "IDトークン",
"access_token": "アクセストークン",
"userinfo": "ユーザー情報",
"id_token_payload": "IDトークンのペイロード",
"access_token_payload": "アクセストークンのペイロード",
"userinfo_endpoint_response": "ユーザー情報エンドポイントのレスポンス",
"copy": "コピー",
"no_preview_data_available": "No preview data available",
"no_preview_data_available": "利用可能なプレビューデータがありません",
"copy_all": "すべてコピー",
"preview": "プレビュー",
"preview_for_user": "{name} のプレビュー",
"preview_the_oidc_data_that_would_be_sent_for_this_user": "Preview the OIDC data that would be sent for this user",
"preview_the_oidc_data_that_would_be_sent_for_this_user": "このユーザーに対して送信されるOIDCデータのプレビュー",
"show": "表示",
"select_an_option": "Select an option",
"select_an_option": "オプションを選択",
"select_user": "ユーザーを選択",
"error": "エラー",
"select_an_accent_color_to_customize_the_appearance_of_pocket_id": "アクセントカラーを選択して、Pocket IDの外観をカスタマイズしてください。",
"accent_color": "アクセントカラー",
"custom_accent_color": "カスタムアクセントカラー",
"custom_accent_color_description": "有効な CSS カラーフォーマット (例: hex, rgb, hsl) を使用してカスタムカラーを入力します。",
"color_value": "Color Value",
"color_value": "カラー値",
"apply": "適用",
"signup_token": "サインアップトークン",
"create_a_signup_token_to_allow_new_user_registration": "新規ユーザー登録を許可するためのサインアップトークンを作成する。",
@@ -432,7 +432,7 @@
"signup_token_deleted_successfully": "サインアップトークンが正常に削除されました。",
"expired": "Expired",
"used_up": "Used Up",
"active": "Active",
"active": "有効",
"usage": "Usage",
"created": "Created",
"token": "トークン",
@@ -452,24 +452,24 @@
"launch": "起動",
"client_launch_url": "Client Launch URL",
"client_launch_url_description": "ユーザーが「マイアプリ」ページからアプリを起動した際に開かれるURL。",
"client_name_description": "The name of the client that shows in the Pocket ID UI.",
"client_name_description": "Pocket ID UIに表示されるクライアント名。",
"revoke_access": "アクセスを取り消す",
"revoke_access_description": "<b>{clientName}</b>へのアクセスを取り消します。 <b>{clientName}</b> はあなたのアカウント情報にアクセスできなくなります。",
"revoke_access_successful": "{clientName} へのアクセスは正常に取り消されました。",
"last_signed_in_ago": "Last signed in {time} ago",
"invalid_client_id": "Client ID can only contain letters, numbers, underscores, and hyphens",
"custom_client_id_description": "Set a custom client ID if this is required by your application. Otherwise, leave it blank to generate a random one.",
"last_signed_in_ago": "最終ログイン {time} ",
"invalid_client_id": "クライアントIDには、英字、数字、アンダースコア、ハイフンのみを含めることができます",
"custom_client_id_description": "アプリケーションで必要とされる場合は、カスタムクライアントIDを設定してください。必要ない場合は、空白のままにするとランダムなIDが生成されます。",
"generated": "Generated",
"administration": "Administration",
"group_rdn_attribute_description": "The attribute used in the groups distinguished name (DN).",
"display_name_attribute": "Display Name Attribute",
"administration": "管理",
"group_rdn_attribute_description": "グループの識別名DNで使用される属性。",
"display_name_attribute": "表示名属性",
"display_name": "表示名",
"configure_application_images": "アプリケーションの画像を設定",
"ui_config_disabled_info_title": "UI Configuration Disabled",
"ui_config_disabled_info_description": "The UI configuration is disabled because the application configuration settings are managed through environment variables. Some settings may not be editable.",
"ui_config_disabled_info_title": "UI設定が無効になっています",
"ui_config_disabled_info_description": "アプリケーションの設定は環境変数を通じて管理されているため、UI設定は無効になっています。一部の設定は編集できない場合があります。",
"logo_from_url_description": "画像の直接のURL (svg, png, webp) を貼り付けます。アイコンは <link href=\"https://selfh.st/icons\">Selfh.st Icons</link> か <link href=\"https://dashboardicons.com\">Dashboard Icons</link> で探せます。",
"invalid_url": "無効な URL",
"require_user_email": "Require Email Address",
"require_user_email": "メールアドレスを必須にする",
"require_user_email_description": "ユーザーにメールアドレスの登録を必須とします。無効にした場合、メールアドレスを持たないユーザーはメールアドレスが必要な機能を利用できなくなります。",
"view": "表示",
"toggle_columns": "列の表示/非表示を切り替える",

View File

@@ -1,65 +1,65 @@
{
"name": "pocket-id-frontend",
"version": "2.7.0",
"private": true,
"type": "module",
"scripts": {
"preinstall": "npx only-allow pnpm",
"dev": "vite dev --port 3000",
"build": "vite build",
"preview": "vite preview --port 3000",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"lint": "prettier --check . && eslint .",
"format": "prettier --write ."
},
"dependencies": {
"@fontsource/gloock": "^5.2.8",
"@simplewebauthn/browser": "^13.3.0",
"@tailwindcss/vite": "^4.3.0",
"axios": "^1.16.1",
"clsx": "^2.1.1",
"date-fns": "^4.2.1",
"jose": "^6.2.3",
"qrcode": "^1.5.4",
"runed": "^0.37.1",
"sveltekit-superforms": "^2.30.1",
"tailwind-merge": "^3.6.0",
"zod": "^4.4.3"
},
"devDependencies": {
"@inlang/paraglide-js": "^2.18.0",
"@inlang/plugin-m-function-matcher": "^2.2.6",
"@inlang/plugin-message-format": "^4.4.0",
"@internationalized/date": "^3.12.1",
"@lucide/svelte": "^1.16.0",
"@sveltejs/adapter-static": "^3.0.10",
"@sveltejs/kit": "^2.60.1",
"@sveltejs/vite-plugin-svelte": "^7.1.2",
"@types/eslint": "^9.6.1",
"@types/node": "^25.9.0",
"@types/qrcode": "^1.5.6",
"bits-ui": "^2.18.1",
"eslint": "^10.4.0",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-svelte": "^3.17.1",
"formsnap": "^2.0.1",
"globals": "^17.6.0",
"mode-watcher": "^1.1.0",
"prettier": "^3.8.3",
"prettier-plugin-svelte": "^3.5.2",
"prettier-plugin-tailwindcss": "^0.8.0",
"rollup": "^4.60.4",
"svelte": "^5.55.8",
"svelte-check": "^4.4.8",
"svelte-sonner": "^1.1.1",
"tailwind-variants": "^3.2.2",
"tailwindcss": "^4.3.0",
"tslib": "^2.8.1",
"tw-animate-css": "^1.4.0",
"typescript": "^6.0.3",
"typescript-eslint": "^8.59.4",
"vite": "^8.0.13",
"vite-plugin-compression": "^0.5.1"
}
"name": "pocket-id-frontend",
"version": "2.8.0",
"private": true,
"type": "module",
"scripts": {
"preinstall": "npx only-allow pnpm",
"dev": "vite dev --port 3000",
"build": "vite build",
"preview": "vite preview --port 3000",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"lint": "prettier --check . && eslint .",
"format": "prettier --write ."
},
"dependencies": {
"@fontsource/gloock": "^5.2.8",
"@simplewebauthn/browser": "^13.3.0",
"@tailwindcss/vite": "^4.3.0",
"axios": "^1.16.1",
"clsx": "^2.1.1",
"date-fns": "^4.2.1",
"jose": "^6.2.3",
"qrcode": "^1.5.4",
"runed": "^0.37.1",
"sveltekit-superforms": "^2.30.1",
"tailwind-merge": "^3.6.0",
"zod": "^4.4.3"
},
"devDependencies": {
"@inlang/paraglide-js": "^2.18.0",
"@inlang/plugin-m-function-matcher": "^2.2.6",
"@inlang/plugin-message-format": "^4.4.0",
"@internationalized/date": "^3.12.1",
"@lucide/svelte": "^1.16.0",
"@sveltejs/adapter-static": "^3.0.10",
"@sveltejs/kit": "^2.60.1",
"@sveltejs/vite-plugin-svelte": "^7.1.2",
"@types/eslint": "^9.6.1",
"@types/node": "^25.9.0",
"@types/qrcode": "^1.5.6",
"bits-ui": "^2.18.1",
"eslint": "^10.4.0",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-svelte": "^3.17.1",
"formsnap": "^2.0.1",
"globals": "^17.6.0",
"mode-watcher": "^1.1.0",
"prettier": "^3.8.3",
"prettier-plugin-svelte": "^3.5.2",
"prettier-plugin-tailwindcss": "^0.8.0",
"rollup": "^4.60.4",
"svelte": "^5.55.8",
"svelte-check": "^4.4.8",
"svelte-sonner": "^1.1.1",
"tailwind-variants": "^3.2.2",
"tailwindcss": "^4.3.0",
"tslib": "^2.8.1",
"tw-animate-css": "^1.4.0",
"typescript": "^6.0.3",
"typescript-eslint": "^8.59.4",
"vite": "^8.0.13",
"vite-plugin-compression": "^0.5.1"
}
}

View File

@@ -135,7 +135,7 @@
--color-sidebar-ring: var(--sidebar-ring);
/* Font */
--font-glooock: 'glooock ', serif;
--font-gloock: 'gloock', serif;
}
@layer base {

View File

@@ -35,7 +35,7 @@
<a href="/" class="flex items-center transition-opacity hover:opacity-80">
<Logo class="size-8" />
<Separator orientation="vertical" class="h-5! bg-neutral-600 ml-2 mr-3" />
<h1 class="text-2xl font-glooock" data-testid="application-name">
<h1 class="text-2xl font-gloock" data-testid="application-name">
{m.settings()}
</h1>
</a>

View File

@@ -7,7 +7,7 @@
<div class="bg-muted mx-auto rounded-2xl p-3">
<Logo class="size-10" />
</div>
<p class="font-glooock mt-5 text-3xl font-bold sm:text-4xl">{m.browser_unsupported()}</p>
<p class="font-gloock mt-5 text-3xl font-bold sm:text-4xl">{m.browser_unsupported()}</p>
<p class="text-muted-foreground mt-3">
{m.this_browser_does_not_support_passkeys()}
</p>

View File

@@ -8,7 +8,8 @@ import type {
OidcClientUpdate,
OidcClientWithAllowedUserGroups,
OidcClientWithAllowedUserGroupsCount,
OidcDeviceCodeInfo
OidcDeviceCodeInfo,
OidcAuthorizeRequestInfo
} from '$lib/types/oidc.type';
import type { ScimServiceProvider } from '$lib/types/scim.type';
import { cachedOidcClientLogo } from '$lib/utils/cached-image-util';
@@ -24,7 +25,8 @@ class OidcService extends APIService {
codeChallengeMethod?: string,
reauthenticationToken?: string,
responseMode?: string,
prompt?: string
prompt?: string,
requestURI?: string
) => {
const res = await this.api.post('/oidc/authorize', {
scope,
@@ -35,19 +37,29 @@ class OidcService extends APIService {
codeChallengeMethod,
reauthenticationToken,
responseMode,
prompt
prompt,
requestURI
});
return res.data as AuthorizeResponse;
};
isAuthorizationRequired = async (clientId: string, scope: string) => {
isAuthorizationRequired = async (clientId: string, scope: string, requestURI?: string) => {
const res = await this.api.post('/oidc/authorization-required', {
scope,
clientId
clientId,
requestURI
});
return res.data.authorizationRequired as boolean;
return res.data as { authorizationRequired: boolean; scope: string };
};
getParRequestInfo = async (clientId: string, requestURI: string) => {
const res = await this.api.get('/oidc/par-request-info', {
params: { client_id: clientId, request_uri: requestURI }
});
return res.data as OidcAuthorizeRequestInfo;
};
listClients = async (options?: ListRequestOptions) => {

View File

@@ -26,6 +26,7 @@ export type OidcClient = OidcClientMetaData & {
isPublic: boolean;
pkceEnabled: boolean;
requiresReauthentication: boolean;
requiresPushedAuthorizationRequests: boolean;
credentials?: OidcClientCredentials;
launchURL?: string;
isGroupRestricted: boolean;
@@ -72,3 +73,12 @@ export type AuthorizeResponse = {
export type AccessibleOidcClient = OidcClientMetaData & {
lastUsedAt: Date | null;
};
export type OidcAuthorizeRequestInfo = {
scope: string;
redirectURI: string;
state?: string;
nonce?: string;
responseMode?: string;
prompt?: string;
};

View File

@@ -25,16 +25,16 @@
let { data }: PageProps = $props();
let {
client,
scope,
callbackURL,
nonce,
codeChallenge,
codeChallengeMethod,
authorizeState,
prompt,
responseMode
responseMode,
requestURI
} = data;
let scope = $state(data.scope);
let isLoading = $state(false);
let success = $state(false);
let errorMessage: string | null = $state(null);
@@ -112,7 +112,16 @@
}
if (!authorizationConfirmed) {
authorizationRequired = await oidService.isAuthorizationRequired(client!.id, scope);
const authRequired = await oidService.isAuthorizationRequired(
client!.id,
scope,
requestURI
);
authorizationRequired = authRequired.authorizationRequired;
if (requestURI) {
scope = authRequired.scope;
}
// If prompt=consent, always show consent UI
if (hasPromptConsent) {
@@ -153,7 +162,8 @@
codeChallengeMethod,
reauthToken,
responseMode,
prompt
prompt,
requestURI
);
// Check if backend returned a redirect error
@@ -265,7 +275,7 @@
{:else}
<SignInWrapper showAlternativeSignInMethodButton={$userStore == null}>
<ClientProviderImages {client} {success} error={!!errorMessage} />
<h1 class="font-glooock mt-5 text-3xl font-bold sm:text-4xl">
<h1 class="font-gloock mt-5 text-3xl font-bold sm:text-4xl">
{m.sign_in_to({ name: client.name })}
</h1>
{#if errorMessage}

View File

@@ -3,19 +3,24 @@ import type { PageLoad } from './$types';
export const load: PageLoad = async ({ url }) => {
const clientId = url.searchParams.get('client_id');
const requestURI = url.searchParams.get('request_uri') || undefined;
const oidcService = new OidcService();
const client = await oidcService.getClientMetaData(clientId!);
const [client, parInfo] = await Promise.all([
oidcService.getClientMetaData(clientId!),
requestURI ? oidcService.getParRequestInfo(clientId!, requestURI) : undefined
]);
return {
scope: url.searchParams.get('scope')!,
nonce: url.searchParams.get('nonce') || undefined,
authorizeState: url.searchParams.get('state')!,
callbackURL: url.searchParams.get('redirect_uri')!,
scope: parInfo?.scope ?? url.searchParams.get('scope')!,
nonce: parInfo?.nonce ?? url.searchParams.get('nonce') ?? undefined,
authorizeState: parInfo?.state ?? url.searchParams.get('state')!,
callbackURL: parInfo?.redirectURI ?? url.searchParams.get('redirect_uri')!,
client,
codeChallenge: url.searchParams.get('code_challenge')!,
codeChallengeMethod: url.searchParams.get('code_challenge_method')!,
prompt: url.searchParams.get('prompt') || undefined,
responseMode: url.searchParams.get('response_mode') || undefined
prompt: parInfo?.prompt ?? url.searchParams.get('prompt') ?? undefined,
responseMode: parInfo?.responseMode ?? url.searchParams.get('response_mode') ?? undefined,
requestURI
};
};

View File

@@ -79,7 +79,7 @@
<LoginLogoErrorSuccessIndicator {success} error={!!errorMessage} />
{/if}
</div>
<h1 class="font-glooock mt-5 text-4xl font-bold">{m.authorize_device()}</h1>
<h1 class="font-gloock mt-5 text-4xl font-bold">{m.authorize_device()}</h1>
{#if errorMessage}
<p class="text-muted-foreground mt-2">
{errorMessage}. {m.please_try_again()}

View File

@@ -44,7 +44,7 @@
<div class="flex justify-center">
<LoginLogoErrorSuccessIndicator error={!!error} />
</div>
<h1 class="font-glooock mt-5 text-3xl font-bold sm:text-4xl">
<h1 class="font-gloock mt-5 text-3xl font-bold sm:text-4xl">
{m.sign_in_to_appname({ appName: $appConfigStore.appName })}
</h1>
{#if error}

View File

@@ -35,7 +35,7 @@
<div class="bg-muted mx-auto rounded-2xl p-3">
<Logo class="size-10" />
</div>
<h1 class="font-glooock mt-5 text-3xl font-bold sm:text-4xl">{m.alternative_sign_in()}</h1>
<h1 class="font-gloock mt-5 text-3xl font-bold sm:text-4xl">{m.alternative_sign_in()}</h1>
<p class="text-muted-foreground mt-3">
{m.if_you_do_not_have_access_to_your_passkey_you_can_sign_in_using_one_of_the_following_methods()}
</p>

View File

@@ -59,7 +59,7 @@
<div class="flex justify-center">
<LoginLogoErrorSuccessIndicator error={!!error} />
</div>
<h1 class="font-glooock mt-5 text-4xl font-bold">{m.login_code()}</h1>
<h1 class="font-gloock mt-5 text-4xl font-bold">{m.login_code()}</h1>
{#if error}
<p class="text-muted-foreground mt-2">
{error}. {m.please_try_again()}

View File

@@ -37,7 +37,7 @@
<div class="flex justify-center">
<LoginLogoErrorSuccessIndicator {success} error={!!error} />
</div>
<h1 class="font-glooock mt-5 text-3xl font-bold sm:text-4xl">{m.email_login()}</h1>
<h1 class="font-gloock mt-5 text-3xl font-bold sm:text-4xl">{m.email_login()}</h1>
{#if error}
<p class="text-muted-foreground mt-2" in:fade>
{error}. {m.please_try_again()}

View File

@@ -34,7 +34,7 @@
<Logo class="size-10" />
</div>
</div>
<h1 class="font-glooock mt-5 text-4xl">{m.sign_out()}</h1>
<h1 class="font-gloock mt-5 text-4xl">{m.sign_out()}</h1>
<p class="text-muted-foreground mt-2">
<FormattedMessage

View File

@@ -47,7 +47,10 @@
[m.logout_url()]: `https://${page.url.host}/api/oidc/end-session`,
[m.certificate_url()]: `https://${page.url.host}/.well-known/jwks.json`,
[m.pkce()]: client.pkceEnabled ? m.enabled() : m.disabled(),
[m.requires_reauthentication()]: client.requiresReauthentication ? m.enabled() : m.disabled()
[m.requires_reauthentication()]: client.requiresReauthentication ? m.enabled() : m.disabled(),
[m.requires_pushed_authorization_requests()]: client.requiresPushedAuthorizationRequests
? m.enabled()
: m.disabled()
});
async function updateClient(updatedClient: OidcClientCreateWithLogo) {
@@ -71,6 +74,8 @@
await Promise.all([dataPromise, imagePromise, darkImagePromise])
.then(() => {
setupDetails[m.requires_pushed_authorization_requests()] =
updatedClient.requiresPushedAuthorizationRequests ? m.enabled() : m.disabled();
if (updatedClient.logoUrl) {
cachedOidcClientLogo.bustCache(client.id, true);
}

View File

@@ -49,6 +49,8 @@
isPublic: existingClient?.isPublic || false,
pkceEnabled: existingClient?.pkceEnabled || false,
requiresReauthentication: existingClient?.requiresReauthentication || false,
requiresPushedAuthorizationRequests:
existingClient?.requiresPushedAuthorizationRequests || false,
launchURL: existingClient?.launchURL || '',
credentials: {
federatedIdentities: existingClient?.credentials?.federatedIdentities || []
@@ -74,6 +76,7 @@
isPublic: z.boolean(),
pkceEnabled: z.boolean(),
requiresReauthentication: z.boolean(),
requiresPushedAuthorizationRequests: z.boolean(),
launchURL: optionalUrl,
logoUrl: optionalUrl,
darkLogoUrl: optionalUrl,
@@ -205,6 +208,7 @@
onCheckedChange={(v) => {
if (v) {
$inputs.pkceEnabled.value = true;
$inputs.requiresPushedAuthorizationRequests.value = false;
}
}}
bind:checked={$inputs.isPublic.value}
@@ -270,6 +274,13 @@
{#if showAdvancedOptions}
<div class="mt-7 flex flex-col gap-y-7 md:col-span-2" transition:slide={{ duration: 200 }}>
<SwitchWithLabel
id="requires-par"
label={m.requires_pushed_authorization_requests()}
description={m.requires_pushed_authorization_requests_description()}
disabled={$inputs.isPublic.value}
bind:checked={$inputs.requiresPushedAuthorizationRequests.value}
/>
{#if mode == 'create'}
<FormInput
label={m.client_id()}

View File

@@ -59,6 +59,13 @@
sortable: true,
filterableValues: booleanFilterValues
},
{
label: m.par(),
column: 'requiresPushedAuthorizationRequests',
sortable: true,
hidden: true,
filterableValues: booleanFilterValues
},
{
label: m.client_launch_url(),
column: 'launchURL',

View File

@@ -61,7 +61,7 @@
<LoginLogoErrorSuccessIndicator error={!!error} />
</div>
<h1 class="font-glooock mt-5 text-3xl font-bold sm:text-4xl">
<h1 class="font-gloock mt-5 text-3xl font-bold sm:text-4xl">
{m.signup_to_appname({ appName: $appConfigStore.appName })}
</h1>

View File

@@ -69,7 +69,7 @@
<div class="flex justify-center">
<LoginLogoErrorSuccessIndicator error={!!error} />
</div>
<h1 class="font-glooock mt-5 text-3xl font-bold sm:text-4xl">
<h1 class="font-gloock mt-5 text-3xl font-bold sm:text-4xl">
{m.setup_your_passkey()}
</h1>
<p class="text-muted-foreground mt-2" in:fade>

View File

@@ -47,7 +47,7 @@
<LoginLogoErrorSuccessIndicator error={!!error} />
</div>
<h1 class="font-glooock mt-5 text-3xl font-bold sm:text-4xl">
<h1 class="font-gloock mt-5 text-3xl font-bold sm:text-4xl">
{m.signup_to_appname({ appName: $appConfigStore.appName })}
</h1>

View File

@@ -67,6 +67,12 @@ export const oidcClients = {
callbackUrl: 'http://pingvin.share/auth/callback',
secondCallbackUrl: 'http://pingvin.share/auth/callback2',
launchURL: 'https://pingvin-share.local'
},
parClient: {
id: 'a1b2c3d4-e5f6-7890-abcd-ef0000000001',
name: 'PAR Test Client',
callbackUrl: 'http://par-client/auth/callback',
secret: 'w2mUeZISmEvIDMEDvpY0PnxQIpj1m3zY'
}
};

View File

@@ -1,6 +1,6 @@
{
"provider": "sqlite",
"version": 20260518222000,
"version": 20260601154900,
"tableOrder": ["users", "user_groups", "oidc_clients", "signup_tokens"],
"tables": {
"api_keys": [
@@ -82,6 +82,7 @@
"logout_callback_urls": "WyJodHRwOi8vbmV4dGNsb3VkL2F1dGgvbG9nb3V0L2NhbGxiYWNrIl0=",
"name": "Nextcloud",
"pkce_enabled": false,
"requires_pushed_authorization_requests": false,
"requires_reauthentication": false,
"secret": "$2a$10$9dypwot8nGuCjT6wQWWpJOckZfRprhe2EkwpKizxS/fpVHrOLEJHC"
},
@@ -99,6 +100,7 @@
"logout_callback_urls": "bnVsbA==",
"name": "Immich",
"pkce_enabled": false,
"requires_pushed_authorization_requests": false,
"requires_reauthentication": false,
"secret": "$2a$10$Ak.FP8riD1ssy2AGGbG.gOpnp/rBpymd74j0nxNMtW0GG1Lb4gzxe"
},
@@ -116,6 +118,7 @@
"logout_callback_urls": "WyJodHRwOi8vdGFpbHNjYWxlL2F1dGgvbG9nb3V0L2NhbGxiYWNrIl0=",
"name": "Tailscale",
"pkce_enabled": false,
"requires_pushed_authorization_requests": false,
"requires_reauthentication": false,
"secret": "$2a$10$xcRReBsvkI1XI6FG8xu/pOgzeF00bH5Wy4d/NThwcdi3ZBpVq/B9a"
},
@@ -133,6 +136,7 @@
"logout_callback_urls": "bnVsbA==",
"name": "Federated",
"pkce_enabled": false,
"requires_pushed_authorization_requests": false,
"requires_reauthentication": false,
"secret": "$2a$10$Ak.FP8riD1ssy2AGGbG.gOpnp/rBpymd74j0nxNMtW0GG1Lb4gzxe"
},
@@ -149,8 +153,27 @@
"logout_callback_urls": "bnVsbA==",
"name": "SCIM Client",
"pkce_enabled": false,
"requires_pushed_authorization_requests": false,
"requires_reauthentication": false,
"secret": "$2a$10$h4wfa8gI7zavDAxwzSq1sOwYU4e8DwK1XZ8ZweNnY5KzlJ3Iz.qdK"
},
{
"callback_urls": "WyJodHRwOi8vcGFyLWNsaWVudC9hdXRoL2NhbGxiYWNrIl0=",
"created_at": "2025-11-25T12:39:02Z",
"created_by_id": "f4b89dc2-62fb-46bf-9f5f-c34f4eafe93e",
"credentials": "e30=",
"dark_image_type": null,
"id": "a1b2c3d4-e5f6-7890-abcd-ef0000000001",
"image_type": null,
"is_group_restricted": false,
"is_public": false,
"launch_url": null,
"logout_callback_urls": "bnVsbA==",
"name": "PAR Test Client",
"pkce_enabled": false,
"requires_pushed_authorization_requests": false,
"requires_reauthentication": false,
"secret": "$2a$10$9dypwot8nGuCjT6wQWWpJOckZfRprhe2EkwpKizxS/fpVHrOLEJHC"
}
],
"oidc_clients_allowed_user_groups": [
@@ -259,6 +282,12 @@
"scope": "openid profile email",
"user_id": "f4b89dc2-62fb-46bf-9f5f-c34f4eafe93e"
},
{
"client_id": "a1b2c3d4-e5f6-7890-abcd-ef0000000001",
"last_used_at": "2024-01-01T00:00:00Z",
"scope": "openid profile email",
"user_id": "f4b89dc2-62fb-46bf-9f5f-c34f4eafe93e"
},
{
"client_id": "c48232ff-ff65-45ed-ae96-7afa8a9b443b",
"last_used_at": "2025-08-12T12:00:00Z",

View File

@@ -11,7 +11,7 @@ test('Dashboard shows all clients in the correct order', async ({ page }) => {
await page.goto('/settings/apps');
await expect(page.getByTestId('authorized-oidc-client-card')).toHaveCount(5);
await expect(page.getByTestId('authorized-oidc-client-card')).toHaveCount(6);
// Should be first
const card1 = page.getByTestId('authorized-oidc-client-card').first();
@@ -32,7 +32,7 @@ test.describe('Dashboard shows only clients where user has access', () => {
const cards = page.getByTestId('authorized-oidc-client-card');
await expect(cards).toHaveCount(4);
await expect(cards).toHaveCount(5);
const cardTexts = await cards.allTextContents();
expect(cardTexts.some((text) => text.includes(notVisibleClient.name))).toBe(false);
@@ -40,7 +40,7 @@ test.describe('Dashboard shows only clients where user has access', () => {
test('User can see all clients', async ({ page }) => {
await page.goto('/settings/apps');
const cards = page.getByTestId('authorized-oidc-client-card');
await expect(cards).toHaveCount(5);
await expect(cards).toHaveCount(6);
});
});

View File

@@ -118,6 +118,43 @@ test('Delete OIDC client', async ({ page }) => {
await expect(page.getByRole('row', { name: oidcClient.name })).not.toBeVisible();
});
test('Filter OIDC clients by PAR requirement', async ({ page, request }) => {
const parClient = oidcClients.parClient;
// Enable PAR on the PAR test client
await request.put(`/api/oidc/clients/${parClient.id}`, {
data: {
name: parClient.name,
callbackURLs: [parClient.callbackUrl],
logoutCallbackURLs: [],
isPublic: false,
pkceEnabled: false,
requiresReauthentication: false,
requiresPushedAuthorizationRequests: true,
credentials: { federatedIdentities: [] },
isGroupRestricted: false
}
});
await page.goto('/settings/admin/oidc-clients');
// Open PAR filter and select "Yes"
await page.getByTestId('facet-par-trigger').click();
await page.getByTestId('facet-par-option-true').click();
// Only the PAR client should be visible
await expect(page.getByRole('row', { name: parClient.name })).toBeVisible();
await expect(page.getByRole('row', { name: oidcClients.nextcloud.name })).not.toBeVisible();
// Deselect "Yes" and select "No" to invert the filter
await page.getByTestId('facet-par-option-true').click();
await page.getByTestId('facet-par-option-false').click();
// PAR client should be hidden, others visible
await expect(page.getByRole('row', { name: oidcClients.nextcloud.name })).toBeVisible();
await expect(page.getByRole('row', { name: parClient.name })).not.toBeVisible();
});
test('Update OIDC client allowed user groups', async ({ page }) => {
await page.goto(`/settings/admin/oidc-clients/${oidcClients.nextcloud.id}`);

View File

@@ -886,3 +886,266 @@ async function routeCallbackPage(page: Page, callbackUrl: string): Promise<(url:
return callbackRouteMatcher;
}
// ─── PAR (Pushed Authorization Requests - RFC 9126) ──────────────────────────
test.describe('Pushed Authorization Requests (PAR)', () => {
const client = oidcClients.parClient;
test('PAR endpoint returns request_uri for valid confidential client', async ({ page }) => {
const result = await oidcUtil.pushAuthorizationRequest(page, {
clientId: client.id,
clientSecret: client.secret,
redirectUri: client.callbackUrl
});
expect(result.request_uri).toMatch(/^urn:ietf:params:oauth:request_uri:/);
expect(result.expires_in).toBe(90);
expect(result.error).toBeUndefined();
});
test('PAR full flow: push then authorize then exchange tokens', async ({ page }) => {
// Step 1: Push authorization parameters
const parResult = await oidcUtil.pushAuthorizationRequest(page, {
clientId: client.id,
clientSecret: client.secret,
redirectUri: client.callbackUrl,
nonce: 'par-nonce-123'
});
expect(parResult.request_uri).toBeDefined();
expect(parResult.error).toBeUndefined();
// Step 2: Navigate to /authorize using the request_uri
const urlParams = new URLSearchParams({
client_id: client.id,
request_uri: parResult.request_uri!
});
const callbackUrl = await expectCallbackRedirect(page, client.callbackUrl, () =>
page.goto(`/authorize?${urlParams.toString()}`)
);
const code = callbackUrl.searchParams.get('code');
expect(code).toBeTruthy();
// Step 3: Exchange the authorization code for tokens
const tokenResult = await oidcUtil.exchangeCode(page, {
grant_type: 'authorization_code',
code: code!,
client_id: client.id,
client_secret: client.secret,
redirect_uri: client.callbackUrl
});
expect(tokenResult.access_token).toBeTruthy();
expect(tokenResult.token_type).toBe('Bearer');
expect(tokenResult.error).toBeUndefined();
});
test('par-request-info resolves the stored request parameters', async ({ page }) => {
const state = 'par-info-state-9f3a';
const parResult = await oidcUtil.pushAuthorizationRequest(page, {
clientId: client.id,
clientSecret: client.secret,
redirectUri: client.callbackUrl,
scope: 'openid profile',
state,
responseMode: 'form_post'
});
expect(parResult.request_uri).toBeDefined();
const res = await page.request.get('/api/oidc/par-request-info', {
params: { client_id: client.id, request_uri: parResult.request_uri! }
});
expect(res.ok()).toBe(true);
const body = await res.json();
expect(body.scope).toBe('openid profile');
expect(body.redirectURI).toBe(client.callbackUrl);
expect(body.state).toBe(state);
expect(body.responseMode).toBe('form_post');
});
test('PAR full flow carries the resolved state into the callback redirect', async ({ page }) => {
const state = 'par-flow-state-7b21';
const parResult = await oidcUtil.pushAuthorizationRequest(page, {
clientId: client.id,
clientSecret: client.secret,
redirectUri: client.callbackUrl,
state,
responseMode: 'form_post'
});
expect(parResult.request_uri).toBeDefined();
const urlParams = new URLSearchParams({
client_id: client.id,
request_uri: parResult.request_uri!
});
const formPostRequestPromise = waitForFormPostRequest(page, client.callbackUrl);
await page.goto(`/authorize?${urlParams.toString()}`);
const request = await formPostRequestPromise;
const formData = new URLSearchParams(request.postData() ?? '');
expect(formData.get('code')).toBeTruthy();
expect(formData.get('state')).toBe(state);
});
test('PAR full flow shows consent screen when authorization is required', async ({ page }) => {
// The parClient is pre-authorized for "openid profile email"; pushing a different
// scope means consent is required and the consent screen must be shown rather than
// silently authorizing.
const parResult = await oidcUtil.pushAuthorizationRequest(page, {
clientId: client.id,
clientSecret: client.secret,
redirectUri: client.callbackUrl,
scope: 'openid profile'
});
expect(parResult.request_uri).toBeDefined();
const urlParams = new URLSearchParams({
client_id: client.id,
request_uri: parResult.request_uri!
});
await page.goto(`/authorize?${urlParams.toString()}`);
// Consent screen with the requested scope (resolved from the PAR) must be shown
await expect(
page.getByTestId('scopes').getByRole('heading', { name: 'Profile' })
).toBeVisible();
// Confirming proceeds with the authorization
await expectCallbackRedirect(page, client.callbackUrl, () =>
page.getByRole('button', { name: 'Sign in' }).click()
);
});
test('PAR request_uri is single-use', async ({ page }) => {
// Push two requests — use the first via the browser, then try to reuse it
const parResult = await oidcUtil.pushAuthorizationRequest(page, {
clientId: client.id,
clientSecret: client.secret,
redirectUri: client.callbackUrl
});
expect(parResult.request_uri).toBeDefined();
// First use — navigate to /authorize (must succeed and consume the request_uri)
const urlParams = new URLSearchParams({
client_id: client.id,
request_uri: parResult.request_uri!
});
const firstCallbackUrl = await expectCallbackRedirect(page, client.callbackUrl, () =>
page.goto(`/authorize?${urlParams.toString()}`)
);
expect(firstCallbackUrl.searchParams.get('code')).toBeTruthy();
// Second use of the same request_uri should fail
// Use the authorize API directly (requires auth cookie which we have)
const response = await page.request.post('/api/oidc/authorize', {
headers: { 'Content-Type': 'application/json' },
data: {
clientID: client.id,
requestURI: parResult.request_uri
}
});
expect(response.status()).toBe(400);
});
test('PAR endpoint rejects request without client credentials', async ({ page }) => {
const result = await oidcUtil.pushAuthorizationRequest(page, {
clientId: client.id,
// no clientSecret
redirectUri: client.callbackUrl
});
expect(result.error).toBeDefined();
expect(result.request_uri).toBeUndefined();
});
test('PAR endpoint rejects public client', async ({ page }) => {
// The parClient is confidential — test by setting isPublic via admin API first
await page.request.put(`/api/oidc/clients/${client.id}`, {
headers: { 'Content-Type': 'application/json' },
data: {
name: client.name,
callbackURLs: [client.callbackUrl],
logoutCallbackURLs: [],
isPublic: true,
pkceEnabled: true,
requiresReauthentication: false,
requiresPushedAuthorizationRequests: false,
credentials: { federatedIdentities: [] },
isGroupRestricted: false
}
});
const result = await oidcUtil.pushAuthorizationRequest(page, {
clientId: client.id,
clientSecret: client.secret,
redirectUri: client.callbackUrl
});
expect(result.error).toBe('Pushed authorization requests are not supported for public clients');
expect(result.request_uri).toBeUndefined();
});
test('PAR endpoint rejects invalid redirect_uri at push time', async ({ page }) => {
const result = await oidcUtil.pushAuthorizationRequest(page, {
clientId: client.id,
clientSecret: client.secret,
redirectUri: 'http://evil.example.com/steal'
});
expect(result.error).toBeDefined();
expect(result.request_uri).toBeUndefined();
});
test('Client with requiresPushedAuthorizationRequests rejects direct /authorize', async ({
page,
request
}) => {
// Enable the PAR requirement on the client
await request.put(`/api/oidc/clients/${client.id}`, {
headers: { 'Content-Type': 'application/json' },
data: {
name: client.name,
callbackURLs: [client.callbackUrl],
logoutCallbackURLs: [],
isPublic: false,
pkceEnabled: false,
requiresReauthentication: false,
requiresPushedAuthorizationRequests: true,
credentials: { federatedIdentities: [] },
isGroupRestricted: false
}
});
// Attempt a normal authorization (without request_uri)
const response = await page.request.post('/api/oidc/authorize', {
headers: { 'Content-Type': 'application/json' },
data: {
clientID: client.id,
scope: 'openid profile',
callbackURL: client.callbackUrl
}
});
expect(response.status()).toBe(400);
});
test('Admin UI: PAR toggle persists after save', async ({ page }) => {
await page.goto(`/settings/admin/oidc-clients/${client.id}`);
await page.getByRole('button', { name: 'Show Advanced Options' }).click();
// Enable the PAR toggle
const parToggle = page.getByRole('switch', { name: 'Requires Pushed Authorization' });
if (!(await parToggle.isChecked())) {
await parToggle.click();
}
await page.getByRole('button', { name: /save/i }).click();
await page.reload();
await page.getByRole('button', { name: 'Show Advanced Options' }).click();
const savedToggle = page.getByRole('switch', { name: 'Requires Pushed Authorization' });
await expect(savedToggle).toBeChecked();
});
});

View File

@@ -63,6 +63,47 @@ export async function exchangeCode(
.then((r) => r.json());
}
export async function pushAuthorizationRequest(
page: Page,
params: {
clientId: string;
clientSecret?: string;
scope?: string;
redirectUri?: string;
responseType?: string;
codeChallenge?: string;
codeChallengeMethod?: string;
nonce?: string;
state?: string;
responseMode?: string;
}
): Promise<{
request_uri?: string;
expires_in?: number;
error?: string;
error_description?: string;
}> {
const form: Record<string, string> = {
client_id: params.clientId,
response_type: params.responseType ?? 'code',
scope: params.scope ?? 'openid profile email'
};
if (params.redirectUri) form.redirect_uri = params.redirectUri;
if (params.clientSecret) form.client_secret = params.clientSecret;
if (params.codeChallenge) form.code_challenge = params.codeChallenge;
if (params.codeChallengeMethod) form.code_challenge_method = params.codeChallengeMethod;
if (params.nonce) form.nonce = params.nonce;
if (params.state) form.state = params.state;
if (params.responseMode) form.response_mode = params.responseMode;
return page.request
.post('/api/oidc/par', {
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
form
})
.then((r) => r.json());
}
export async function getClientAssertion(
page: Page,
data: { issuer: string; audience: string; subject: string }