diff --git a/backend/frontend/frontend_excluded.go b/backend/frontend/frontend_excluded.go
index d7c652f6..fd203d26 100644
--- a/backend/frontend/frontend_excluded.go
+++ b/backend/frontend/frontend_excluded.go
@@ -4,9 +4,8 @@ package frontend
import (
"github.com/gin-gonic/gin"
- "github.com/pocket-id/pocket-id/backend/internal/service"
)
-func RegisterFrontend(router *gin.Engine, oidcService *service.OidcService) error {
+func RegisterFrontend(router *gin.Engine) error {
return ErrFrontendNotIncluded
}
diff --git a/backend/frontend/frontend_included.go b/backend/frontend/frontend_included.go
index 10bb6519..e35e3c0f 100644
--- a/backend/frontend/frontend_included.go
+++ b/backend/frontend/frontend_included.go
@@ -16,8 +16,6 @@ import (
"github.com/gin-gonic/gin"
"github.com/pocket-id/pocket-id/backend/internal/middleware"
- "github.com/pocket-id/pocket-id/backend/internal/service"
- "golang.org/x/time/rate"
)
//go:embed all:dist/*
@@ -55,7 +53,7 @@ func init() {
}
}
-func RegisterFrontend(router *gin.Engine, oidcService *service.OidcService) error {
+func RegisterFrontend(router *gin.Engine) error {
distFS, err := fs.Sub(frontendFS, "dist")
if err != nil {
return fmt.Errorf("failed to create sub FS: %w", err)
@@ -87,19 +85,6 @@ func RegisterFrontend(router *gin.Engine, oidcService *service.OidcService) erro
if isSPARequest(path, distFS) {
nonce := middleware.GetCSPNonce(c)
- // For an authorization request that responds via response_mode=form_post, the
- // consent page auto-submits a form to the client's callback, so that callback
- // must be allowed in the CSP form-action directive
- isAuthPostRequest, redirectURI := isOAuth2AuthorizationPostRequest(c, oidcService)
- if isAuthPostRequest {
- clientID := c.Query("client_id")
- // In that case, we need to validate and allow form submissions to the redirect_uri
- validatedRedirectURI, err := oidcService.ResolveAllowedCallbackURL(c.Request.Context(), clientID, redirectURI)
- if err == nil {
- c.Header("Content-Security-Policy", middleware.BuildCSP(nonce, validatedRedirectURI))
- }
- }
-
// Do not cache the HTML shell, as it embeds a per-request nonce
c.Header("Content-Type", "text/html; charset=utf-8")
c.Header("Cache-Control", "no-store")
@@ -115,55 +100,11 @@ func RegisterFrontend(router *gin.Engine, oidcService *service.OidcService) erro
fileServer.ServeHTTP(c.Writer, c.Request)
}
- rateLimitMiddleware := middleware.NewRateLimitMiddleware().Add(rate.Every(300*time.Millisecond), 50)
- router.NoRoute(rateLimitOnlyForOAuth2AuthorizationPostRequest(rateLimitMiddleware, oidcService, distFS), handler)
+ router.NoRoute(handler)
return nil
}
-func rateLimitOnlyForOAuth2AuthorizationPostRequest(rateLimitMiddleware gin.HandlerFunc, oidcService *service.OidcService, distFS fs.FS) gin.HandlerFunc {
- return func(c *gin.Context) {
- path := strings.TrimPrefix(c.Request.URL.Path, "/")
-
- if isSPARequest(path, distFS) {
- isAuthPostRequest, _ := isOAuth2AuthorizationPostRequest(c, oidcService)
- if isAuthPostRequest {
- rateLimitMiddleware(c)
- return
- }
- }
-
- c.Next()
- }
-}
-
-// isOAuth2AuthorizationRequest checks if this is an OAuth2 authorization request with response_mode=form_post
-// In that case, we need to validate and allow form submissions to the redirect_uri
-func isOAuth2AuthorizationPostRequest(c *gin.Context, oidcService *service.OidcService) (bool, string) {
- responseMode := c.Query("response_mode")
- redirectURI := c.Query("redirect_uri")
- clientID := c.Query("client_id")
- requestUri := c.Query("request_uri")
-
- if c.Request.URL.Path != "/authorize" {
- return false, ""
- }
-
- if requestUri != "" && clientID != "" {
- par, err := oidcService.GetPushedAuthorizationRequest(c.Request.Context(), clientID, requestUri)
- if err != nil {
- return false, ""
- }
-
- responseMode = par.Parameters.ResponseMode
- redirectURI = par.Parameters.RedirectURI
-
- }
-
- ok := responseMode == "form_post" && redirectURI != "" && clientID != ""
- return ok, redirectURI
-}
-
func isSPARequest(path string, distFS fs.FS) bool {
if path == "" {
return true
diff --git a/backend/frontend/frontend_included_test.go b/backend/frontend/frontend_included_test.go
index b5421342..0e755abc 100644
--- a/backend/frontend/frontend_included_test.go
+++ b/backend/frontend/frontend_included_test.go
@@ -3,12 +3,9 @@
package frontend
import (
- "net/http"
- "net/http/httptest"
"testing"
"testing/fstest"
- "github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
)
@@ -29,109 +26,3 @@ func TestIsSPARequest(t *testing.T) {
assert.True(t, isSPARequest("authorize", distFS))
})
}
-
-func TestRateLimitOnlyForOAuth2AuthorizationPostRequest(t *testing.T) {
- gin.SetMode(gin.TestMode)
-
- distFS := fstest.MapFS{
- "assets/app.js": &fstest.MapFile{Data: []byte("console.log('test')")},
- }
-
- t.Run("rate limits spa form_post request", func(t *testing.T) {
- rateLimited := false
- nextCalled := false
- middleware := rateLimitOnlyForOAuth2AuthorizationPostRequest(func(c *gin.Context) {
- rateLimited = true
- c.Abort()
- }, nil, distFS)
-
- router := gin.New()
- router.NoRoute(
- middleware,
- func(c *gin.Context) {
- nextCalled = true
- },
- )
-
- recorder := httptest.NewRecorder()
- req := httptest.NewRequest(http.MethodGet, "/authorize?response_mode=form_post&client_id=test&redirect_uri=https://example.com/callback", nil)
- router.ServeHTTP(recorder, req)
-
- assert.True(t, rateLimited)
- assert.False(t, nextCalled)
- })
-
- t.Run("does not rate limit page request with no form_post params", func(t *testing.T) {
- rateLimited := false
- nextCalled := false
- middleware := rateLimitOnlyForOAuth2AuthorizationPostRequest(func(c *gin.Context) {
- rateLimited = true
- c.Abort()
- }, nil, distFS)
-
- router := gin.New()
- router.NoRoute(
- middleware,
- func(c *gin.Context) {
- nextCalled = true
- },
- )
-
- recorder := httptest.NewRecorder()
- req := httptest.NewRequest(http.MethodGet, "/authorize", nil)
- router.ServeHTTP(recorder, req)
-
- assert.False(t, rateLimited)
- assert.True(t, nextCalled)
- })
-
- t.Run("does not rate limit static asset request with form_post params", func(t *testing.T) {
- rateLimited := false
- nextCalled := false
- middleware := rateLimitOnlyForOAuth2AuthorizationPostRequest(func(c *gin.Context) {
- rateLimited = true
- c.Abort()
- }, nil, distFS)
-
- router := gin.New()
- router.NoRoute(
- middleware,
- func(c *gin.Context) {
- nextCalled = true
- },
- )
-
- recorder := httptest.NewRecorder()
- req := httptest.NewRequest(http.MethodGet, "/assets/app.js?response_mode=form_post&client_id=test&redirect_uri=https://example.com/callback", nil)
- router.ServeHTTP(recorder, req)
-
- assert.False(t, rateLimited)
- assert.True(t, nextCalled)
- })
-
- t.Run("does not rate limit non-authorize spa path with form_post params", func(t *testing.T) {
- rateLimited := false
- nextCalled := false
- // oidcService is nil: a non-/authorize path is rejected by the path guard
- // before the request_uri branch that would dereference it.
- middleware := rateLimitOnlyForOAuth2AuthorizationPostRequest(func(c *gin.Context) {
- rateLimited = true
- c.Abort()
- }, nil, distFS)
-
- router := gin.New()
- router.NoRoute(
- middleware,
- func(c *gin.Context) {
- nextCalled = true
- },
- )
-
- recorder := httptest.NewRecorder()
- req := httptest.NewRequest(http.MethodGet, "/settings?response_mode=form_post&client_id=test&redirect_uri=https://example.com/callback", nil)
- router.ServeHTTP(recorder, req)
-
- assert.False(t, rateLimited)
- assert.True(t, nextCalled)
- })
-}
diff --git a/backend/go.mod b/backend/go.mod
index 958909f4..ffbfd7a7 100644
--- a/backend/go.mod
+++ b/backend/go.mod
@@ -22,6 +22,7 @@ require (
github.com/glebarez/go-sqlite v1.22.0
github.com/glebarez/sqlite v1.11.0
github.com/go-co-op/gocron/v2 v2.21.2
+ github.com/go-jose/go-jose/v4 v4.1.4
github.com/go-ldap/ldap/v3 v3.4.13
github.com/go-playground/validator/v10 v10.30.3
github.com/go-webauthn/webauthn v0.17.4
@@ -36,6 +37,7 @@ require (
github.com/mattn/go-isatty v0.0.22
github.com/mileusna/useragent v1.3.5
github.com/orandin/slog-gorm v1.4.0
+ github.com/ory/fosite v0.49.1-0.20250703093431-a5f0b09bf31c
github.com/oschwald/maxminddb-golang/v2 v2.4.0
github.com/spf13/cobra v1.10.2
github.com/stretchr/testify v1.11.1
@@ -62,6 +64,7 @@ require (
require (
github.com/Azure/go-ntlmssp v0.1.0 // indirect
+ github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.13 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.29 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.29 // indirect
@@ -82,8 +85,10 @@ require (
github.com/bytedance/sonic/loader v0.5.1 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/cloudwego/base64x v0.1.7 // indirect
+ github.com/cristalhq/jwt/v5 v5.4.0 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1 // indirect
+ github.com/dgraph-io/ristretto/v2 v2.4.0 // indirect
github.com/disintegration/gift v1.2.1 // indirect
github.com/dsoprea/go-exif v0.0.0-20230826092837-6579e82b732d // indirect
github.com/dsoprea/go-exif/v2 v2.0.0-20230826092837-6579e82b732d // indirect
@@ -109,18 +114,24 @@ require (
github.com/go-xmlfmt/xmlfmt v1.1.3 // indirect
github.com/goccy/go-json v0.10.6 // indirect
github.com/goccy/go-yaml v1.19.2 // indirect
+ github.com/gogo/googleapis v1.4.1 // indirect
+ github.com/gogo/protobuf v1.3.2 // indirect
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
github.com/golang/geo v0.0.0-20250319145452-ed1c8b99c3d7 // indirect
github.com/google/go-github/v39 v39.2.0 // indirect
github.com/google/go-querystring v1.2.0 // indirect
github.com/google/go-tpm v0.9.8 // indirect
+ github.com/gorilla/websocket v1.5.3 // indirect
github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 // indirect
github.com/h2non/filetype v1.1.3 // indirect
+ github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
+ github.com/hashicorp/go-retryablehttp v0.7.8 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/pgx/v5 v5.9.1 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
+ github.com/jaegertracing/jaeger-idl v0.9.0 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/jonboulle/clockwork v0.5.0 // indirect
@@ -134,13 +145,21 @@ require (
github.com/lestrrat-go/option/v2 v2.0.0 // indirect
github.com/lib/pq v1.12.3 // indirect
github.com/mattn/go-sqlite3 v1.14.42 // indirect
+ github.com/mattn/goveralls v0.0.12 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
+ github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/nlnwa/whatwg-url v0.6.2 // indirect
+ github.com/openzipkin/zipkin-go v0.4.3 // indirect
+ github.com/ory/go-acc v0.2.9-0.20230103102148-6b1c9a70dbbe // indirect
+ github.com/ory/go-convenience v0.1.0 // indirect
+ github.com/ory/pop/v6 v6.4.1 // indirect
+ github.com/ory/x v0.0.729 // indirect
github.com/pelletier/go-toml/v2 v2.3.1 // indirect
github.com/philhofer/fwd v1.2.0 // indirect
+ github.com/pkg/errors v0.9.1 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/prometheus/client_golang v1.23.2 // indirect
github.com/prometheus/client_model v0.6.2 // indirect
@@ -151,8 +170,15 @@ require (
github.com/quic-go/quic-go v0.59.1 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/robfig/cron/v3 v3.0.1 // indirect
+ github.com/sagikazarmark/locafero v0.12.0 // indirect
+ github.com/seatgeek/logrus-gelf-formatter v0.0.0-20210414080842-5b05eb8ff761 // indirect
github.com/segmentio/asm v1.2.1 // indirect
+ github.com/sirupsen/logrus v1.9.4 // indirect
+ github.com/spf13/afero v1.15.0 // indirect
+ github.com/spf13/cast v1.10.0 // indirect
github.com/spf13/pflag v1.0.10 // indirect
+ github.com/spf13/viper v1.21.0 // indirect
+ github.com/subosito/gotenv v1.6.0 // indirect
github.com/tinylib/msgp v1.6.4 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.3.1 // indirect
@@ -161,6 +187,11 @@ require (
go.mongodb.org/mongo-driver/v2 v2.6.0 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/contrib/bridges/prometheus v0.69.0 // indirect
+ go.opentelemetry.io/contrib/instrumentation/net/http/httptrace/otelhttptrace v0.69.0 // indirect
+ go.opentelemetry.io/contrib/propagators/b3 v1.44.0 // indirect
+ go.opentelemetry.io/contrib/propagators/jaeger v1.44.0 // indirect
+ go.opentelemetry.io/contrib/samplers/jaegerremote v0.37.1 // indirect
+ go.opentelemetry.io/otel/exporters/jaeger v1.17.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.20.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.20.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.44.0 // indirect
@@ -172,15 +203,20 @@ require (
go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.20.0 // indirect
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.44.0 // indirect
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.44.0 // indirect
+ go.opentelemetry.io/otel/exporters/zipkin v1.44.0 // indirect
go.opentelemetry.io/proto/otlp v1.10.0 // indirect
+ go.uber.org/mock v0.6.0 // indirect
go.yaml.in/yaml/v2 v2.4.4 // indirect
+ go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/arch v0.27.0 // indirect
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f // indirect
+ golang.org/x/mod v0.37.0 // indirect
golang.org/x/net v0.55.0 // indirect
golang.org/x/oauth2 v0.36.0 // indirect
golang.org/x/sys v0.46.0 // indirect
- google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa // indirect
- google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa // indirect
+ golang.org/x/tools v0.45.0 // indirect
+ google.golang.org/genproto/googleapis/api v0.0.0-20260608224507-4308a22a1bab // indirect
+ google.golang.org/genproto/googleapis/rpc v0.0.0-20260608224507-4308a22a1bab // indirect
google.golang.org/grpc v1.81.1 // indirect
google.golang.org/protobuf v1.36.11 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
@@ -190,3 +226,5 @@ require (
modernc.org/memory v1.11.0 // indirect
modernc.org/sqlite v1.48.2 // indirect
)
+
+replace github.com/ory/fosite => github.com/pocket-id/fosite v0.0.0-20260617200813-dd5303674b39
diff --git a/backend/go.sum b/backend/go.sum
index 86dd29d6..3a28269b 100644
--- a/backend/go.sum
+++ b/backend/go.sum
@@ -1,11 +1,13 @@
-github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25UVaW/CKtUDjefjrs0SPonmDGUVOYP0=
-github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
+github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg=
+github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
github.com/Azure/go-ntlmssp v0.1.0 h1:DjFo6YtWzNqNvQdrwEyr/e4nhU3vRiwenz5QX7sFz+A=
github.com/Azure/go-ntlmssp v0.1.0/go.mod h1:NYqdhxd/8aAct/s4qSYZEerdPuH1liG2/X9DiVTbhpk=
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e h1:4dAU9FXIyQktpoUAgOJK3OTFc/xug0PCXYCqU0FgDKI=
github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4=
+github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3dyBCFEj5IhUbnKptjxatkF07cF2ak3yi77so=
+github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw=
github.com/aws/aws-sdk-go-v2 v1.42.0 h1:XvXMJTkFQtpBKIWZnmr9ZEOc2InWM2yldjXEJ/bymhA=
github.com/aws/aws-sdk-go-v2 v1.42.0/go.mod h1:27+ACypSLljLAEKsCYOmrjKh83vuTRkuAe9Uv/3A4bg=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.13 h1:p1BBrg/Hhp6uK7zpejeI8QFXHJeC/mynzi04Sl03k9g=
@@ -68,12 +70,18 @@ github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmC
github.com/coreos/go-systemd v0.0.0-20191104093116-d3cd4ed1dbcf h1:iW4rZ826su+pqaw19uhpSCzhj44qo35pNgKFGqzDKkU=
github.com/coreos/go-systemd v0.0.0-20191104093116-d3cd4ed1dbcf/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
+github.com/cristalhq/jwt/v5 v5.4.0 h1:Wxi1TocFHaijyV608j7v7B9mPc4ZNjvWT3LKBO0d4QI=
+github.com/cristalhq/jwt/v5 v5.4.0/go.mod h1:+b/BzaCWEpFDmXxspJ5h4SdJ1N/45KMjKOetWzmHvDA=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1 h1:5RVFMOWjMyRy8cARdy79nAmgYw3hK/4HUq48LQ6Wwqo=
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40=
+github.com/dgraph-io/ristretto/v2 v2.4.0 h1:I/w09yLjhdcVD2QV192UJcq8dPBaAJb9pOuMyNy0XlU=
+github.com/dgraph-io/ristretto/v2 v2.4.0/go.mod h1:0KsrXtXvnv0EqnzyowllbVJB8yBonswa2lTCK2gGo9E=
+github.com/dgryski/go-farm v0.0.0-20240924180020-3414d57e47da h1:aIftn67I1fkbMa512G+w+Pxci9hJPB8oMnkcP3iZF38=
+github.com/dgryski/go-farm v0.0.0-20240924180020-3414d57e47da/go.mod h1:SqUrOPUnsFjfmXRMNPybcSiG0BgUW2AuFH8PAnS2iTw=
github.com/dhui/dktest v0.4.6 h1:+DPKyScKSEp3VLtbMDHcUq6V5Lm5zfZZVb0Sk7Ahom4=
github.com/dhui/dktest v0.4.6/go.mod h1:JHTSYDtKkvFNFHJKqCzVzqXecyv+tKt8EzceOmQOgbU=
github.com/disintegration/gift v1.1.2/go.mod h1:Jh2i7f7Q2BM7Ezno3PhfezbR1xpUg9dUg3/RlKGr4HI=
@@ -125,8 +133,12 @@ github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 h1:oP4q0fw+fOSWn3
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ=
github.com/emersion/go-smtp v0.24.0 h1:g6AfoF140mvW0vLNPD/LuCBLEAdlxOjIXqbIkJIS6Wk=
github.com/emersion/go-smtp v0.24.0/go.mod h1:ZtRRkbTyp2XTHCA+BmyTFTrj8xY4I+b4McvHxCU2gsQ=
+github.com/fatih/color v1.19.0 h1:Zp3PiM21/9Ld6FzSKyL5c/BULoe/ONr9KlbYVOfG8+w=
+github.com/fatih/color v1.19.0/go.mod h1:zNk67I0ZUT1bEGsSGyCZYZNrHuTkJJB+r6Q9VuMi0LE=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
+github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
+github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho=
github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo=
github.com/fxamacker/cbor/v2 v2.9.2 h1:X4Ksno9+x3cz0TZv69ec1hxP/+tymuR8PXQJyDwfh78=
@@ -152,6 +164,8 @@ github.com/go-errors/errors v1.0.2/go.mod h1:psDX2osz5VnTOnFWbDeWwS7yejl+uV3FEWE
github.com/go-errors/errors v1.1.1/go.mod h1:psDX2osz5VnTOnFWbDeWwS7yejl+uV3FEWEp4lssFEs=
github.com/go-errors/errors v1.5.1 h1:ZwEMSLRCapFLflTpT7NKaAc7ukJ8ZPEjzlxt8rPN8bk=
github.com/go-errors/errors v1.5.1/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og=
+github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA=
+github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
github.com/go-ldap/ldap/v3 v3.4.13 h1:+x1nG9h+MZN7h/lUi5Q3UZ0fJ1GyDQYbPvbuH38baDQ=
github.com/go-ldap/ldap/v3 v3.4.13/go.mod h1:LxsGZV6vbaK0sIvYfsv47rfh4ca0JXokCoKjZxsszv0=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
@@ -180,6 +194,8 @@ github.com/goccy/go-json v0.10.6 h1:p8HrPJzOakx/mn/bQtjgNjdTcN+/S6FcG2CTtQOrHVU=
github.com/goccy/go-json v0.10.6/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
+github.com/gogo/googleapis v1.4.1 h1:1Yx4Myt7BxzvUr5ldGSbwYiZG6t9wGBZ+8/fX3Wvtq0=
+github.com/gogo/googleapis v1.4.1/go.mod h1:2lpHqI5OcWCtVElxXnPt+s8oJvMpySlOyM6xDCrzib4=
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
@@ -213,10 +229,18 @@ github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17k
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
+github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
+github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 h1:5VipnvEpbqr2gA2VbM+nYVbkIF28c5ZQfqCBQ5g2xfk=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0/go.mod h1:Hyl3n6Twe1hvtd9XUXDec4pTvgMSEixRuQKPTMH2bNs=
github.com/h2non/filetype v1.1.3 h1:FKkx9QbD7HR/zjK1Ia5XiBsq9zdLi5Kf3zGyFTAFkGg=
github.com/h2non/filetype v1.1.3/go.mod h1:319b3zT68BvV+WRj7cwy856M2ehB3HqNOt6sy1HndBY=
+github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ=
+github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48=
+github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k=
+github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M=
+github.com/hashicorp/go-retryablehttp v0.7.8 h1:ylXZWnqa7Lhqpk0L1P1LzDtGcCR0rPVUrx/c8Unxc48=
+github.com/hashicorp/go-retryablehttp v0.7.8/go.mod h1:rjiScheydd+CxvumBsIrFKlx3iS0jrZ7LvzFGFmuKbw=
github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8=
github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
@@ -231,6 +255,8 @@ github.com/jackc/pgx/v5 v5.9.1 h1:uwrxJXBnx76nyISkhr33kQLlUqjv7et7b9FjCen/tdc=
github.com/jackc/pgx/v5 v5.9.1/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
+github.com/jaegertracing/jaeger-idl v0.9.0 h1:dI4olA7ArW3cjXwVbic/aYKDbdlfe7V+9wPQqAdzu8Y=
+github.com/jaegertracing/jaeger-idl v0.9.0/go.mod h1:W+9vbcr2cVZyS6z/cbr540EOzSkKYml3hmaWEavxkB0=
github.com/jcmturner/aescts/v2 v2.0.0 h1:9YKLH6ey7H4eDBXW8khjYslgyqG2xZikXP0EQFKrle8=
github.com/jcmturner/aescts/v2 v2.0.0/go.mod h1:AiaICIRyfYg35RUkr8yESTqvSy7csK90qZ5xfvvsoNs=
github.com/jcmturner/dnsutils/v2 v2.0.0 h1:lltnkeZGL0wILNvrNiVCR6Ro5PGU/SeBvVO/8c/iPbo=
@@ -256,10 +282,20 @@ github.com/jonboulle/clockwork v0.5.0 h1:Hyh9A8u51kptdkR+cqRpT1EebBwTn1oK9YfGYbd
github.com/jonboulle/clockwork v0.5.0/go.mod h1:3mZlmanh0g2NDKO5TWZVJAfofYk64M7XN3SzBPjZF60=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
+github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
+github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
+github.com/knadh/koanf/maps v0.1.1 h1:G5TjmUh2D7G2YWf5SQQqSiHRJEjaicvU0KpypqB3NIs=
+github.com/knadh/koanf/maps v0.1.1/go.mod h1:npD/QZY3V6ghQDdcQzl1W4ICNVTkohC8E73eI2xW4yI=
+github.com/knadh/koanf/parsers/json v0.1.0 h1:dzSZl5pf5bBcW0Acnu20Djleto19T0CfHcvZ14NJ6fU=
+github.com/knadh/koanf/parsers/json v0.1.0/go.mod h1:ll2/MlXcZ2BfXD6YJcjVFzhG9P0TdJ207aIBKQhV2hY=
+github.com/knadh/koanf/providers/rawbytes v0.1.0 h1:dpzgu2KO6uf6oCb4aP05KDmKmAmI51k5pe8RYKQ0qME=
+github.com/knadh/koanf/providers/rawbytes v0.1.0/go.mod h1:mMTB1/IcJ/yE++A2iEZbY1MLygX7vttU+C+S/YmPu9c=
+github.com/knadh/koanf/v2 v2.1.2 h1:I2rtLRqXRy1p01m/utEtpZSSA6dcJbgGVuE27kW2PzQ=
+github.com/knadh/koanf/v2 v2.1.2/go.mod h1:Gphfaen0q1Fc1HTgJgSTC4oRX9R2R5ErYMZJy8fLJBo=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
@@ -286,21 +322,31 @@ github.com/lib/pq v1.12.3 h1:tTWxr2YLKwIvK90ZXEw8GP7UFHtcbTtty8zsI+YjrfQ=
github.com/lib/pq v1.12.3/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA=
github.com/lmittmann/tint v1.1.3 h1:Hv4EaHWXQr+GTFnOU4VKf8UvAtZgn0VuKT+G0wFlO3I=
github.com/lmittmann/tint v1.1.3/go.mod h1:HIS3gSy7qNwGCj+5oRjAutErFBl4BzdQP6cJZ0NfMwE=
+github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
+github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4=
github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
github.com/mattn/go-sqlite3 v1.14.42 h1:MigqEP4ZmHw3aIdIT7T+9TLa90Z6smwcthx+Azv4Cgo=
github.com/mattn/go-sqlite3 v1.14.42/go.mod h1:pjEuOr8IwzLJP2MfGeTb0A35jauH+C2kbHKBr7yXKVQ=
+github.com/mattn/goveralls v0.0.12 h1:PEEeF0k1SsTjOBQ8FOmrOAoCu4ytuMaWCnWe94zxbCg=
+github.com/mattn/goveralls v0.0.12/go.mod h1:44ImGEUfmqH8bBtaMrYKsM65LXfNLWmwaxFGjZwgMSQ=
github.com/mileusna/useragent v1.3.5 h1:SJM5NzBmh/hO+4LGeATKpaEX9+b4vcGg2qXGLiNGDws=
github.com/mileusna/useragent v1.3.5/go.mod h1:3d8TOmwL/5I8pJjyVDteHtgDGcefrFUX4ccGOMKNYYc=
+github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw=
+github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s=
+github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ=
+github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw=
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
-github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0=
-github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y=
+github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ=
+github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
+github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 h1:RWengNIwukTxcDr9M+97sNutRR1RKhG96O6jWumTTnw=
+github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826/go.mod h1:TaXosZuwdSHYgviHp1DAtfrULt5eUgsSMsZf+YrPgl8=
github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
@@ -309,12 +355,30 @@ github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOF
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/nlnwa/whatwg-url v0.6.2 h1:jU61lU2ig4LANydbEJmA2nPrtCGiKdtgT0rmMd2VZ/Q=
github.com/nlnwa/whatwg-url v0.6.2/go.mod h1:x0FPXJzzOEieQtsBT/AKvbiBbQ46YlL6Xa7m02M1ECk=
+github.com/nyaruka/phonenumbers v1.5.0 h1:0M+Gd9zl53QC4Nl5z1Yj1O/zPk2XXBUwR/vlzdXSJv4=
+github.com/nyaruka/phonenumbers v1.5.0/go.mod h1:gv+CtldaFz+G3vHHnasBSirAi3O2XLqZzVWz4V1pl2E=
+github.com/oleiade/reflections v1.1.0 h1:D+I/UsXQB4esMathlt0kkZRJZdUDmhv5zGi/HOwYTWo=
+github.com/oleiade/reflections v1.1.0/go.mod h1:mCxx0QseeVCHs5Um5HhJeCKVC7AwS8kO67tky4rdisA=
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
-github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug=
-github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM=
+github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=
+github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M=
+github.com/openzipkin/zipkin-go v0.4.3 h1:9EGwpqkgnwdEIJ+Od7QVSEIH+ocmm5nPat0G7sjsSdg=
+github.com/openzipkin/zipkin-go v0.4.3/go.mod h1:M9wCJZFWCo2RiY+o1eBCEMe0Dp2S5LDHcMZmk3RmK7c=
github.com/orandin/slog-gorm v1.4.0 h1:FgA8hJufF9/jeNSYoEXmHPPBwET2gwlF3B85JdpsTUU=
github.com/orandin/slog-gorm v1.4.0/go.mod h1:MoZ51+b7xE9lwGNPYEhxcUtRNrYzjdcKvA8QXQQGEPA=
+github.com/ory/go-acc v0.2.9-0.20230103102148-6b1c9a70dbbe h1:rvu4obdvqR0fkSIJ8IfgzKOWwZ5kOT2UNfLq81Qk7rc=
+github.com/ory/go-acc v0.2.9-0.20230103102148-6b1c9a70dbbe/go.mod h1:z4n3u6as84LbV4YmgjHhnwtccQqzf4cZlSk9f1FhygI=
+github.com/ory/go-convenience v0.1.0 h1:zouLKfF2GoSGnJwGq+PE/nJAE6dj2Zj5QlTgmMTsTS8=
+github.com/ory/go-convenience v0.1.0/go.mod h1:uEY/a60PL5c12nYz4V5cHY03IBmwIAEm8TWB0yn9KNs=
+github.com/ory/herodot v0.10.3-0.20250318104651-3179543efba8 h1:bBFBzJ+sy1l/9+uYaz5TLGNNe0GWeXPMyqLhUEy9gPg=
+github.com/ory/herodot v0.10.3-0.20250318104651-3179543efba8/go.mod h1:aq2fDNzFXlh8wF6+ILtlEin2oZSrqR79/Zdsi05WEVA=
+github.com/ory/jsonschema/v3 v3.0.9-0.20250317235931-280c5fc7bf0e h1:4tUrC7x4YWRVMFp+c64KACNSGchW1zXo4l6Pa9/1hA8=
+github.com/ory/jsonschema/v3 v3.0.9-0.20250317235931-280c5fc7bf0e/go.mod h1:XWLxVK4un/iuIcrw+6lCeanbF3NZwO5k6RdLeu/loQk=
+github.com/ory/pop/v6 v6.4.1 h1:mxwfgwIB+kRlE4hvcoeEuxFqXZai6TWgQ23sOCBTERo=
+github.com/ory/pop/v6 v6.4.1/go.mod h1:o+a3+gdnfWUd/IpFCTKidX7sRgQ6GdPmH02XYiMH8cw=
+github.com/ory/x v0.0.729 h1:7ttCYNCjCdspI6X0oaxGAXoiYWSBrwGRz6w/IG8s3I4=
+github.com/ory/x v0.0.729/go.mod h1:qdUK3Sp4K4nRbYJG0sEnFO1tDLN/Ct53G+ymre0JhCU=
github.com/oschwald/maxminddb-golang/v2 v2.4.0 h1:3ftnrR1/XwiQ788bWIRhsE1DK3GOgJ6tm6S2qTktLm8=
github.com/oschwald/maxminddb-golang/v2 v2.4.0/go.mod h1:7jcFtmhWVDEV+UopVv9NjcPm200uMyEHN14LIVV4hW8=
github.com/pelletier/go-toml/v2 v2.3.1 h1:MYEvvGnQjeNkRF1qUuGolNtNExTDwct51yp7olPtrEc=
@@ -326,6 +390,8 @@ github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINE
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/pocket-id/fosite v0.0.0-20260617200813-dd5303674b39 h1:+gvFcRlW9RprrtKe0ltCWERkFkKlEBdFaQWMwBWGznQ=
+github.com/pocket-id/fosite v0.0.0-20260617200813-dd5303674b39/go.mod h1:KeQ7tTIBm3DyeBnKcKLnPbSdrd6ttM6w3TD3yy9x8rM=
github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
@@ -347,16 +413,29 @@ github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzG
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
+github.com/sagikazarmark/locafero v0.12.0 h1:/NQhBAkUb4+fH1jivKHWusDYFjMOOKU88eegjfxfHb4=
+github.com/sagikazarmark/locafero v0.12.0/go.mod h1:sZh36u/YSZ918v0Io+U9ogLYQJ9tLLBmM4eneO6WwsI=
+github.com/seatgeek/logrus-gelf-formatter v0.0.0-20210414080842-5b05eb8ff761 h1:0b8DF5kR0PhRoRXDiEEdzrgBc8UqVY4JWLkQJCRsLME=
+github.com/seatgeek/logrus-gelf-formatter v0.0.0-20210414080842-5b05eb8ff761/go.mod h1:/THDZYi7F/BsVEcYzYPqdcWFQ+1C2InkawTKfLOAnzg=
github.com/segmentio/asm v1.2.1 h1:DTNbBqs57ioxAD4PrArqftgypG4/qNpXoJx8TVXxPR0=
github.com/segmentio/asm v1.2.1/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs=
+github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w=
+github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g=
+github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
+github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=
+github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
+github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
+github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU=
+github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
+github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
@@ -366,16 +445,30 @@ github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXl
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
+github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
+github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
+github.com/tidwall/gjson v1.19.0 h1:xwxm7n691Uf3u5OFjzngavjGTh55KX5q/9w9xHW88JU=
+github.com/tidwall/gjson v1.19.0/go.mod h1:V37/opeE/JbLUOfH0QTXiNez2l0RUjYUhpT4szFQAfc=
+github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
+github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
+github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
+github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
+github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
+github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
github.com/tinylib/msgp v1.6.4 h1:mOwYbyYDLPj35mkA2BjjYejgJk9BuHxDdvRnb6v2ZcQ=
github.com/tinylib/msgp v1.6.4/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
+github.com/urfave/negroni v1.0.0 h1:kIimOitoypq34K7TG7DUaJ9kq/N4Ofuwi1sjz0KipXc=
+github.com/urfave/negroni v1.0.0/go.mod h1:Meg73S6kFm/4PpbYdq35yYWoCZ9mS/YSx+lKnmiohz4=
github.com/valyala/fastjson v1.6.10 h1:/yjJg8jaVQdYR3arGxPE2X5z89xrlhS0eGXdv+ADTh4=
github.com/valyala/fastjson v1.6.10/go.mod h1:e6FubmQouUNP73jtMLmcbxS6ydWIpOfhz34TSfO3JaE=
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
+github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
+github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/zitadel/exifremove v0.1.0 h1:qD50ezWsfeeqfcvs79QyyjVfK+snN12v0U0deaU8aKg=
github.com/zitadel/exifremove v0.1.0/go.mod h1:rzKJ3woL/Rz2KthVBiSBKIBptNTvgmk9PLaeUKTm+ek=
@@ -391,12 +484,20 @@ go.opentelemetry.io/contrib/exporters/autoexport v0.69.0 h1:R3jsCoTIzv0BiYNhW0ax
go.opentelemetry.io/contrib/exporters/autoexport v0.69.0/go.mod h1:m07gqyr2QhQxKOKb5vqKCCBtLH3uqlNYR7PU/FISXVU=
go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin v0.69.0 h1:u5gsfBL8t1Km4ROhQKAs0cA0t9CzUE7nfkASj/UjAtI=
go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin v0.69.0/go.mod h1:W6FFYCZQuntC5hxVesXpu7Ppd9sT0a84njildAijc+k=
+go.opentelemetry.io/contrib/instrumentation/net/http/httptrace/otelhttptrace v0.69.0 h1:MCcYL7J6Vt/X0kjqbMZkekCmwsurbQRbL69vkiye2lk=
+go.opentelemetry.io/contrib/instrumentation/net/http/httptrace/otelhttptrace v0.69.0/go.mod h1:3jnStNwSufK+f5ktjL4EPcwtig4rtd81NS70lqHuXl8=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 h1:8tvICD4vSTOOsNrsI4Ljf6C+6UKvpTEH5XY3JMoyPoo=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0/go.mod h1:z9+yiacE0IHRqM4qFfkbt/JYlmYXgss8GY/jXoNuPJI=
go.opentelemetry.io/contrib/propagators/b3 v1.44.0 h1:1IFH4oFKK8KupzIelCl3u+bkxpGRps1oWRjQI2+TTWs=
go.opentelemetry.io/contrib/propagators/b3 v1.44.0/go.mod h1:JqWFXsc7VDaqIyubFhEd2cPHqsrzqP0Lvn783SUwyro=
+go.opentelemetry.io/contrib/propagators/jaeger v1.44.0 h1:OyzvsAMc/zHt0DRPcfstn0wgfq8ApDkeY0ABMcueweM=
+go.opentelemetry.io/contrib/propagators/jaeger v1.44.0/go.mod h1:44kghcGX+BNxy9UTiWtd6VDt8Nd4EypGBkH2+v2Dqrc=
+go.opentelemetry.io/contrib/samplers/jaegerremote v0.37.1 h1:pV2nZ1iE87X9ym+crkD9k15zTLbN08+IC4C7sk9sQYM=
+go.opentelemetry.io/contrib/samplers/jaegerremote v0.37.1/go.mod h1:nvgrM8LaG2+5G7WxbtjEPiSkg87+d0/ltZZK70p7FVo=
go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU=
go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc=
+go.opentelemetry.io/otel/exporters/jaeger v1.17.0 h1:D7UpUy2Xc2wsi1Ras6V40q806WM07rqoCWzXu7Sqy+4=
+go.opentelemetry.io/otel/exporters/jaeger v1.17.0/go.mod h1:nPCqOnEH9rNLKqH/+rrUjiMzHJdV1BlpKcTwRTyKkKI=
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.20.0 h1:rydZ9sxbcFdm/oWrVyfLTjHIygMgv0bEeMd+3B/BvoM=
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.20.0/go.mod h1:earQ25dooT0Hhspq59DZ8YCC50jWfOlFEeWoxy/P444=
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.20.0 h1:owlhcJ3QO3X0YTDTCcDZ4V+6aVDkWbNmBoQ5NUp7Oww=
@@ -419,6 +520,8 @@ go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.44.0 h1:hqxVTu/GtBF+vJ
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.44.0/go.mod h1:z5fVEF4X5v0ESvlJqBrrFlBVoj5EQuefZpzsu7R+x5Q=
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.44.0 h1:bl2S7Ubua0Nms+D/gAmznQTd4dxxMA93aKbcpKqiTCs=
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.44.0/go.mod h1:L0hRV50XdVIODHUfWEqGRCXQvj2rV82STVo12FMFBU0=
+go.opentelemetry.io/otel/exporters/zipkin v1.44.0 h1:zv7PRYGLrQHkdeZj0c5SNAZOJcw55XgaTezUkNpwA+w=
+go.opentelemetry.io/otel/exporters/zipkin v1.44.0/go.mod h1:3+VZyCi6hFW+UuxFF+wSOvwsOwncfBpQfP7Qdb3JXKg=
go.opentelemetry.io/otel/log v0.20.0 h1:/5i0vuHxCLWUfChWG41K9wkM0jafruPw9NU1/RCJirs=
go.opentelemetry.io/otel/log v0.20.0/go.mod h1:wOcMcjsZpG8x7Bak7IhSi/lg8wscV2C1VdrKCLPlt0E=
go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc=
@@ -443,10 +546,13 @@ go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ=
go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ=
+go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/arch v0.27.0 h1:0WNVcR8u9yFz8j5FvdHpgwNp3FS5U4guYdzHwEiGjoU=
golang.org/x/arch v0.27.0/go.mod h1:0X+GdSIP+kL5wPmpK7sdkEVTt2XoYP0cSjQSbZBwOi8=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
+golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
+golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20210817164053-32db794688a5/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
@@ -460,23 +566,30 @@ golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f/go.mod h1:J1xhfL/vlindoeF/aI
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/image v0.42.0 h1:1gSs6ehNWXLbkHBIPcWztk3D/6aIA/8hauiAYtlodVY=
golang.org/x/image v0.42.0/go.mod h1:rrpelvGFt+kLPAjPM4HeWPgrl0FtafueU//e5N0qk/Q=
+golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
+golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
+golang.org/x/mod v0.10.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
-golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4=
-golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ=
+golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
+golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
+golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20191209160850-c0dbc17a3553/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
+golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200320220750-118fecf932d8/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200501053045-e0ff5e5a1de5/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20200513185701-a91f0712d120/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20200520182314-0ba52f642ac2/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
+golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
+golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
@@ -488,6 +601,8 @@ golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAG
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
@@ -497,12 +612,15 @@ golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
+golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
@@ -514,6 +632,7 @@ golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXct
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
+golang.org/x/term v0.7.0/go.mod h1:P32HKFT3hSsZrRxla30E9HqToFYAQPCMs/zFMBUFqPY=
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
@@ -535,21 +654,26 @@ golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
+golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
+golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
+golang.org/x/tools v0.8.0/go.mod h1:JxBZ99ISMI5ViVkT1tr6tdNmXeTrcpVSD3vZ1RsRdN4=
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8=
golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
-google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa h1:Kjn0N0tCrDgiAFW+lGO4JZ3ck44CehvJQMAwj9QF0G8=
-google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:q4lMZS6kskjT5HvCPrnnypcDPVJqT/f4nfxmkE7gryY=
-google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa h1:mZHHdPZl0dbGHCflZgAq/Q468DWVFcU2whhB2KAo8fk=
-google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
+google.golang.org/genproto/googleapis/api v0.0.0-20260608224507-4308a22a1bab h1:Foefixyu0l973HSYkX8Etw/fPxAmKRhyMGwuqXFiVI0=
+google.golang.org/genproto/googleapis/api v0.0.0-20260608224507-4308a22a1bab/go.mod h1:KdNqO+rCIWgFumrNBSEDlDNrkrQnpkax7Tv1WxNY8V4=
+google.golang.org/genproto/googleapis/rpc v0.0.0-20260608224507-4308a22a1bab h1:cY0oV1VnAqvaim8VsR8ZyEKAudzbRJMRGwD3W/L7yOw=
+google.golang.org/genproto/googleapis/rpc v0.0.0-20260608224507-4308a22a1bab/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/grpc v1.81.1 h1:VnnIIZ88UzOOKLukQi+ImGz8O1Wdp8nAGGnvOfEIWQQ=
google.golang.org/grpc v1.81.1/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
diff --git a/backend/internal/bootstrap/router_bootstrap.go b/backend/internal/bootstrap/router_bootstrap.go
index caeceb1a..4fb21e1e 100644
--- a/backend/internal/bootstrap/router_bootstrap.go
+++ b/backend/internal/bootstrap/router_bootstrap.go
@@ -109,7 +109,7 @@ func registerGlobalMiddleware(r *gin.Engine) {
func registerRoutes(r *gin.Engine, db *gorm.DB, svc *services) error {
- err := frontend.RegisterFrontend(r, svc.oidcService)
+ err := frontend.RegisterFrontend(r)
if errors.Is(err, frontend.ErrFrontendNotIncluded) {
slog.Warn("Frontend is not included in the build. Skipping frontend registration.")
} else if err != nil {
@@ -122,9 +122,11 @@ func registerRoutes(r *gin.Engine, db *gorm.DB, svc *services) error {
apiRateLimitMiddleware := middleware.NewRateLimitMiddleware().Add(rate.Every(time.Second), 100)
apiGroup := r.Group("/api", apiRateLimitMiddleware)
+ baseGroup := r.Group("/", apiRateLimitMiddleware)
+
controller.NewApiKeyController(apiGroup, authMiddleware, svc.apiKeyService)
controller.NewWebauthnController(apiGroup, authMiddleware, middleware.NewRateLimitMiddleware(), svc.webauthnService, svc.appConfigService)
- controller.NewOidcController(apiGroup, authMiddleware, fileSizeLimitMiddleware, svc.oidcService, svc.jwtService)
+ controller.NewOidcController(apiGroup, authMiddleware, fileSizeLimitMiddleware, svc.oidcService)
controller.NewUserController(apiGroup, authMiddleware, middleware.NewRateLimitMiddleware(), svc.userService, svc.oneTimeAccessService, svc.webauthnService, svc.appConfigService)
controller.NewAppConfigController(apiGroup, authMiddleware, svc.appConfigService, svc.emailService, svc.ldapService)
controller.NewAppImagesController(apiGroup, authMiddleware, svc.appImagesService)
@@ -135,9 +137,12 @@ func registerRoutes(r *gin.Engine, db *gorm.DB, svc *services) error {
controller.NewScimController(apiGroup, authMiddleware, svc.scimService)
controller.NewUserSignupController(apiGroup, authMiddleware, middleware.NewRateLimitMiddleware(), svc.userSignUpService, svc.appConfigService)
+ optionalBrowserAuth := authMiddleware.WithAdminNotRequired().WithSuccessOptional().WithApiKeyAuthDisabled().Add()
+ browserAuth := authMiddleware.WithAdminNotRequired().WithApiKeyAuthDisabled().Add()
+ svc.oidcModule.RegisterRoutes(baseGroup, apiGroup, optionalBrowserAuth, browserAuth)
+
registerTestRoutes(apiGroup, db, svc)
- baseGroup := r.Group("/", apiRateLimitMiddleware)
controller.NewWellKnownController(baseGroup, svc.jwtService)
// These are not rate-limited.
diff --git a/backend/internal/bootstrap/services_bootstrap.go b/backend/internal/bootstrap/services_bootstrap.go
index 069b814c..e20aed55 100644
--- a/backend/internal/bootstrap/services_bootstrap.go
+++ b/backend/internal/bootstrap/services_bootstrap.go
@@ -8,6 +8,8 @@ import (
"github.com/pocket-id/pocket-id/backend/internal/job"
"gorm.io/gorm"
+ "github.com/pocket-id/pocket-id/backend/internal/common"
+ "github.com/pocket-id/pocket-id/backend/internal/oidc"
"github.com/pocket-id/pocket-id/backend/internal/service"
"github.com/pocket-id/pocket-id/backend/internal/storage"
)
@@ -32,6 +34,8 @@ type services struct {
appLockService *service.AppLockService
userSignUpService *service.UserSignUpService
oneTimeAccessService *service.OneTimeAccessService
+
+ oidcModule *oidc.Module
}
// Initializes all services
@@ -67,7 +71,24 @@ func initServices(ctx context.Context, db *gorm.DB, httpClient *http.Client, ima
svc.scimService = service.NewScimService(db, scheduler, httpClient)
- svc.oidcService, err = service.NewOidcService(ctx, db, svc.jwtService, svc.appConfigService, svc.auditLogService, svc.customClaimService, svc.webauthnService, svc.scimService, httpClient, fileStorage)
+ svc.oidcModule, err = oidc.New(ctx, oidc.Dependencies{
+ DB: db,
+ HTTPClient: httpClient,
+ Config: oidc.Config{
+ BaseURL: common.EnvConfig.AppURL,
+ TokenBaseURL: common.EnvConfig.AppURL,
+ Secret: string(common.EnvConfig.EncryptionKey),
+ },
+ Signer: svc.jwtService,
+ CustomClaims: svc.customClaimService,
+ Reauth: svc.webauthnService,
+ AuditLog: svc.auditLogService,
+ })
+ if err != nil {
+ return nil, fmt.Errorf("failed to create OIDC module: %w", err)
+ }
+
+ svc.oidcService, err = service.NewOidcService(db, svc.jwtService, svc.appConfigService, svc.oidcModule.Preview, svc.scimService, httpClient, fileStorage)
if err != nil {
return nil, fmt.Errorf("failed to create OIDC service: %w", err)
}
diff --git a/backend/internal/common/claims.go b/backend/internal/common/claims.go
new file mode 100644
index 00000000..cd9a3275
--- /dev/null
+++ b/backend/internal/common/claims.go
@@ -0,0 +1,8 @@
+package common
+
+// AuthenticationMethodsClaim is the JWT claim ("amr") used to identify how the user
+// authenticated. It is shared between the session JWTs and the OIDC tokens.
+const AuthenticationMethodsClaim = "amr"
+
+// TokenTypeClaim is the JWT claim ("type") used to identify the type of token.
+const TokenTypeClaim = "type"
diff --git a/backend/internal/common/errors.go b/backend/internal/common/errors.go
index fd4c87b6..873054de 100644
--- a/backend/internal/common/errors.go
+++ b/backend/internal/common/errors.go
@@ -62,43 +62,6 @@ type OidcMissingAuthorizationError struct{}
func (e OidcMissingAuthorizationError) Error() string { return "missing authorization" }
func (e OidcMissingAuthorizationError) HttpStatusCode() int { return http.StatusForbidden }
-type OidcGrantTypeNotSupportedError struct{}
-
-func (e OidcGrantTypeNotSupportedError) Error() string { return "grant type not supported" }
-func (e OidcGrantTypeNotSupportedError) HttpStatusCode() int { return http.StatusBadRequest }
-
-type OidcMissingClientCredentialsError struct{}
-
-func (e OidcMissingClientCredentialsError) Error() string { return "client id or secret not provided" }
-func (e OidcMissingClientCredentialsError) HttpStatusCode() int { return http.StatusBadRequest }
-
-type OidcClientSecretInvalidError struct{}
-
-func (e OidcClientSecretInvalidError) Error() string { return "invalid client secret" }
-func (e OidcClientSecretInvalidError) HttpStatusCode() int { return http.StatusUnauthorized }
-
-type OidcClientAssertionInvalidError struct{}
-
-func (e OidcClientAssertionInvalidError) Error() string { return "invalid client assertion" }
-func (e OidcClientAssertionInvalidError) HttpStatusCode() int { return http.StatusUnauthorized }
-
-type OidcInvalidAuthorizationCodeError struct{}
-
-func (e OidcInvalidAuthorizationCodeError) Error() string { return "invalid authorization code" }
-func (e OidcInvalidAuthorizationCodeError) HttpStatusCode() int { return http.StatusBadRequest }
-
-type OidcClientNotFoundError struct{}
-
-func (e OidcClientNotFoundError) Error() string { return "client not found" }
-func (e OidcClientNotFoundError) HttpStatusCode() int { return http.StatusNotFound }
-
-type OidcMissingCallbackURLError struct{}
-
-func (e OidcMissingCallbackURLError) Error() string {
- return "unable to detect callback url, it might be necessary for an admin to fix this"
-}
-func (e OidcMissingCallbackURLError) HttpStatusCode() int { return http.StatusBadRequest }
-
type OidcInvalidCallbackURLError struct{}
func (e OidcInvalidCallbackURLError) Error() string {
@@ -189,16 +152,6 @@ func (e DuplicateClaimError) Error() string {
}
func (e DuplicateClaimError) HttpStatusCode() int { return http.StatusBadRequest }
-type OidcInvalidCodeVerifierError struct{}
-
-func (e OidcInvalidCodeVerifierError) Error() string { return "Invalid code verifier" }
-func (e OidcInvalidCodeVerifierError) HttpStatusCode() int { return http.StatusBadRequest }
-
-type OidcMissingCodeChallengeError struct{}
-
-func (e OidcMissingCodeChallengeError) Error() string { return "Missing code challenge" }
-func (e OidcMissingCodeChallengeError) HttpStatusCode() int { return http.StatusBadRequest }
-
type LdapUserUpdateError struct{}
func (e LdapUserUpdateError) Error() string { return "LDAP users can't be updated" }
@@ -221,13 +174,6 @@ func (e OidcClientIdNotMatchingError) Error() string {
}
func (e OidcClientIdNotMatchingError) HttpStatusCode() int { return http.StatusBadRequest }
-type OidcNoCallbackURLError struct{}
-
-func (e OidcNoCallbackURLError) Error() string {
- return "No callback URL provided"
-}
-func (e OidcNoCallbackURLError) HttpStatusCode() int { return http.StatusBadRequest }
-
type UiConfigDisabledError struct{}
func (e UiConfigDisabledError) Error() string {
@@ -279,21 +225,6 @@ func (e APIKeyAuthNotAllowedError) Error() string {
}
func (e APIKeyAuthNotAllowedError) HttpStatusCode() int { return http.StatusForbidden }
-type OidcInvalidRefreshTokenError struct{}
-
-func (e OidcInvalidRefreshTokenError) Error() string { return "refresh token is invalid or expired" }
-func (e OidcInvalidRefreshTokenError) HttpStatusCode() int { return http.StatusBadRequest }
-
-type OidcMissingRefreshTokenError struct{}
-
-func (e OidcMissingRefreshTokenError) Error() string { return "refresh token is required" }
-func (e OidcMissingRefreshTokenError) HttpStatusCode() int { return http.StatusBadRequest }
-
-type OidcMissingAuthorizationCodeError struct{}
-
-func (e OidcMissingAuthorizationCodeError) Error() string { return "authorization code is required" }
-func (e OidcMissingAuthorizationCodeError) HttpStatusCode() int { return http.StatusBadRequest }
-
type UserDisabledError struct{}
func (e UserDisabledError) Error() string { return "User account is disabled" }
@@ -315,16 +246,6 @@ type OidcInvalidDeviceCodeError struct{}
func (e OidcInvalidDeviceCodeError) Error() string { return "invalid device code" }
func (e OidcInvalidDeviceCodeError) HttpStatusCode() int { return http.StatusBadRequest }
-type OidcSlowDownError struct{}
-
-func (e OidcSlowDownError) Error() string { return "polling too frequently" }
-func (e OidcSlowDownError) HttpStatusCode() int { return http.StatusTooManyRequests }
-
-type OidcAuthorizationPendingError struct{}
-
-func (e OidcAuthorizationPendingError) Error() string { return "authorization is still pending" }
-func (e OidcAuthorizationPendingError) HttpStatusCode() int { return http.StatusBadRequest }
-
type ReauthenticationRequiredError struct{}
func (e ReauthenticationRequiredError) Error() string { return "reauthentication required" }
@@ -354,22 +275,6 @@ func (e ImageNotFoundError) Error() string { return "Image not found" }
func (e ImageNotFoundError) HttpStatusCode() int { return http.StatusNotFound }
-type OidcPARNotSupportedForPublicClientsError struct{}
-
-func (e OidcPARNotSupportedForPublicClientsError) Error() string {
- return "pushed authorization requests are not supported for public clients"
-}
-func (e OidcPARNotSupportedForPublicClientsError) HttpStatusCode() int {
- return http.StatusBadRequest
-}
-
-type OidcInvalidRequestURIError struct{}
-
-func (e OidcInvalidRequestURIError) Error() string {
- return "invalid or expired request_uri"
-}
-func (e OidcInvalidRequestURIError) HttpStatusCode() int { return http.StatusBadRequest }
-
type OidcPARRequiredError struct{}
func (e OidcPARRequiredError) Error() string {
@@ -382,35 +287,3 @@ type InvalidEmailVerificationTokenError struct{}
func (e InvalidEmailVerificationTokenError) Error() string { return "Invalid email verification token" }
func (e InvalidEmailVerificationTokenError) HttpStatusCode() int { return http.StatusBadRequest }
-
-// OIDC prompt parameter errors - used for redirect error responses
-
-type OidcLoginRequiredError struct{}
-
-func (e OidcLoginRequiredError) Error() string { return "login_required" }
-func (e OidcLoginRequiredError) HttpStatusCode() int { return http.StatusBadRequest }
-
-type OidcConsentRequiredError struct{}
-
-func (e OidcConsentRequiredError) Error() string { return "consent_required" }
-func (e OidcConsentRequiredError) HttpStatusCode() int { return http.StatusBadRequest }
-
-type OidcInteractionRequiredError struct{}
-
-func (e OidcInteractionRequiredError) Error() string { return "interaction_required" }
-func (e OidcInteractionRequiredError) HttpStatusCode() int { return http.StatusBadRequest }
-
-type OidcInvalidRequestError struct{ description string }
-
-func NewOidcInvalidRequestError(description string) *OidcInvalidRequestError {
- return &OidcInvalidRequestError{description: description}
-}
-
-func (e OidcInvalidRequestError) Error() string { return "invalid_request" }
-func (e OidcInvalidRequestError) HttpStatusCode() int { return http.StatusBadRequest }
-func (e OidcInvalidRequestError) Description() string { return e.description }
-
-type OidcAccountSelectionRequiredError struct{}
-
-func (e OidcAccountSelectionRequiredError) Error() string { return "account_selection_required" }
-func (e OidcAccountSelectionRequiredError) HttpStatusCode() int { return http.StatusBadRequest }
diff --git a/backend/internal/controller/e2etest_controller.go b/backend/internal/controller/e2etest_controller.go
index 62937c66..d15bfbfb 100644
--- a/backend/internal/controller/e2etest_controller.go
+++ b/backend/internal/controller/e2etest_controller.go
@@ -14,6 +14,7 @@ func NewTestController(group *gin.RouterGroup, testService *service.TestService)
testController := &TestController{TestService: testService}
group.POST("/test/reset", testController.resetAndSeedHandler)
+ group.POST("/test/accesstoken", testController.signAccessToken)
group.POST("/test/refreshtoken", testController.signRefreshToken)
group.GET("/externalidp/jwks.json", testController.externalIdPJWKS)
@@ -108,6 +109,27 @@ func (tc *TestController) externalIdPSignToken(c *gin.Context) {
c.Writer.WriteString(token)
}
+func (tc *TestController) signAccessToken(c *gin.Context) {
+ var input struct {
+ UserID string `json:"user"`
+ ClientID string `json:"client"`
+ Expired bool `json:"expired"`
+ }
+ err := c.ShouldBindJSON(&input)
+ if err != nil {
+ _ = c.Error(err)
+ return
+ }
+
+ token, err := tc.TestService.SignAccessToken(c.Request.Context(), input.UserID, input.ClientID, input.Expired)
+ if err != nil {
+ _ = c.Error(err)
+ return
+ }
+
+ c.Writer.WriteString(token)
+}
+
func (tc *TestController) signRefreshToken(c *gin.Context) {
var input struct {
UserID string `json:"user"`
@@ -120,7 +142,7 @@ func (tc *TestController) signRefreshToken(c *gin.Context) {
return
}
- token, err := tc.TestService.SignRefreshToken(input.UserID, input.ClientID, input.RefreshToken)
+ token, err := tc.TestService.SignRefreshToken(c.Request.Context(), input.UserID, input.ClientID, input.RefreshToken)
if err != nil {
_ = c.Error(err)
return
diff --git a/backend/internal/controller/oidc_controller.go b/backend/internal/controller/oidc_controller.go
index 919361a6..0033e918 100644
--- a/backend/internal/controller/oidc_controller.go
+++ b/backend/internal/controller/oidc_controller.go
@@ -1,11 +1,7 @@
package controller
import (
- "context"
- "errors"
- "log/slog"
"net/http"
- "net/url"
"strconv"
"strings"
"time"
@@ -17,33 +13,17 @@ import (
"github.com/pocket-id/pocket-id/backend/internal/middleware"
"github.com/pocket-id/pocket-id/backend/internal/service"
"github.com/pocket-id/pocket-id/backend/internal/utils"
- "github.com/pocket-id/pocket-id/backend/internal/utils/cookie"
)
// NewOidcController creates a new controller for OIDC related endpoints
// @Summary OIDC controller
// @Description Initializes all OIDC-related API endpoints for authentication and client management
// @Tags OIDC
-func NewOidcController(group *gin.RouterGroup, authMiddleware *middleware.AuthMiddleware, fileSizeLimitMiddleware *middleware.FileSizeLimitMiddleware, oidcService *service.OidcService, jwtService *service.JwtService) {
+func NewOidcController(group *gin.RouterGroup, authMiddleware *middleware.AuthMiddleware, fileSizeLimitMiddleware *middleware.FileSizeLimitMiddleware, oidcService *service.OidcService) {
oc := &OidcController{
- oidcService: oidcService,
- jwtService: jwtService,
- createTokens: oidcService.CreateTokens,
+ oidcService: oidcService,
}
- group.POST("/oidc/authorize", authMiddleware.WithAdminNotRequired().Add(), oc.authorizeHandler)
- group.POST("/oidc/authorize/callback-url", oc.authorizeCallbackURLHandler)
- group.POST("/oidc/authorization-required", authMiddleware.WithAdminNotRequired().Add(), oc.authorizationConfirmationRequiredHandler)
- group.GET("/oidc/par-request-info", authMiddleware.WithAdminNotRequired().Add(), oc.parRequestInfoHandler)
-
- group.POST("/oidc/token", oc.createTokensHandler)
- group.POST("/oidc/par", oc.pushedAuthorizationRequestHandler)
- group.GET("/oidc/userinfo", oc.userInfoHandler)
- group.POST("/oidc/userinfo", oc.userInfoHandler)
- group.POST("/oidc/end-session", authMiddleware.WithAdminNotRequired().WithSuccessOptional().Add(), oc.EndSessionHandler)
- group.GET("/oidc/end-session", authMiddleware.WithAdminNotRequired().WithSuccessOptional().Add(), oc.EndSessionHandler)
- group.POST("/oidc/introspect", oc.introspectTokenHandler)
-
group.GET("/oidc/clients", authMiddleware.Add(), oc.listClientsHandler)
group.POST("/oidc/clients", authMiddleware.Add(), oc.createClientHandler)
group.GET("/oidc/clients/:id", authMiddleware.Add(), oc.getClientHandler)
@@ -60,10 +40,6 @@ func NewOidcController(group *gin.RouterGroup, authMiddleware *middleware.AuthMi
group.GET("/oidc/clients/:id/preview/:userId", authMiddleware.Add(), oc.getClientPreviewHandler)
- group.POST("/oidc/device/authorize", oc.deviceAuthorizationHandler)
- group.POST("/oidc/device/verify", authMiddleware.WithAdminNotRequired().Add(), oc.verifyDeviceCodeHandler)
- group.GET("/oidc/device/info", authMiddleware.WithAdminNotRequired().Add(), oc.getDeviceCodeInfoHandler)
-
group.GET("/oidc/users/me/authorized-clients", authMiddleware.WithAdminNotRequired().Add(), oc.listOwnAuthorizedClientsHandler)
group.GET("/oidc/users/:id/authorized-clients", authMiddleware.Add(), oc.listAuthorizedClientsHandler)
@@ -76,429 +52,7 @@ func NewOidcController(group *gin.RouterGroup, authMiddleware *middleware.AuthMi
}
type OidcController struct {
- oidcService *service.OidcService
- jwtService *service.JwtService
- createTokens func(context.Context, dto.OidcCreateTokensDto) (service.CreatedTokens, error)
-}
-
-// authorizeHandler godoc
-// @Summary Authorize OIDC client
-// @Description Start the OIDC authorization process for a client
-// @Tags OIDC
-// @Accept json
-// @Produce json
-// @Param request body dto.AuthorizeOidcClientRequestDto true "Authorization request parameters"
-// @Success 200 {object} dto.AuthorizeOidcClientResponseDto "Authorization code and callback URL"
-// @Router /api/oidc/authorize [post]
-func (oc *OidcController) authorizeHandler(c *gin.Context) {
- var input dto.AuthorizeOidcClientRequestDto
- err := c.ShouldBindJSON(&input)
- if err != nil {
- _ = c.Error(err)
- return
- }
-
- code, callbackURL, err := oc.oidcService.Authorize(
- c.Request.Context(),
- input,
- c.GetString("userID"),
- c.GetString("authenticationMethod"),
- c.ClientIP(),
- c.Request.UserAgent(),
- )
- if err != nil {
- // Check if this is a prompt-related error that should be returned as a redirect error
- if isOidcPromptError(err) {
- c.JSON(http.StatusOK, gin.H{
- "error": err.Error(),
- "requiresRedirect": true,
- "callbackURL": callbackURL,
- })
- return
- }
- _ = c.Error(err)
- return
- }
-
- response := dto.AuthorizeOidcClientResponseDto{
- Code: code,
- CallbackURL: callbackURL,
- Issuer: common.EnvConfig.AppURL,
- }
-
- c.JSON(http.StatusOK, response)
-}
-
-// authorizeCallbackURLHandler godoc
-// @Summary Resolve a validated callback URL for an OIDC authorization request
-// @Description Resolves the redirect URI against the client's configured callback URLs without consuming PAR records.
-// @Tags OIDC
-// @Accept json
-// @Produce json
-// @Param request body dto.AuthorizeOidcClientCallbackRequestDto true "Authorization callback parameters"
-// @Success 200 {object} dto.AuthorizeOidcClientCallbackResponseDto "Resolved callback URL"
-// @Router /api/oidc/authorize/callback-url [post]
-func (oc *OidcController) authorizeCallbackURLHandler(c *gin.Context) {
- var input dto.AuthorizeOidcClientCallbackRequestDto
- if err := c.ShouldBindJSON(&input); err != nil {
- _ = c.Error(err)
- return
- }
-
- callbackURL, err := oc.oidcService.ResolveAuthorizeCallbackURL(
- c.Request.Context(),
- input.ClientID,
- input.CallbackURL,
- input.RequestURI,
- )
- if err != nil {
- _ = c.Error(err)
- return
- }
-
- c.JSON(http.StatusOK, dto.AuthorizeOidcClientCallbackResponseDto{CallbackURL: callbackURL})
-}
-
-// isOidcPromptError checks if an error is a prompt-related OIDC error that should trigger a redirect
-func isOidcPromptError(err error) bool {
- var loginReq *common.OidcLoginRequiredError
- var consentReq *common.OidcConsentRequiredError
- var interactionReq *common.OidcInteractionRequiredError
- var accountSelectionReq *common.OidcAccountSelectionRequiredError
-
- return errors.As(err, &loginReq) ||
- errors.As(err, &consentReq) ||
- errors.As(err, &interactionReq) ||
- errors.As(err, &accountSelectionReq)
-}
-
-// authorizationConfirmationRequiredHandler godoc
-// @Summary Check if authorization confirmation is required
-// @Description Check if the user needs to confirm authorization for the client
-// @Tags OIDC
-// @Accept json
-// @Produce json
-// @Param request body dto.AuthorizationRequiredDto true "Authorization check parameters"
-// @Success 200 {object} object "{ \"authorizationRequired\": true/false }"
-// @Router /api/oidc/authorization-required [post]
-func (oc *OidcController) authorizationConfirmationRequiredHandler(c *gin.Context) {
- var input dto.AuthorizationRequiredDto
- if err := c.ShouldBindJSON(&input); err != nil {
- _ = c.Error(err)
- return
- }
-
- authorizationRequired, scope, err := oc.oidcService.AuthorizationRequired(c.Request.Context(), input.ClientID, c.GetString("userID"), input.Scope, input.RequestURI)
- if err != nil {
- _ = c.Error(err)
- return
- }
-
- c.JSON(http.StatusOK, gin.H{"authorizationRequired": authorizationRequired, "scope": scope})
-}
-
-// parRequestInfoHandler godoc
-// @Summary Resolve stored authorization request parameters
-// @Description Resolve the parameters of a stored request_uri request so the consent page can render the requested scope and build the final redirect. Does not consume the request.
-// @Tags OIDC
-// @Produce json
-// @Param client_id query string true "Client ID"
-// @Param request_uri query string true "Request URI returned from the PAR endpoint"
-// @Success 200 {object} dto.OidcAuthorizeRequestInfoDto "Resolved authorization request parameters"
-// @Router /api/oidc/par-request-info [get]
-func (oc *OidcController) parRequestInfoHandler(c *gin.Context) {
- clientID := c.Query("client_id")
- requestURI := c.Query("request_uri")
- if clientID == "" {
- _ = c.Error(&common.ValidationError{Message: "client_id is required"})
- return
- }
- if requestURI == "" {
- _ = c.Error(&common.ValidationError{Message: "request_uri is required"})
- return
- }
-
- info, err := oc.oidcService.GetPushedAuthorizationRequest(c.Request.Context(), clientID, requestURI)
- if err != nil {
- _ = c.Error(err)
- return
- }
-
- var infoDto dto.OidcAuthorizeRequestInfoDto
- err = dto.MapStruct(info.Parameters, &infoDto)
- if err != nil {
- _ = c.Error(err)
- return
- }
-
- c.JSON(http.StatusOK, infoDto)
-}
-
-// createTokensHandler godoc
-// @Summary Create OIDC tokens
-// @Description Exchange authorization code or refresh token for access tokens
-// @Tags OIDC
-// @Produce json
-// @Param client_id formData string false "Client ID (if not using Basic Auth)"
-// @Param client_secret formData string false "Client secret (if not using Basic Auth or client assertions)"
-// @Param code formData string false "Authorization code (required for 'authorization_code' grant)"
-// @Param grant_type formData string true "Grant type ('authorization_code' or 'refresh_token')"
-// @Param code_verifier formData string false "PKCE code verifier (for authorization_code with PKCE)"
-// @Param refresh_token formData string false "Refresh token (required for 'refresh_token' grant)"
-// @Param client_assertion formData string false "Client assertion type (for 'authorization_code' grant when using client assertions)"
-// @Param client_assertion_type formData string false "Client assertion type (for 'authorization_code' grant when using client assertions)"
-// @Success 200 {object} dto.OidcTokenResponseDto "Token response with access_token and optional id_token and refresh_token"
-// @Router /api/oidc/token [post]
-func (oc *OidcController) createTokensHandler(c *gin.Context) {
- // Per RFC-6749, parameters passed to the /token endpoint MUST be passed in the body of the request
- // Gin's "ShouldBind" by default reads from the query string too, so we need to reset all query string args before invoking ShouldBind
- c.Request.URL.RawQuery = ""
-
- var input dto.OidcCreateTokensDto
- err := c.ShouldBind(&input)
- if err != nil {
- _ = c.Error(err)
- return
- }
-
- // Validate that code is provided for authorization_code grant type
- if input.GrantType == service.GrantTypeAuthorizationCode && input.Code == "" {
- _ = c.Error(&common.OidcMissingAuthorizationCodeError{})
- return
- }
-
- // Validate that refresh_token is provided for refresh_token grant type
- if input.GrantType == service.GrantTypeRefreshToken && input.RefreshToken == "" {
- _ = c.Error(&common.OidcMissingRefreshTokenError{})
- return
- }
-
- // Check if the client ID / secret are passed in the Authorization header (RFC 6749)
- parseBasicAuth(c.Request, &input.ClientSecret, &input.ClientID)
-
- tokens, err := oc.createTokens(c.Request.Context(), input)
-
- switch {
- case errors.Is(err, &common.OidcAuthorizationPendingError{}):
- c.JSON(http.StatusBadRequest, gin.H{
- "error": "authorization_pending",
- })
- return
- case errors.Is(err, &common.OidcSlowDownError{}):
- c.JSON(http.StatusBadRequest, gin.H{
- "error": "slow_down",
- })
- return
- case err != nil:
- _ = c.Error(err)
- return
- }
-
- c.JSON(http.StatusOK, dto.OidcTokenResponseDto{
- AccessToken: tokens.AccessToken,
- TokenType: "Bearer",
- ExpiresIn: int(tokens.ExpiresIn.Seconds()),
- IdToken: tokens.IdToken, // May be empty
- RefreshToken: tokens.RefreshToken, // May be empty
- })
-}
-
-// pushedAuthorizationRequestHandler godoc
-// @Summary Pushed Authorization Request (PAR)
-// @Description RFC 9126: Push authorization request parameters and receive a request_uri. Only confidential clients may use this endpoint.
-// @Tags OIDC
-// @Accept application/x-www-form-urlencoded
-// @Produce json
-// @Success 201 {object} dto.OidcPARResponseDto
-// @Router /api/oidc/par [post]
-func (oc *OidcController) pushedAuthorizationRequestHandler(c *gin.Context) {
- // Per RFC 9126, parameters MUST be passed in the request body
- c.Request.URL.RawQuery = ""
-
- var input dto.OidcPARRequestDto
- if err := c.ShouldBind(&input); err != nil {
- _ = c.Error(err)
- return
- }
-
- // Client id and secret can also be passed over the Authorization header
- if input.ClientID == "" && input.ClientSecret == "" {
- input.ClientID, input.ClientSecret, _ = utils.OAuthClientBasicAuth(c.Request)
- }
-
- creds := service.ClientAuthCredentials{
- ClientID: input.ClientID,
- ClientSecret: input.ClientSecret,
- ClientAssertion: input.ClientAssertion,
- ClientAssertionType: input.ClientAssertionType,
- }
-
- requestURI, expiresIn, err := oc.oidcService.CreatePushedAuthorizationRequest(c.Request.Context(), creds, input)
- if err != nil {
- _ = c.Error(err)
- return
- }
-
- // RFC 9126 §2.2 requires HTTP 201 Created for successful PAR responses
- c.JSON(http.StatusCreated, dto.OidcPARResponseDto{
- RequestURI: requestURI,
- ExpiresIn: expiresIn,
- })
-}
-
-// userInfoHandler godoc
-// @Summary Get user information
-// @Description Get user information based on the access token
-// @Tags OIDC
-// @Accept json
-// @Produce json
-// @Success 200 {object} object "User claims based on requested scopes"
-// @Security OAuth2AccessToken
-// @Router /api/oidc/userinfo [get]
-func (oc *OidcController) userInfoHandler(c *gin.Context) {
- _, authToken, ok := strings.Cut(c.GetHeader("Authorization"), " ")
- if !ok || authToken == "" {
- _ = c.Error(&common.MissingAccessToken{})
- return
- }
-
- token, err := oc.jwtService.VerifyOAuthAccessToken(authToken)
- if err != nil {
- _ = c.Error(err)
- return
- }
- userID, ok := token.Subject()
- if !ok {
- _ = c.Error(&common.TokenInvalidError{})
- return
- }
- clientID, ok := token.Audience()
- if !ok || len(clientID) != 1 {
- _ = c.Error(&common.TokenInvalidError{})
- return
- }
- claims, err := oc.oidcService.GetUserClaimsForClient(c.Request.Context(), userID, clientID[0])
- if err != nil {
- _ = c.Error(err)
- return
- }
-
- c.JSON(http.StatusOK, claims)
-}
-
-// EndSessionHandler godoc
-// @Summary End OIDC session
-// @Description End user session and handle OIDC logout
-// @Tags OIDC
-// @Accept application/x-www-form-urlencoded
-// @Param id_token_hint query string false "ID token"
-// @Param post_logout_redirect_uri query string false "URL to redirect to after logout"
-// @Param state query string false "State parameter to include in the redirect"
-// @Success 302 "Redirect to post-logout URL or application logout page"
-// @Router /api/oidc/end-session [get]
-func (oc *OidcController) EndSessionHandler(c *gin.Context) {
- var input dto.OidcLogoutDto
-
- // Bind query parameters to the struct
- switch c.Request.Method {
- case http.MethodGet:
- if err := c.ShouldBindQuery(&input); err != nil {
- _ = c.Error(err)
- return
- }
- case http.MethodPost:
- // Bind form parameters to the struct
- if err := c.ShouldBind(&input); err != nil {
- _ = c.Error(err)
- return
- }
- }
-
- callbackURL, err := oc.oidcService.ValidateEndSession(c.Request.Context(), input, c.GetString("userID"))
- if err != nil {
- // If the validation fails, the user has to confirm the logout manually and doesn't get redirected
- slog.WarnContext(c.Request.Context(), "Error getting logout callback URL, the user has to confirm the logout manually", "error", err)
- c.Redirect(http.StatusFound, common.EnvConfig.AppURL+"/logout")
- return
- }
-
- // The validation was successful, so we can log out and redirect the user to the callback URL without confirmation
- cookie.AddAccessTokenCookie(c, 0, "")
-
- // Callback URL can be empty if none is configured
- if callbackURL == "" {
- c.Redirect(http.StatusFound, common.EnvConfig.AppURL+"/logout")
- return
- }
-
- logoutCallbackURL, _ := url.Parse(callbackURL)
- if input.State != "" {
- q := logoutCallbackURL.Query()
- q.Set("state", input.State)
- logoutCallbackURL.RawQuery = q.Encode()
- }
-
- c.Redirect(http.StatusFound, logoutCallbackURL.String())
-}
-
-// EndSessionHandler godoc (POST method)
-// @Summary End OIDC session (POST method)
-// @Description End user session and handle OIDC logout using POST
-// @Tags OIDC
-// @Accept application/x-www-form-urlencoded
-// @Produce html
-// @Param id_token_hint formData string false "ID token"
-// @Param post_logout_redirect_uri formData string false "URL to redirect to after logout"
-// @Param state formData string false "State parameter to include in the redirect"
-// @Success 302 "Redirect to post-logout URL or application logout page"
-// @Router /api/oidc/end-session [post]
-func (oc *OidcController) EndSessionHandlerPost(c *gin.Context) {
- // Implementation is the same as GET
-}
-
-// introspectToken godoc
-// @Summary Introspect OIDC tokens
-// @Description Pass an access_token to verify if it is considered valid.
-// @Tags OIDC
-// @Produce json
-// @Param token formData string true "The token to be introspected."
-// @Success 200 {object} dto.OidcIntrospectionResponseDto "Response with the introspection result."
-// @Router /api/oidc/introspect [post]
-func (oc *OidcController) introspectTokenHandler(c *gin.Context) {
- var input dto.OidcIntrospectDto
- if err := c.ShouldBind(&input); err != nil {
- _ = c.Error(err)
- return
- }
-
- // Client id and secret have to be passed over the Authorization header. This kind of
- // authentication allows us to keep the endpoint protected (since it could be used to
- // find valid tokens) while still allowing it to be used by an application that is
- // supposed to interact with our IdP (since that needs to have a client_id
- // and client_secret anyway).
- var (
- creds service.ClientAuthCredentials
- ok bool
- )
- creds.ClientID, creds.ClientSecret, ok = utils.OAuthClientBasicAuth(c.Request)
- if !ok {
- // If there's no basic auth, check if we have a bearer token (used as client assertion)
- bearer, ok := utils.BearerAuth(c.Request)
- if ok {
- creds.ClientAssertionType = service.ClientAssertionTypeJWTBearer
- creds.ClientAssertion = bearer
- // When using client assertions, client_id can be passed as a form field
- creds.ClientID = input.ClientID
- }
- }
-
- response, err := oc.oidcService.IntrospectToken(c.Request.Context(), creds, input.Token)
- if err != nil {
- _ = c.Error(err)
- return
- }
-
- c.JSON(http.StatusOK, response)
+ oidcService *service.OidcService
}
// getClientMetaDataHandler godoc
@@ -810,46 +364,6 @@ func (oc *OidcController) updateAllowedUserGroupsHandler(c *gin.Context) {
c.JSON(http.StatusOK, oidcClientDto)
}
-// This method can modify the value of clientSecret and clientID
-func parseBasicAuth(r *http.Request, clientSecret *string, clientID *string) {
- // Client id and secret can also be passed via the Authorization header (RFC 6749, section 2.3.1 "client_secret_basic")
- // When PKCE is used, some libraries send client_id in the body for the code_verifier binding while keeping the secret only in the Authorization header
- // We therefore fall back to Basic auth whenever the secret is missing, not only when both fields are empty
- if *clientSecret == "" {
- basicID, basicSecret, ok := utils.OAuthClientBasicAuth(r)
- if ok {
- if *clientID == "" {
- *clientID = basicID
- }
- *clientSecret = basicSecret
- }
- }
-}
-
-func (oc *OidcController) deviceAuthorizationHandler(c *gin.Context) {
- // Per RFC 8628 (OAuth 2.0 Device Authorization Grant), parameters for the device authorization request MUST be sent in the body of the POST request
- // Gin's "ShouldBind" by default reads from the query string too, so we need to reset all query string args before invoking ShouldBind
- c.Request.URL.RawQuery = ""
-
- var input dto.OidcDeviceAuthorizationRequestDto
- err := c.ShouldBind(&input)
- if err != nil {
- _ = c.Error(err)
- return
- }
-
- // Check if the client ID / secret are passed in the Authorization header (RFC 6749)
- parseBasicAuth(c.Request, &input.ClientSecret, &input.ClientID)
-
- response, err := oc.oidcService.CreateDeviceAuthorization(c.Request.Context(), input)
- if err != nil {
- _ = c.Error(err)
- return
- }
-
- c.JSON(http.StatusOK, response)
-}
-
// listOwnAuthorizedClientsHandler godoc
// @Summary List authorized clients for current user
// @Description Get a paginated list of OIDC clients that the current user has authorized
@@ -951,49 +465,6 @@ func (oc *OidcController) listOwnAccessibleClientsHandler(c *gin.Context) {
})
}
-func (oc *OidcController) verifyDeviceCodeHandler(c *gin.Context) {
- userCode := c.Query("code")
- if userCode == "" {
- _ = c.Error(&common.ValidationError{Message: "code is required"})
- return
- }
-
- // Get IP address and user agent from the request context
- ipAddress := c.ClientIP()
- userAgent := c.Request.UserAgent()
-
- err := oc.oidcService.VerifyDeviceCode(
- c.Request.Context(),
- userCode,
- c.GetString("userID"),
- c.GetString("authenticationMethod"),
- ipAddress,
- userAgent)
-
- if err != nil {
- _ = c.Error(err)
- return
- }
-
- c.Status(http.StatusNoContent)
-}
-
-func (oc *OidcController) getDeviceCodeInfoHandler(c *gin.Context) {
- userCode := c.Query("code")
- if userCode == "" {
- _ = c.Error(&common.ValidationError{Message: "code is required"})
- return
- }
-
- deviceCodeInfo, err := oc.oidcService.GetDeviceCodeInfo(c.Request.Context(), userCode, c.GetString("userID"))
- if err != nil {
- _ = c.Error(err)
- return
- }
-
- c.JSON(http.StatusOK, deviceCodeInfo)
-}
-
// getClientPreviewHandler godoc
// @Summary Preview OIDC client data for user
// @Description Get a preview of the OIDC data (ID token, access token, userinfo) that would be sent to the client for a specific user
diff --git a/backend/internal/controller/oidc_controller_test.go b/backend/internal/controller/oidc_controller_test.go
deleted file mode 100644
index 28421448..00000000
--- a/backend/internal/controller/oidc_controller_test.go
+++ /dev/null
@@ -1,264 +0,0 @@
-package controller
-
-import (
- "context"
- "encoding/base64"
- "encoding/json"
- "errors"
- "net/http"
- "net/http/httptest"
- "net/url"
- "strings"
- "testing"
- "time"
-
- "github.com/gin-gonic/gin"
- "github.com/stretchr/testify/assert"
- "github.com/stretchr/testify/require"
-
- "github.com/pocket-id/pocket-id/backend/internal/common"
- "github.com/pocket-id/pocket-id/backend/internal/dto"
- "github.com/pocket-id/pocket-id/backend/internal/service"
-)
-
-func TestCreateTokensHandler(t *testing.T) {
- createTestContext := func(t *testing.T, rawURL string, form url.Values, authHeader string, noCT bool) (*gin.Context, *httptest.ResponseRecorder) {
- t.Helper()
-
- mode := gin.Mode()
- gin.SetMode(gin.TestMode)
- t.Cleanup(func() { gin.SetMode(mode) })
-
- recorder := httptest.NewRecorder()
- c, _ := gin.CreateTestContext(recorder)
-
- req, err := http.NewRequestWithContext(t.Context(), http.MethodPost, rawURL, strings.NewReader(form.Encode()))
- require.NoError(t, err)
-
- if !noCT {
- req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
- }
- if authHeader != "" {
- req.Header.Set("Authorization", authHeader)
- }
-
- c.Request = req
- return c, recorder
- }
-
- t.Run("Ignores Query String Parameters For Binding", func(t *testing.T) {
- oc := &OidcController{}
-
- c, _ := createTestContext(
- t,
- "http://example.com/oidc/token?grant_type=refresh_token&refresh_token=query-value",
- url.Values{},
- "",
- false,
- )
-
- oc.createTokensHandler(c)
-
- require.Len(t, c.Errors, 1)
- assert.Contains(t, c.Errors[0].Err.Error(), "GrantType")
- })
-
- t.Run("Missing Authorization Code", func(t *testing.T) {
- oc := &OidcController{}
-
- c, _ := createTestContext(
- t,
- "http://example.com/oidc/token",
- url.Values{
- "grant_type": {service.GrantTypeAuthorizationCode},
- },
- "",
- false,
- )
-
- oc.createTokensHandler(c)
-
- require.Len(t, c.Errors, 1)
- var missingCodeErr *common.OidcMissingAuthorizationCodeError
- require.ErrorAs(t, c.Errors[0].Err, &missingCodeErr)
- })
-
- t.Run("Missing Refresh Token", func(t *testing.T) {
- oc := &OidcController{}
-
- c, _ := createTestContext(
- t,
- "http://example.com/oidc/token",
- url.Values{
- "grant_type": {service.GrantTypeRefreshToken},
- },
- "",
- false,
- )
-
- oc.createTokensHandler(c)
-
- require.Len(t, c.Errors, 1)
- var missingRefreshErr *common.OidcMissingRefreshTokenError
- require.ErrorAs(t, c.Errors[0].Err, &missingRefreshErr)
- })
-
- t.Run("Uses Basic Auth Credentials When Body Credentials Missing", func(t *testing.T) {
- var capturedInput dto.OidcCreateTokensDto
- oc := &OidcController{
- createTokens: func(_ context.Context, input dto.OidcCreateTokensDto) (service.CreatedTokens, error) {
- capturedInput = input
- return service.CreatedTokens{
- AccessToken: "access-token",
- IdToken: "id-token",
- RefreshToken: "refresh-token",
- ExpiresIn: 2 * time.Minute,
- }, nil
- },
- }
-
- basicAuth := "Basic " + base64.StdEncoding.EncodeToString([]byte("client-id:client-secret"))
- c, recorder := createTestContext(
- t,
- "http://example.com/oidc/token",
- url.Values{
- "grant_type": {service.GrantTypeRefreshToken},
- "refresh_token": {"input-refresh-token"},
- },
- basicAuth,
- false,
- )
-
- oc.createTokensHandler(c)
-
- require.Empty(t, c.Errors)
- assert.Equal(t, "client-id", capturedInput.ClientID)
- assert.Equal(t, "client-secret", capturedInput.ClientSecret)
- assert.Equal(t, "input-refresh-token", capturedInput.RefreshToken)
-
- require.Equal(t, http.StatusOK, recorder.Code)
- var response dto.OidcTokenResponseDto
- require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &response))
- assert.Equal(t, "access-token", response.AccessToken)
- assert.Equal(t, "Bearer", response.TokenType)
- assert.Equal(t, "id-token", response.IdToken)
- assert.Equal(t, "refresh-token", response.RefreshToken)
- assert.Equal(t, 120, response.ExpiresIn)
- })
-
- t.Run("Uses Basic Auth Secret When Body Has Only Client ID (PKCE)", func(t *testing.T) {
- // Some OIDC libraries (e.g. jumbojett/openid-connect-php) combine client_secret_basic with PKCE by sending client_id in the body alongside code_verifier while keeping the secret only in the Authorization header
- // RFC 6749 §2.3.1 permits this; the body client_id must not block the Basic auth fallback for the secret.
- var capturedInput dto.OidcCreateTokensDto
- oc := &OidcController{
- createTokens: func(_ context.Context, input dto.OidcCreateTokensDto) (service.CreatedTokens, error) {
- capturedInput = input
- return service.CreatedTokens{
- AccessToken: "access-token",
- IdToken: "id-token",
- RefreshToken: "refresh-token",
- ExpiresIn: 2 * time.Minute,
- }, nil
- },
- }
-
- basicAuth := "Basic " + base64.StdEncoding.EncodeToString([]byte("client-id:client-secret"))
- c, recorder := createTestContext(
- t,
- "http://example.com/oidc/token",
- url.Values{
- "grant_type": {service.GrantTypeRefreshToken},
- "refresh_token": {"input-refresh-token"},
- "client_id": {"client-id"},
- },
- basicAuth,
- false,
- )
-
- oc.createTokensHandler(c)
-
- require.Empty(t, c.Errors)
- assert.Equal(t, "client-id", capturedInput.ClientID)
- assert.Equal(t, "client-secret", capturedInput.ClientSecret)
- require.Equal(t, http.StatusOK, recorder.Code)
- })
-
- t.Run("Maps Authorization Pending Error", func(t *testing.T) {
- oc := &OidcController{
- createTokens: func(context.Context, dto.OidcCreateTokensDto) (service.CreatedTokens, error) {
- return service.CreatedTokens{}, &common.OidcAuthorizationPendingError{}
- },
- }
-
- c, recorder := createTestContext(
- t,
- "http://example.com/oidc/token",
- url.Values{
- "grant_type": {service.GrantTypeRefreshToken},
- "refresh_token": {"input-refresh-token"},
- },
- "",
- false,
- )
-
- oc.createTokensHandler(c)
-
- require.Empty(t, c.Errors)
- require.Equal(t, http.StatusBadRequest, recorder.Code)
- var response map[string]string
- require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &response))
- assert.Equal(t, "authorization_pending", response["error"])
- })
-
- t.Run("Maps Slow Down Error", func(t *testing.T) {
- oc := &OidcController{
- createTokens: func(context.Context, dto.OidcCreateTokensDto) (service.CreatedTokens, error) {
- return service.CreatedTokens{}, &common.OidcSlowDownError{}
- },
- }
-
- c, recorder := createTestContext(
- t,
- "http://example.com/oidc/token",
- url.Values{
- "grant_type": {service.GrantTypeRefreshToken},
- "refresh_token": {"input-refresh-token"},
- },
- "",
- false,
- )
-
- oc.createTokensHandler(c)
-
- require.Empty(t, c.Errors)
- require.Equal(t, http.StatusBadRequest, recorder.Code)
- var response map[string]string
- require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &response))
- assert.Equal(t, "slow_down", response["error"])
- })
-
- t.Run("Returns Generic Service Error In Context", func(t *testing.T) {
- expectedErr := errors.New("boom")
- oc := &OidcController{
- createTokens: func(context.Context, dto.OidcCreateTokensDto) (service.CreatedTokens, error) {
- return service.CreatedTokens{}, expectedErr
- },
- }
-
- c, _ := createTestContext(
- t,
- "http://example.com/oidc/token",
- url.Values{
- "grant_type": {service.GrantTypeRefreshToken},
- "refresh_token": {"input-refresh-token"},
- },
- "",
- false,
- )
-
- oc.createTokensHandler(c)
-
- require.Len(t, c.Errors, 1)
- assert.ErrorIs(t, c.Errors[0].Err, expectedErr)
- })
-}
diff --git a/backend/internal/controller/webauthn_controller.go b/backend/internal/controller/webauthn_controller.go
index 7fee14be..c4c0315a 100644
--- a/backend/internal/controller/webauthn_controller.go
+++ b/backend/internal/controller/webauthn_controller.go
@@ -203,5 +203,6 @@ func (wc *WebauthnController) reauthenticateHandler(c *gin.Context) {
}
}
- c.JSON(http.StatusOK, gin.H{"reauthenticationToken": token})
+ cookie.AddReauthenticationTokenCookie(c, token)
+ c.Status(http.StatusNoContent)
}
diff --git a/backend/internal/controller/well_known_controller.go b/backend/internal/controller/well_known_controller.go
index 221c1475..5c7a2479 100644
--- a/backend/internal/controller/well_known_controller.go
+++ b/backend/internal/controller/well_known_controller.go
@@ -85,14 +85,14 @@ func (wkc *WellKnownController) computeOIDCConfiguration() ([]byte, error) {
"jwks_uri": internalAppUrl + "/.well-known/jwks.json",
"grant_types_supported": []string{service.GrantTypeAuthorizationCode, service.GrantTypeRefreshToken, service.GrantTypeDeviceCode, service.GrantTypeClientCredentials},
"scopes_supported": []string{"openid", "profile", "email", "groups"},
- "claims_supported": []string{"sub", "given_name", "family_name", "name", "email", "email_verified", "preferred_username", "picture", "groups"},
+ "claims_supported": []string{"sub", "given_name", "family_name", "name", "display_name", "email", "email_verified", "preferred_username", "picture", "groups", "auth_time", "amr"},
"response_types_supported": []string{"code", "id_token"},
"subject_types_supported": []string{"public"},
"id_token_signing_alg_values_supported": []string{alg.String()},
"authorization_response_iss_parameter_supported": true,
"code_challenge_methods_supported": []string{"plain", "S256"},
"prompt_values_supported": []string{"none", "login", "consent", "select_account"},
- "token_endpoint_auth_methods_supported": []string{"client_secret_basic", "client_secret_post", "private_key_jwt", "none"},
+ "token_endpoint_auth_methods_supported": []string{"client_secret_basic", "client_secret_post", "none"},
"pushed_authorization_request_endpoint": internalAppUrl + "/api/oidc/par",
"require_pushed_authorization_requests": false,
}
diff --git a/backend/internal/dto/oidc_dto.go b/backend/internal/dto/oidc_dto.go
index dfebd7a2..6982fef3 100644
--- a/backend/internal/dto/oidc_dto.go
+++ b/backend/internal/dto/oidc_dto.go
@@ -59,93 +59,11 @@ type OidcClientCredentialsDto struct {
}
type OidcClientFederatedIdentityDto struct {
- Issuer string `json:"issuer"`
- Subject string `json:"subject,omitempty"`
- Audience string `json:"audience,omitempty"`
- JWKS string `json:"jwks,omitempty"`
-}
-
-type AuthorizeOidcClientRequestDto struct {
- ClientID string `json:"clientID" binding:"required"`
- Scope string `json:"scope" binding:"required_without=RequestURI"`
- CallbackURL string `json:"callbackURL"`
- Nonce string `json:"nonce"`
- CodeChallenge string `json:"codeChallenge"`
- CodeChallengeMethod string `json:"codeChallengeMethod"`
- ReauthenticationToken string `json:"reauthenticationToken"`
- Prompt string `json:"prompt"`
- ResponseMode string `json:"responseMode" binding:"omitempty,response_mode"`
- RequestURI string `json:"requestURI"`
-}
-
-type OidcPARRequestDto struct {
- ClientID string `form:"client_id"`
- ClientSecret string `form:"client_secret"`
- ClientAssertion string `form:"client_assertion"`
- ClientAssertionType string `form:"client_assertion_type"`
- ResponseType string `form:"response_type" binding:"required"`
- Scope string `form:"scope" binding:"required"`
- RedirectURI string `form:"redirect_uri"`
- State string `form:"state"`
- Nonce string `form:"nonce"`
- CodeChallenge string `form:"code_challenge"`
- CodeChallengeMethod string `form:"code_challenge_method"`
- Prompt string `form:"prompt"`
- ResponseMode string `form:"response_mode" binding:"omitempty,response_mode"`
-}
-
-type OidcPARResponseDto struct {
- RequestURI string `json:"request_uri"`
- ExpiresIn int `json:"expires_in"`
-}
-
-type AuthorizeOidcClientResponseDto struct {
- Code string `json:"code"`
- CallbackURL string `json:"callbackURL"`
- Issuer string `json:"issuer"`
-}
-
-type AuthorizeOidcClientCallbackRequestDto struct {
- ClientID string `json:"clientID" binding:"required"`
- CallbackURL string `json:"callbackURL"`
- RequestURI string `json:"requestURI"`
-}
-
-type AuthorizeOidcClientCallbackResponseDto struct {
- CallbackURL string `json:"callbackURL"`
-}
-
-type AuthorizationRequiredDto struct {
- ClientID string `json:"clientID" binding:"required"`
- Scope string `json:"scope"`
- RequestURI string `json:"requestURI"`
-}
-
-type OidcAuthorizeRequestInfoDto struct {
- Scope string `json:"scope"`
- RedirectURI string `json:"redirectURI"`
- State string `json:"state,omitempty"`
- Nonce string `json:"nonce,omitempty"`
- ResponseMode string `json:"responseMode,omitempty"`
- Prompt string `json:"prompt,omitempty"`
-}
-
-type OidcCreateTokensDto struct {
- GrantType string `form:"grant_type" binding:"required"`
- Code string `form:"code"`
- DeviceCode string `form:"device_code"`
- ClientID string `form:"client_id"`
- ClientSecret string `form:"client_secret"`
- CodeVerifier string `form:"code_verifier"`
- RefreshToken string `form:"refresh_token"`
- ClientAssertion string `form:"client_assertion"`
- ClientAssertionType string `form:"client_assertion_type"`
- Resource string `form:"resource"`
-}
-
-type OidcIntrospectDto struct {
- Token string `form:"token" binding:"required"`
- ClientID string `form:"client_id"`
+ Issuer string `json:"issuer"`
+ Subject string `json:"subject,omitempty"`
+ Audience string `json:"audience,omitempty"`
+ JWKS string `json:"jwks,omitempty"`
+ ReplayProtection bool `json:"replayProtection"`
}
type OidcUpdateAllowedUserGroupsDto struct {
@@ -159,36 +77,6 @@ type OidcLogoutDto struct {
State string `form:"state"`
}
-type OidcTokenResponseDto struct {
- AccessToken string `json:"access_token"`
- TokenType string `json:"token_type"`
- IdToken string `json:"id_token,omitempty"`
- RefreshToken string `json:"refresh_token,omitempty"`
- ExpiresIn int `json:"expires_in"`
-}
-
-type OidcIntrospectionResponseDto struct {
- Active bool `json:"active"`
- TokenType string `json:"token_type,omitempty"`
- Scope string `json:"scope,omitempty"`
- Expiration int64 `json:"exp,omitempty"`
- IssuedAt int64 `json:"iat,omitempty"`
- NotBefore int64 `json:"nbf,omitempty"`
- Subject string `json:"sub,omitempty"`
- Audience []string `json:"aud,omitempty"`
- Issuer string `json:"iss,omitempty"`
- Identifier string `json:"jti,omitempty"`
-}
-
-type OidcDeviceAuthorizationRequestDto struct {
- ClientID string `form:"client_id" binding:"required"`
- Scope string `form:"scope" binding:"required"`
- ClientSecret string `form:"client_secret"`
- ClientAssertion string `form:"client_assertion"`
- ClientAssertionType string `form:"client_assertion_type"`
- Nonce string `form:"nonce"`
-}
-
type OidcDeviceAuthorizationResponseDto struct {
DeviceCode string `json:"device_code"`
UserCode string `json:"user_code"`
@@ -196,20 +84,13 @@ type OidcDeviceAuthorizationResponseDto struct {
VerificationURIComplete string `json:"verification_uri_complete"`
ExpiresIn int `json:"expires_in"`
Interval int `json:"interval"`
- RequiresAuthorization bool `json:"requires_authorization"`
-}
-
-type OidcDeviceTokenRequestDto struct {
- GrantType string `form:"grant_type" binding:"required,eq=urn:ietf:params:oauth:grant-type:device_code"`
- DeviceCode string `form:"device_code" binding:"required"`
- ClientID string `form:"client_id"`
- ClientSecret string `form:"client_secret"`
}
type DeviceCodeInfoDto struct {
- Scope string `json:"scope"`
- AuthorizationRequired bool `json:"authorizationRequired"`
- Client OidcClientMetaDataDto `json:"client"`
+ Scope []string `json:"scope"`
+ AuthorizationRequired bool `json:"authorizationRequired"`
+ ReauthenticationRequired bool `json:"reauthenticationRequired"`
+ Client OidcClientMetaDataDto `json:"client"`
}
type AuthorizedOidcClientDto struct {
diff --git a/backend/internal/dto/validations.go b/backend/internal/dto/validations.go
index 72035972..9d99d6cf 100644
--- a/backend/internal/dto/validations.go
+++ b/backend/internal/dto/validations.go
@@ -47,9 +47,6 @@ 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)
@@ -85,22 +82,7 @@ func ValidateCallbackURL(str string) bool {
}
}
-// ValidateCallbackURLPattern validates callback URL patterns, with support for wildcards
+// ValidateCallbackURLPattern validates callback URL patterns, with support for wildcards.
func ValidateCallbackURLPattern(raw string) bool {
- err := utils.ValidateCallbackURLPattern(raw)
- return err == nil
-}
-
-// ValidateResponseMode validates response_mode parameter
-// If responseMode is present, it must be "form_post", "query", or "fragment"
-// Empty responseMode is allowed (field not provided, use default)
-func ValidateResponseMode(responseMode string) bool {
- switch responseMode {
- case "form_post", "query", "fragment":
- return true
- case "":
- return true
- default:
- return false
- }
+ return utils.ValidateCallbackURLPattern(raw) == nil
}
diff --git a/backend/internal/dto/validations_test.go b/backend/internal/dto/validations_test.go
index d8b84621..4d557ad1 100644
--- a/backend/internal/dto/validations_test.go
+++ b/backend/internal/dto/validations_test.go
@@ -58,26 +58,6 @@ 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 fragment", "fragment", true},
- {"valid empty", "", true},
- {"invalid unknown", "unknown", false},
- }
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- assert.Equal(t, tt.expected, ValidateResponseMode(tt.input))
- })
- }
-}
-
func TestValidateCallbackURL(t *testing.T) {
tests := []struct {
name string
@@ -102,3 +82,26 @@ func TestValidateCallbackURL(t *testing.T) {
})
}
}
+
+func TestValidateCallbackURLPattern(t *testing.T) {
+ tests := []struct {
+ name string
+ input string
+ expected bool
+ }{
+ {"valid exact URL", "https://example.com/callback", true},
+ {"valid wildcard URL", "https://*.example.com/callback", true},
+ {"valid custom scheme", "pocketid://callback", true},
+ {"valid global wildcard", "*", true},
+ {"invalid relative URL", "/callback", false},
+ {"invalid malformed URL", "http://[::1", false},
+ {"rejects javascript scheme", "javascript:alert(1)", false},
+ {"rejects data scheme", "data:text/html;base64,PGgxPkhlbGxvPC9oMT4=", false},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ assert.Equal(t, tt.expected, ValidateCallbackURLPattern(tt.input))
+ })
+ }
+}
diff --git a/backend/internal/job/db_cleanup_job.go b/backend/internal/job/db_cleanup_job.go
index c3377348..913b440a 100644
--- a/backend/internal/job/db_cleanup_job.go
+++ b/backend/internal/job/db_cleanup_job.go
@@ -13,6 +13,7 @@ import (
"github.com/pocket-id/pocket-id/backend/internal/common"
"github.com/pocket-id/pocket-id/backend/internal/model"
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
+ "github.com/pocket-id/pocket-id/backend/internal/oidc"
"github.com/pocket-id/pocket-id/backend/internal/service"
)
@@ -34,11 +35,11 @@ func (s *Scheduler) RegisterDbCleanupJobs(ctx context.Context, db *gorm.DB) erro
s.RegisterJob(ctx, "ClearOneTimeAccessTokens", jobDefWithJitter(24*time.Hour), jobs.clearOneTimeAccessTokens, service.RegisterJobOpts{RunImmediately: true, BackOff: newBackOff()}),
s.RegisterJob(ctx, "ClearSignupTokens", jobDefWithJitter(24*time.Hour), jobs.clearSignupTokens, service.RegisterJobOpts{RunImmediately: true, BackOff: newBackOff()}),
s.RegisterJob(ctx, "ClearEmailVerificationTokens", jobDefWithJitter(24*time.Hour), jobs.clearEmailVerificationTokens, service.RegisterJobOpts{RunImmediately: true, BackOff: newBackOff()}),
- s.RegisterJob(ctx, "ClearOidcAuthorizationCodes", jobDefWithJitter(24*time.Hour), jobs.clearOidcAuthorizationCodes, service.RegisterJobOpts{RunImmediately: true, BackOff: newBackOff()}),
- s.RegisterJob(ctx, "ClearOidcRefreshTokens", jobDefWithJitter(24*time.Hour), jobs.clearOidcRefreshTokens, service.RegisterJobOpts{RunImmediately: true, BackOff: newBackOff()}),
+ s.RegisterJob(ctx, "ClearOAuth2Sessions", jobDefWithJitter(24*time.Hour), jobs.clearOAuth2Sessions, service.RegisterJobOpts{RunImmediately: true, BackOff: newBackOff()}),
+ s.RegisterJob(ctx, "ClearOAuth2JTIs", jobDefWithJitter(24*time.Hour), jobs.clearOAuth2JTIs, service.RegisterJobOpts{RunImmediately: true, BackOff: newBackOff()}),
+ s.RegisterJob(ctx, "ClearInteractionSessions", jobDefWithJitter(24*time.Hour), jobs.clearInteractionSessions, service.RegisterJobOpts{RunImmediately: true, BackOff: newBackOff()}),
s.RegisterJob(ctx, "ClearReauthenticationTokens", jobDefWithJitter(24*time.Hour), jobs.clearReauthenticationTokens, service.RegisterJobOpts{RunImmediately: true, BackOff: newBackOff()}),
s.RegisterJob(ctx, "ClearAuditLogs", jobDefWithJitter(24*time.Hour), jobs.clearAuditLogs, service.RegisterJobOpts{RunImmediately: true, BackOff: newBackOff()}),
- s.RegisterJob(ctx, "ClearOidcPushedAuthorizationRequests", jobDefWithJitter(24*time.Hour), jobs.clearOidcPushedAuthorizationRequests, service.RegisterJobOpts{RunImmediately: true, BackOff: newBackOff()}),
)
}
@@ -89,30 +90,39 @@ func (j *DbCleanupJobs) clearSignupTokens(ctx context.Context) error {
return nil
}
-// ClearOidcAuthorizationCodes deletes OIDC authorization codes that have expired
-func (j *DbCleanupJobs) clearOidcAuthorizationCodes(ctx context.Context) error {
- st := j.db.
- WithContext(ctx).
- Delete(&model.OidcAuthorizationCode{}, "expires_at < ?", datatype.DateTime(time.Now()))
- if st.Error != nil {
- return fmt.Errorf("failed to clean expired OIDC authorization codes: %w", st.Error)
+// clearOAuth2Sessions deletes expired and invalidated OAuth2 sessions. What counts as
+// expired is owned by the oidc module.
+func (j *DbCleanupJobs) clearOAuth2Sessions(ctx context.Context) error {
+ count, err := oidc.CleanupExpiredOAuth2Sessions(ctx, j.db)
+ if err != nil {
+ return fmt.Errorf("failed to clean OAuth2 sessions: %w", err)
}
- slog.InfoContext(ctx, "Cleaned expired OIDC authorization codes", slog.Int64("count", st.RowsAffected))
+ slog.InfoContext(ctx, "Cleaned OAuth2 sessions", slog.Int64("count", count))
return nil
}
-// ClearOidcAuthorizationCodes deletes OIDC authorization codes that have expired
-func (j *DbCleanupJobs) clearOidcRefreshTokens(ctx context.Context) error {
- st := j.db.
- WithContext(ctx).
- Delete(&model.OidcRefreshToken{}, "expires_at < ?", datatype.DateTime(time.Now()))
- if st.Error != nil {
- return fmt.Errorf("failed to clean expired OIDC refresh tokens: %w", st.Error)
+// clearOAuth2JTIs deletes expired JWT IDs used for client assertion replay protection.
+func (j *DbCleanupJobs) clearOAuth2JTIs(ctx context.Context) error {
+ count, err := oidc.CleanupExpiredClientAssertionJTIs(ctx, j.db)
+ if err != nil {
+ return fmt.Errorf("failed to clean OAuth2 client assertion JTIs: %w", err)
}
- slog.InfoContext(ctx, "Cleaned expired OIDC refresh tokens", slog.Int64("count", st.RowsAffected))
+ slog.InfoContext(ctx, "Cleaned OAuth2 client assertion JTIs", slog.Int64("count", count))
+
+ return nil
+}
+
+// clearInteractionSessions deletes abandoned OIDC interaction sessions.
+func (j *DbCleanupJobs) clearInteractionSessions(ctx context.Context) error {
+ count, err := oidc.CleanupAbandonedInteractionSessions(ctx, j.db)
+ if err != nil {
+ return fmt.Errorf("failed to clean interaction sessions: %w", err)
+ }
+
+ slog.InfoContext(ctx, "Cleaned interaction sessions", slog.Int64("count", count))
return nil
}
@@ -147,20 +157,6 @@ func (j *DbCleanupJobs) clearAuditLogs(ctx context.Context) error {
return nil
}
-// clearOidcPushedAuthorizationRequests deletes PAR records that have expired without being consumed
-func (j *DbCleanupJobs) clearOidcPushedAuthorizationRequests(ctx context.Context) error {
- st := j.db.
- WithContext(ctx).
- Delete(&model.OidcPushedAuthorizationRequest{}, "expires_at < ?", datatype.DateTime(time.Now()))
- if st.Error != nil {
- return fmt.Errorf("failed to clean expired pushed authorization requests: %w", st.Error)
- }
-
- slog.InfoContext(ctx, "Cleaned expired pushed authorization requests", slog.Int64("count", st.RowsAffected))
-
- return nil
-}
-
// ClearEmailVerificationTokens deletes email verification tokens that have expired
func (j *DbCleanupJobs) clearEmailVerificationTokens(ctx context.Context) error {
st := j.db.
diff --git a/backend/internal/middleware/auth_middleware.go b/backend/internal/middleware/auth_middleware.go
index 889ccdff..16a89fc7 100644
--- a/backend/internal/middleware/auth_middleware.go
+++ b/backend/internal/middleware/auth_middleware.go
@@ -74,11 +74,12 @@ func (m *AuthMiddleware) WithApiKeyAuthDisabled() *AuthMiddleware {
func (m *AuthMiddleware) Add() gin.HandlerFunc {
return func(c *gin.Context) {
- userID, isAdmin, authenticationMethod, err := m.jwtMiddleware.Verify(c, m.options.AdminRequired)
+ userID, isAdmin, authenticationMethod, authenticationTime, err := m.jwtMiddleware.Verify(c, m.options.AdminRequired)
if err == nil {
c.Set("userID", userID)
c.Set("userIsAdmin", isAdmin)
c.Set("authenticationMethod", authenticationMethod)
+ c.Set("authenticationTime", authenticationTime)
if c.IsAborted() {
return
}
diff --git a/backend/internal/middleware/cors.go b/backend/internal/middleware/cors.go
index d6ae14ef..4a1cc85b 100644
--- a/backend/internal/middleware/cors.go
+++ b/backend/internal/middleware/cors.go
@@ -43,7 +43,7 @@ func isCorsPath(path string) bool {
switch path {
case "/api/oidc/token",
"/api/oidc/userinfo",
- "/oidc/end-session",
+ "/api/oidc/end-session",
"/api/oidc/introspect",
"/.well-known/jwks.json",
"/.well-known/openid-configuration":
diff --git a/backend/internal/middleware/csp_middleware.go b/backend/internal/middleware/csp_middleware.go
index ae64528e..2b6f9f88 100644
--- a/backend/internal/middleware/csp_middleware.go
+++ b/backend/internal/middleware/csp_middleware.go
@@ -1,11 +1,8 @@
package middleware
import (
- "crypto/rand"
- "encoding/base64"
- "strings"
-
"github.com/gin-gonic/gin"
+ "github.com/pocket-id/pocket-id/backend/internal/utils"
)
// CspMiddleware sets a Content Security Policy header and, when possible,
@@ -16,19 +13,14 @@ func NewCspMiddleware() *CspMiddleware { return &CspMiddleware{} }
// GetCSPNonce returns the CSP nonce generated for this request, if any.
func GetCSPNonce(c *gin.Context) string {
- if v, ok := c.Get("csp_nonce"); ok {
- if s, ok := v.(string); ok {
- return s
- }
- }
- return ""
+ return utils.GetCSPNonce(c)
}
func (m *CspMiddleware) Add() gin.HandlerFunc {
return func(c *gin.Context) {
// Generate a random base64 nonce for this request
- nonce := generateNonce()
- c.Set("csp_nonce", nonce)
+ nonce := utils.GenerateCSPNonce()
+ utils.SetCSPNonce(c, nonce)
c.Writer.Header().Set("Content-Security-Policy", BuildCSP(nonce))
c.Next()
@@ -36,36 +28,5 @@ func (m *CspMiddleware) Add() gin.HandlerFunc {
}
func BuildCSP(nonce string, formActionExtra ...string) string {
- formAction := "'self'"
-
- if len(formActionExtra) > 0 {
- b := strings.Builder{}
-
- for _, extra := range formActionExtra {
- if extra != "" {
- b.WriteByte(' ')
- b.WriteString(extra)
- }
- }
-
- formAction += b.String()
- }
-
- return "default-src 'self'; " +
- "base-uri 'self'; " +
- "object-src 'none'; " +
- "frame-ancestors 'none'; " +
- "form-action " + formAction + "; " +
- "img-src * blob:;" +
- "font-src 'self'; " +
- "style-src 'self' 'unsafe-inline'; " +
- "script-src 'self' 'nonce-" + nonce + "'"
-}
-
-func generateNonce() string {
- b := make([]byte, 16)
- if _, err := rand.Read(b); err != nil {
- return "" // if generation fails, return empty; policy will omit nonce
- }
- return base64.RawURLEncoding.EncodeToString(b)
+ return utils.BuildCSP(nonce, formActionExtra...)
}
diff --git a/backend/internal/middleware/jwt_auth.go b/backend/internal/middleware/jwt_auth.go
index c311b537..27aba3ce 100644
--- a/backend/internal/middleware/jwt_auth.go
+++ b/backend/internal/middleware/jwt_auth.go
@@ -2,6 +2,7 @@ package middleware
import (
"strings"
+ "time"
"github.com/gin-gonic/gin"
"github.com/pocket-id/pocket-id/backend/internal/common"
@@ -20,7 +21,7 @@ func NewJwtAuthMiddleware(jwtService *service.JwtService, userService *service.U
func (m *JwtAuthMiddleware) Add(adminRequired bool) gin.HandlerFunc {
return func(c *gin.Context) {
- userID, isAdmin, authenticationMethod, err := m.Verify(c, adminRequired)
+ userID, isAdmin, authenticationMethod, authenticationTime, err := m.Verify(c, adminRequired)
if err != nil {
c.Abort()
_ = c.Error(err)
@@ -30,11 +31,12 @@ func (m *JwtAuthMiddleware) Add(adminRequired bool) gin.HandlerFunc {
c.Set("userID", userID)
c.Set("userIsAdmin", isAdmin)
c.Set("authenticationMethod", authenticationMethod)
+ c.Set("authenticationTime", authenticationTime)
c.Next()
}
}
-func (m *JwtAuthMiddleware) Verify(c *gin.Context, adminRequired bool) (subject string, isAdmin bool, authenticationMethod string, err error) {
+func (m *JwtAuthMiddleware) Verify(c *gin.Context, adminRequired bool) (subject string, isAdmin bool, authenticationMethod string, authenticationTime time.Time, err error) {
// Extract the token from the cookie
accessToken, err := c.Cookie(cookie.AccessTokenCookieName)
if err != nil {
@@ -42,37 +44,38 @@ func (m *JwtAuthMiddleware) Verify(c *gin.Context, adminRequired bool) (subject
var ok bool
_, accessToken, ok = strings.Cut(c.GetHeader("Authorization"), " ")
if !ok || accessToken == "" {
- return "", false, "", &common.NotSignedInError{}
+ return "", false, "", time.Time{}, &common.NotSignedInError{}
}
}
token, err := m.jwtService.VerifyAccessToken(accessToken)
if err != nil {
- return "", false, "", &common.NotSignedInError{}
+ return "", false, "", time.Time{}, &common.NotSignedInError{}
}
authenticationMethod, err = service.GetAuthenticationMethod(token)
if err != nil {
- return "", false, "", &common.NotSignedInError{}
+ return "", false, "", time.Time{}, &common.NotSignedInError{}
}
+ authenticationTime, _ = token.IssuedAt()
subject, ok := token.Subject()
if !ok {
_ = c.Error(&common.TokenInvalidError{})
- return "", false, "", &common.TokenInvalidError{}
+ return "", false, "", time.Time{}, &common.TokenInvalidError{}
}
user, err := m.userService.GetUser(c, subject)
if err != nil {
- return "", false, "", &common.NotSignedInError{}
+ return "", false, "", time.Time{}, &common.NotSignedInError{}
}
if user.Disabled {
- return "", false, "", &common.UserDisabledError{}
+ return "", false, "", time.Time{}, &common.UserDisabledError{}
}
if adminRequired && !user.IsAdmin {
- return "", false, "", &common.MissingPermissionError{}
+ return "", false, "", time.Time{}, &common.MissingPermissionError{}
}
- return subject, user.IsAdmin, authenticationMethod, nil
+ return subject, user.IsAdmin, authenticationMethod, authenticationTime, nil
}
diff --git a/backend/internal/model/oidc.go b/backend/internal/model/oidc.go
index 165ea325..bd9ca046 100644
--- a/backend/internal/model/oidc.go
+++ b/backend/internal/model/oidc.go
@@ -3,14 +3,13 @@ package model
import (
"database/sql/driver"
"encoding/json"
- "strings"
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
"github.com/pocket-id/pocket-id/backend/internal/utils"
)
type UserAuthorizedOidcClient struct {
- Scope string
+ Scope datatype.StringList
LastUsedAt datatype.DateTime `sortable:"true"`
UserID string `gorm:"primary_key;"`
@@ -20,31 +19,6 @@ type UserAuthorizedOidcClient struct {
Client OidcClient
}
-func (c UserAuthorizedOidcClient) Scopes() []string {
- if len(c.Scope) == 0 {
- return []string{}
- }
-
- return strings.Split(c.Scope, " ")
-}
-
-type OidcAuthorizationCode struct {
- Base
-
- Code string
- Scope string
- AuthenticationMethod string
- Nonce string
- CodeChallenge *string
- CodeChallengeMethodSha256 *bool
- ExpiresAt datatype.DateTime
-
- UserID string
- User User
-
- ClientID string
-}
-
type OidcClient struct {
Base
@@ -76,39 +50,16 @@ func (c OidcClient) HasDarkLogo() bool {
return c.DarkImageType != nil && *c.DarkImageType != ""
}
-type OidcRefreshToken struct {
- Base
-
- Token string
- IdTokenJti *string
- ExpiresAt datatype.DateTime
- Scope string
- AuthenticationMethod string
-
- UserID string
- User User
-
- ClientID string
- Client OidcClient
-}
-
-func (c OidcRefreshToken) Scopes() []string {
- if len(c.Scope) == 0 {
- return []string{}
- }
-
- return strings.Split(c.Scope, " ")
-}
-
type OidcClientCredentials struct { //nolint:recvcheck
FederatedIdentities []OidcClientFederatedIdentity `json:"federatedIdentities,omitempty"`
}
type OidcClientFederatedIdentity struct {
- Issuer string `json:"issuer"`
- Subject string `json:"subject,omitempty"`
- Audience string `json:"audience,omitempty"`
- JWKS string `json:"jwks,omitempty"` // URL of the JWKS
+ Issuer string `json:"issuer"`
+ Subject string `json:"subject,omitempty"`
+ Audience string `json:"audience,omitempty"`
+ JWKS string `json:"jwks,omitempty"` // URL of the JWKS
+ ReplayProtection bool `json:"replayProtection,omitempty"`
}
func (occ OidcClientCredentials) FederatedIdentityForIssuer(issuer string) (OidcClientFederatedIdentity, bool) {
@@ -142,48 +93,3 @@ func (cu *UrlList) Scan(value any) error {
func (cu UrlList) Value() (driver.Value, error) {
return json.Marshal(cu)
}
-
-type OidcDeviceCode struct {
- Base
- DeviceCode string
- UserCode string
- Scope string
- AuthenticationMethod string
- Nonce string
- ExpiresAt datatype.DateTime
- IsAuthorized bool
-
- UserID *string
- User User
- ClientID string
- Client OidcClient
-}
-
-type OidcPushedAuthorizationRequest struct {
- Base
-
- RequestURI string
- ClientID string
- Parameters OidcAuthorizationRequestParameters
- ExpiresAt datatype.DateTime
-}
-
-type OidcAuthorizationRequestParameters struct { //nolint:recvcheck
- Scope string `json:"scope,omitempty"`
- RedirectURI string `json:"redirect_uri,omitempty"`
- State string `json:"state,omitempty"`
- Nonce string `json:"nonce,omitempty"`
- CodeChallenge string `json:"code_challenge,omitempty"`
- CodeChallengeMethod string `json:"code_challenge_method,omitempty"`
- ResponseType string `json:"response_type,omitempty"`
- Prompt string `json:"prompt,omitempty"`
- ResponseMode string `json:"response_mode,omitempty"`
-}
-
-func (p *OidcAuthorizationRequestParameters) Scan(value any) error {
- return utils.UnmarshalJSONFromDatabase(p, value)
-}
-
-func (p OidcAuthorizationRequestParameters) Value() (driver.Value, error) {
- return json.Marshal(p)
-}
diff --git a/backend/internal/model/types/string_list.go b/backend/internal/model/types/string_list.go
new file mode 100644
index 00000000..aae65b19
--- /dev/null
+++ b/backend/internal/model/types/string_list.go
@@ -0,0 +1,18 @@
+package datatype
+
+import (
+ "database/sql/driver"
+ "encoding/json"
+
+ "github.com/pocket-id/pocket-id/backend/internal/utils"
+)
+
+type StringList []string //nolint:recvcheck
+
+func (s *StringList) Scan(value any) error {
+ return utils.UnmarshalJSONFromDatabase(s, value)
+}
+
+func (s StringList) Value() (driver.Value, error) {
+ return json.Marshal(s)
+}
diff --git a/backend/internal/oidc/authorization_handler.go b/backend/internal/oidc/authorization_handler.go
new file mode 100644
index 00000000..a95e043f
--- /dev/null
+++ b/backend/internal/oidc/authorization_handler.go
@@ -0,0 +1,156 @@
+package oidc
+
+import (
+ "log/slog"
+ "net/http"
+ "strings"
+ "time"
+
+ "github.com/gin-gonic/gin"
+ "github.com/ory/fosite"
+ "github.com/pocket-id/pocket-id/backend/internal/common"
+ "github.com/pocket-id/pocket-id/backend/internal/utils"
+ "github.com/pocket-id/pocket-id/backend/internal/utils/cookie"
+)
+
+const parRequestURIPrefix = "urn:ietf:params:oauth:request_uri:"
+
+type authorizationHandler struct {
+ provider fosite.OAuth2Provider
+ authorizationService *authorizationService
+ baseURL string
+}
+
+func newAuthorizationHandler(
+ provider fosite.OAuth2Provider,
+ authorizationService *authorizationService,
+ baseURL string,
+) *authorizationHandler {
+ return &authorizationHandler{
+ provider: provider,
+ authorizationService: authorizationService,
+ baseURL: baseURL,
+ }
+}
+
+func (h *authorizationHandler) authorize(c *gin.Context) {
+ ctx := c.Request.Context()
+ userID := c.GetString("userID")
+ authenticationMethod := c.GetString("authenticationMethod")
+ authenticationTime, _ := c.Get("authenticationTime")
+ typedAuthenticationTime, _ := authenticationTime.(time.Time)
+ reauthenticationToken, _ := c.Cookie(cookie.ReauthenticationTokenCookieName)
+
+ // A request that resumes an interaction only carries the interaction ID; the original
+ // parameters are restored from the stored session so they never travel through the
+ // front channel.
+ interactionID := c.Query("interaction")
+ if interactionID != "" {
+ query, err := h.authorizationService.interactionRequestQuery(ctx, interactionID)
+ if err != nil {
+ slog.WarnContext(ctx, "Failed to restore authorize request from interaction session", "error", err.Error())
+ h.provider.WriteAuthorizeError(ctx, c.Writer, fosite.NewAuthorizeRequest(), err)
+ return
+ }
+ c.Request.URL.RawQuery = query.Encode()
+ }
+
+ // Treat the request as a pushed authorization request only when the request_uri carries the
+ // PAR prefix. Without this, a client required to use PAR could bypass that requirement by
+ // sending an arbitrary (non-prefixed) request_uri, which fosite silently ignores.
+ hasPushedAuthorizationRequest := strings.HasPrefix(c.Query("request_uri"), parRequestURIPrefix)
+
+ ar, err := h.provider.NewAuthorizeRequest(ctx, c.Request)
+ if err != nil {
+ slog.ErrorContext(ctx, "Failed to create authorize request", "error", err.Error())
+ h.provider.WriteAuthorizeError(ctx, c.Writer, ar, err)
+ return
+ }
+
+ authorization, err := h.authorizationService.authorize(ctx, authorizeInput{
+ userID: userID,
+ authenticationMethod: authenticationMethod,
+ authenticationTime: typedAuthenticationTime,
+ requester: ar,
+ hasPushedAuthorizationRequest: hasPushedAuthorizationRequest,
+ reauthenticationToken: reauthenticationToken,
+ interactionID: interactionID,
+ requestParams: authorizeRequestParams(ar),
+ meta: requestMetaFromGin(c),
+ })
+ if err != nil {
+ slog.ErrorContext(ctx, "Failed to authorize request", "error", err.Error())
+ h.provider.WriteAuthorizeError(ctx, c.Writer, ar, err)
+ return
+ }
+
+ if authorization.RequiresInteraction {
+ c.Redirect(http.StatusFound, "/interaction?interaction="+authorization.InteractionID)
+ return
+ }
+
+ response, err := h.provider.NewAuthorizeResponse(ctx, ar, authorization.Session)
+ if err != nil {
+ slog.ErrorContext(ctx, "Failed to create authorize response", "error", err.Error())
+ h.provider.WriteAuthorizeError(ctx, c.Writer, ar, err)
+ return
+ }
+
+ response.AddParameter("iss", h.baseURL)
+ if ar.GetResponseMode() == fosite.ResponseModeFormPost && ar.GetRedirectURI() != nil {
+ c.Header("Content-Security-Policy", utils.BuildCSP(utils.GetCSPNonce(c), ar.GetRedirectURI().String()))
+ }
+ h.provider.WriteAuthorizeResponse(ctx, c.Writer, ar, response)
+}
+
+func requestMetaFromGin(c *gin.Context) requestMeta {
+ return requestMeta{
+ IPAddress: c.ClientIP(),
+ UserAgent: c.Request.UserAgent(),
+ }
+}
+
+func authorizeRequestParams(requester fosite.AuthorizeRequester) map[string]string {
+ params := make(map[string]string)
+ for key, values := range requester.GetRequestForm() {
+ if len(values) == 0 || key == "request_uri" || key == "interaction" {
+ continue
+ }
+ params[key] = values[0]
+ }
+
+ return params
+}
+
+func (h *authorizationHandler) getInteractionSession(c *gin.Context) {
+ interactionID := c.Param("id")
+
+ interactionSession, err := h.authorizationService.getInteractionSession(c.Request.Context(), interactionID)
+ if err != nil {
+ _ = c.Error(err)
+ return
+ }
+
+ c.JSON(http.StatusOK, interactionSession)
+}
+
+func (h *authorizationHandler) completeInteraction(c *gin.Context) {
+ interactionID := c.Param("id")
+ authenticationTime, _ := c.Get("authenticationTime")
+ typedAuthenticationTime, _ := authenticationTime.(time.Time)
+
+ var request completeInteractionRequest
+ if err := c.ShouldBindJSON(&request); err != nil {
+ _ = c.Error(&common.ValidationError{Message: "invalid interaction request"})
+ return
+ }
+
+ reauthenticationToken, _ := c.Cookie(cookie.ReauthenticationTokenCookieName)
+ response, err := h.authorizationService.completeInteractionStep(c.Request.Context(), interactionID, c.GetString("userID"), request.Step, reauthenticationToken, typedAuthenticationTime, requestMetaFromGin(c))
+ if err != nil {
+ _ = c.Error(err)
+ return
+ }
+
+ c.JSON(http.StatusOK, response)
+}
diff --git a/backend/internal/oidc/authorization_service.go b/backend/internal/oidc/authorization_service.go
new file mode 100644
index 00000000..93590d6d
--- /dev/null
+++ b/backend/internal/oidc/authorization_service.go
@@ -0,0 +1,690 @@
+package oidc
+
+import (
+ "context"
+ "errors"
+ "net/url"
+ "slices"
+ "strconv"
+ "strings"
+ "time"
+
+ "github.com/ory/fosite"
+ "github.com/pocket-id/pocket-id/backend/internal/common"
+ "github.com/pocket-id/pocket-id/backend/internal/model"
+ datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
+ "github.com/pocket-id/pocket-id/backend/internal/utils"
+ "gorm.io/gorm"
+ "gorm.io/gorm/clause"
+)
+
+func newAuthorizationService(db *gorm.DB, interactionSessionService *interactionSessionService, claimsService *ClaimsService, reauth ReauthenticationTokenConsumer, auditLog AuditLogger) *authorizationService {
+ return &authorizationService{
+ db: db,
+ interactionSessionService: interactionSessionService,
+ claimsService: claimsService,
+ reauth: reauth,
+ auditLog: auditLog,
+ }
+}
+
+type authorizationService struct {
+ db *gorm.DB
+ interactionSessionService *interactionSessionService
+ claimsService *ClaimsService
+ reauth ReauthenticationTokenConsumer
+ auditLog AuditLogger
+}
+
+type requestMeta struct {
+ IPAddress string
+ UserAgent string
+}
+
+type authorizationResult struct {
+ RequiresInteraction bool
+ InteractionID string
+ Session *Session
+}
+
+type promptValues []string
+
+func newPromptValues(prompt string) promptValues {
+ return strings.Fields(prompt)
+}
+
+func (p promptValues) has(value string) bool {
+ return slices.Contains(p, value)
+}
+
+// authorizeInput is the authorization request as provided by the handler.
+type authorizeInput struct {
+ userID string
+ authenticationMethod string
+ authenticationTime time.Time
+ requester fosite.AuthorizeRequester
+ hasPushedAuthorizationRequest bool
+ reauthenticationToken string
+ interactionID string
+ requestParams map[string]string
+ meta requestMeta
+}
+
+// authorizeRequest is the input enriched with everything the service derives from it.
+type authorizeRequest struct {
+ authorizeInput
+
+ client Client
+ prompt promptValues
+ interactionSession *InteractionSession
+ now time.Time
+}
+
+func (s *authorizationService) authorize(ctx context.Context, input authorizeInput) (authorizationResult, error) {
+ client := input.requester.GetClient().(Client)
+ prompt := newPromptValues(input.requester.GetRequestForm().Get("prompt"))
+
+ if err := validateClientPKCERequirement(client, input.requester); err != nil {
+ return authorizationResult{}, err
+ }
+
+ interactionSession, err := s.boundInteractionSession(ctx, input.interactionID, input.userID, client, input.requester)
+ if err != nil {
+ return authorizationResult{}, err
+ }
+
+ // Reject authorization requests that require PAR when the request is not a resumed interaction and doesn't have a valid PAR.
+ if client.RequiresPushedAuthorizationRequests && !input.hasPushedAuthorizationRequest && interactionSession == nil {
+ return authorizationResult{}, &common.OidcPARRequiredError{}
+ }
+
+ if input.userID == "" {
+ if prompt.has("none") {
+ return authorizationResult{}, fosite.ErrLoginRequired
+ }
+
+ interactionSession, err := s.createInteractionSession(ctx, input.requester, input.requestParams, "", interactionRequirements{
+ AuthenticationRequired: true,
+ ReauthenticationRequired: prompt.has("login") || client.RequiresReauthentication,
+ AccountSelectionRequired: prompt.has("select_account"),
+ ConsentRequired: prompt.has("consent"),
+ })
+ if err != nil {
+ return authorizationResult{}, err
+ }
+
+ return authorizationResult{RequiresInteraction: true, InteractionID: interactionSession.ID}, nil
+ }
+
+ req := authorizeRequest{
+ authorizeInput: input,
+ client: client,
+ prompt: prompt,
+ interactionSession: interactionSession,
+ now: time.Now().UTC(),
+ }
+
+ var result authorizationResult
+ err = withTx(ctx, s.db, func(ctx context.Context) error {
+ var err error
+ result, err = s.authorizeAuthenticated(ctx, req)
+ return err
+ })
+ if err != nil {
+ return authorizationResult{}, err
+ }
+
+ if result.Session == nil {
+ return result, nil
+ }
+
+ if err := s.claimsService.applyIDTokenClaims(ctx, result.Session, input.requester.GetGrantedScopes()); err != nil {
+ return authorizationResult{}, err
+ }
+
+ return result, nil
+}
+
+// authorizeAuthenticated either reports the interaction the user still has to complete or grants the request.
+func (s *authorizationService) authorizeAuthenticated(ctx context.Context, req authorizeRequest) (authorizationResult, error) {
+ var user model.User
+ err := dbFromContext(ctx, s.db).
+ Preload("UserGroups").
+ First(&user, "id = ?", req.userID).
+ Error
+ if err != nil {
+ return authorizationResult{}, err
+ }
+
+ if !IsUserGroupAllowedToAuthorize(user, req.client.OidcClient) {
+ return authorizationResult{}, fosite.ErrAccessDenied.WithHint("You are not allowed to access this service.")
+ }
+
+ interactionSession := req.interactionSession
+ if interactionSession != nil && interactionSession.UserID != nil && *interactionSession.UserID != req.userID {
+ if err := s.switchInteractionSessionUser(ctx, interactionSession, req.userID, req.authenticationTime); err != nil {
+ return authorizationResult{}, err
+ }
+ if err := s.interactionSessionService.update(ctx, *interactionSession); err != nil {
+ return authorizationResult{}, err
+ }
+ }
+
+ requirements, authenticationTime, err := s.resolveRequirements(ctx, req, interactionSession)
+ if err != nil {
+ return authorizationResult{}, err
+ }
+
+ if requirements.any() {
+ if interactionSession != nil {
+ return authorizationResult{RequiresInteraction: true, InteractionID: interactionSession.ID}, nil
+ }
+
+ created, err := s.createInteractionSession(ctx, req.requester, req.requestParams, req.userID, requirements)
+ if err != nil {
+ return authorizationResult{}, err
+ }
+
+ return authorizationResult{RequiresInteraction: true, InteractionID: created.ID}, nil
+ }
+
+ if interactionSession != nil {
+ err := s.interactionSessionService.delete(ctx, interactionSession.ID)
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ return authorizationResult{}, fosite.ErrInvalidRequest.WithHint("The interaction session has already been used.")
+ }
+ if err != nil {
+ return authorizationResult{}, err
+ }
+ }
+
+ hasAlreadyAuthorizedClient, err := s.consent(ctx, req.userID, req.client.GetID(), req.requester.GetRequestedScopes())
+ if err != nil {
+ return authorizationResult{}, err
+ }
+
+ session := s.buildAuthorizedSession(req, interactionSession, authenticationTime)
+
+ for _, scope := range req.requester.GetRequestedScopes() {
+ req.requester.GrantScope(scope)
+ }
+
+ authorizationEvent := model.AuditLogEventClientAuthorization
+ if !hasAlreadyAuthorizedClient {
+ authorizationEvent = model.AuditLogEventNewClientAuthorization
+ }
+ if s.auditLog != nil {
+ s.auditLog.Create(ctx, authorizationEvent, req.meta.IPAddress, req.meta.UserAgent, req.userID, model.AuditLogData{"clientName": req.client.Name}, dbFromContext(ctx, s.db))
+ }
+
+ return authorizationResult{Session: session}, nil
+}
+
+// resolveRequirements determines the interaction steps still required; the requirements
+// of a resumed interaction session win over the ones derived from the request.
+func (s *authorizationService) resolveRequirements(ctx context.Context, req authorizeRequest, interactionSession *InteractionSession) (interactionRequirements, time.Time, error) {
+ authenticationTime := req.authenticationTime
+
+ hasAlreadyAuthorizedClient, err := s.hasAuthorizedClient(ctx, req.client.GetID(), req.userID, req.requester.GetRequestedScopes())
+ if err != nil {
+ return interactionRequirements{}, authenticationTime, err
+ }
+
+ maxAgeReauthenticationRequired, err := requiresReauthenticationForMaxAge(req.requester.GetRequestForm().Get("max_age"), authenticationTime, req.now)
+ if err != nil {
+ return interactionRequirements{}, authenticationTime, err
+ }
+
+ requirements := interactionRequirements{
+ ConsentRequired: !hasAlreadyAuthorizedClient || req.prompt.has("consent"),
+ ReauthenticationRequired: req.prompt.has("login") || req.client.RequiresReauthentication || maxAgeReauthenticationRequired,
+ AccountSelectionRequired: req.prompt.has("select_account"),
+ AuthenticationRequired: false,
+ }
+
+ if interactionSession != nil {
+ requirements = interactionRequirements{
+ ConsentRequired: interactionSession.ConsentRequired,
+ ReauthenticationRequired: interactionSession.ReauthenticationRequired,
+ AccountSelectionRequired: interactionSession.AccountSelectionRequired,
+ AuthenticationRequired: interactionSession.AuthenticationRequired,
+ }
+ if interactionSession.ReauthenticatedAt != nil {
+ authenticationTime = interactionSession.ReauthenticatedAt.UTC()
+ }
+ }
+
+ if req.prompt.has("none") && requirements.ConsentRequired {
+ return interactionRequirements{}, authenticationTime, fosite.ErrConsentRequired
+ }
+ if req.prompt.has("none") && requirements.ReauthenticationRequired {
+ return interactionRequirements{}, authenticationTime, fosite.ErrLoginRequired
+ }
+
+ if requirements.ReauthenticationRequired && req.reauthenticationToken != "" && s.reauth != nil {
+ reauthenticatedAt, err := s.reauth.ConsumeReauthenticationToken(ctx, dbFromContext(ctx, s.db), req.reauthenticationToken, req.userID)
+ if err == nil {
+ requirements.ReauthenticationRequired = false
+ authenticationTime = reauthenticatedAt
+ }
+ }
+
+ return requirements, authenticationTime, nil
+}
+
+func (s *authorizationService) buildAuthorizedSession(req authorizeRequest, interactionSession *InteractionSession, authenticationTime time.Time) *Session {
+ if authenticationTime.IsZero() {
+ authenticationTime = req.now
+ }
+ requestedAt := req.requester.GetRequestedAt()
+ if interactionSession != nil && !interactionSession.RequestedAt.ToTime().IsZero() {
+ requestedAt = interactionSession.RequestedAt.UTC()
+ }
+ if requestedAt.IsZero() {
+ requestedAt = req.now
+ }
+
+ return NewAuthenticatedSession(req.userID, req.authenticationMethod, authenticationTime, requestedAt)
+}
+
+// interactionRequestQuery returns the authorize parameters stored for the interaction
+// session, so the handler can rebuild the re-entry request server-side.
+func (s *authorizationService) interactionRequestQuery(ctx context.Context, interactionID string) (query url.Values, err error) {
+ interactionSession, err := s.interactionSessionService.get(ctx, interactionID)
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ return nil, fosite.ErrInvalidRequest.WithHint("The interaction session is invalid or has expired.")
+ }
+ if err != nil {
+ return nil, err
+ }
+
+ query = url.Values{}
+ for key, value := range interactionSession.Parameters {
+ if value != "" {
+ query.Set(key, value)
+ }
+ }
+ query.Set("interaction", interactionSession.ID)
+
+ return query, nil
+}
+
+// boundInteractionSession loads the referenced interaction session and validates the request against it.
+func (s *authorizationService) boundInteractionSession(ctx context.Context, interactionID string, userID string, client Client, requester fosite.AuthorizeRequester) (*InteractionSession, error) {
+ if interactionID == "" {
+ return nil, nil
+ }
+
+ session, err := s.interactionSessionService.get(ctx, interactionID)
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ return nil, fosite.ErrInvalidRequest.WithHint("The interaction session is invalid or has expired.")
+ }
+ if err != nil {
+ return nil, err
+ }
+
+ if err := validateInteractionSessionBinding(session, userID, client, requester); err != nil {
+ return nil, err
+ }
+
+ return &session, nil
+}
+
+// validateInteractionSessionBinding ensures a request matches the interaction session
+// it resumes: the requirement flags and the PAR exemption come from the session, so a
+// tampered request (extra scopes, another client's interaction) must not inherit them.
+func validateInteractionSessionBinding(interactionSession InteractionSession, userID string, client Client, requester fosite.AuthorizeRequester) error {
+ if interactionSession.ClientID != client.GetID() {
+ return fosite.ErrInvalidRequest.WithHint("The interaction session does not belong to this client.")
+ }
+
+ if interactionSession.UserID != nil {
+ if userID == "" {
+ return fosite.ErrLoginRequired
+ }
+ }
+
+ for _, scope := range requester.GetRequestedScopes() {
+ if !slices.Contains(interactionSession.Scopes, scope) {
+ return fosite.ErrInvalidRequest.WithHint("The requested scopes exceed the scopes of the interaction session.")
+ }
+ }
+
+ return nil
+}
+
+type interactionRequirements struct {
+ ConsentRequired bool
+ ReauthenticationRequired bool
+ AuthenticationRequired bool
+ AccountSelectionRequired bool
+}
+
+func (r interactionRequirements) any() bool {
+ return r.ConsentRequired || r.ReauthenticationRequired || r.AuthenticationRequired || r.AccountSelectionRequired
+}
+
+func (s *authorizationService) createInteractionSession(ctx context.Context, requester fosite.AuthorizeRequester, requestParams map[string]string, userID string, requirements interactionRequirements) (InteractionSession, error) {
+ parameters := make(map[string]string, len(requestParams))
+ for key, value := range requestParams {
+ parameters[key] = value
+ }
+
+ return s.interactionSessionService.create(ctx, InteractionSession{
+ Base: model.Base{
+ ID: requester.GetID(),
+ },
+ Scopes: datatype.StringList(requester.GetRequestedScopes()),
+ ClientID: requester.GetClient().GetID(),
+ UserID: utils.PtrOrNil(userID),
+ ConsentRequired: requirements.ConsentRequired,
+ ReauthenticationRequired: requirements.ReauthenticationRequired,
+ AuthenticationRequired: requirements.AuthenticationRequired,
+ AccountSelectionRequired: requirements.AccountSelectionRequired,
+ RequestedAt: datatype.DateTime(requester.GetRequestedAt()),
+ Parameters: parameters,
+ })
+}
+
+func bindInteractionSessionUser(interactionSession *InteractionSession, userID string) error {
+ if userID == "" {
+ return fosite.ErrLoginRequired
+ }
+ if interactionSession.UserID != nil && *interactionSession.UserID != userID {
+ return fosite.ErrInvalidRequest.WithHint("The interaction session belongs to another user.")
+ }
+ if interactionSession.UserID == nil {
+ interactionSession.UserID = &userID
+ }
+
+ return nil
+}
+
+func (s *authorizationService) switchInteractionSessionUser(ctx context.Context, interactionSession *InteractionSession, userID string, authenticationTime time.Time) error {
+ if userID == "" {
+ return fosite.ErrLoginRequired
+ }
+ interactionSession.UserID = &userID
+
+ requirements, err := s.interactionRequirementsForUser(ctx, userID, interactionSession, authenticationTime)
+ if err != nil {
+ return err
+ }
+
+ interactionSession.AuthenticationRequired = false
+ interactionSession.ConsentRequired = requirements.ConsentRequired
+ interactionSession.ReauthenticationRequired = requirements.ReauthenticationRequired
+ interactionSession.ReauthenticatedAt = nil
+
+ return nil
+}
+
+func (s *authorizationService) getInteractionSession(ctx context.Context, interactionSessionID string) (interactionSessionForUser, error) {
+ interactionSession, err := s.interactionSessionService.get(ctx, interactionSessionID)
+ if err != nil {
+ return interactionSessionForUser{}, err
+ }
+
+ return newInteractionSessionForUser(interactionSession)
+}
+
+func (s *authorizationService) completeInteractionStep(ctx context.Context, interactionSessionID, userID string, step interactionStep, reauthenticationToken string, authenticationTime time.Time, meta requestMeta) (completeInteractionResponse, error) {
+ var interactionSession InteractionSession
+ var response completeInteractionResponse
+ err := withTx(ctx, s.db, func(ctx context.Context) error {
+ var err error
+ interactionSession, err = s.interactionSessionService.get(ctx, interactionSessionID)
+ if err != nil {
+ return err
+ }
+
+ if interactionSession.UserID != nil && *interactionSession.UserID != userID {
+ if err := s.switchInteractionSessionUser(ctx, &interactionSession, userID, authenticationTime); err != nil {
+ return err
+ }
+ }
+
+ if userID == "" {
+ return fosite.ErrLoginRequired
+ }
+
+ requiredSteps := requiredInteractionSteps(interactionSession)
+ if len(requiredSteps) == 0 {
+ response = completeInteractionResponse{RedirectURL: authorizeRedirectURL(interactionSession.ID)}
+ return nil
+ }
+
+ if requiredSteps[0] != step {
+ return &common.ValidationError{Message: "expected interaction step " + string(requiredSteps[0]) + " but got " + string(step)}
+ }
+
+ if err := s.applyInteractionStep(ctx, &interactionSession, userID, step, reauthenticationToken, authenticationTime, meta); err != nil {
+ return err
+ }
+
+ return s.interactionSessionService.update(ctx, interactionSession)
+ })
+ if err != nil {
+ return completeInteractionResponse{}, err
+ }
+ if response.RedirectURL != "" {
+ return response, nil
+ }
+
+ if !hasRemainingInteractionSteps(interactionSession) {
+ return completeInteractionResponse{RedirectURL: authorizeRedirectURL(interactionSession.ID)}, nil
+ }
+
+ interaction, err := newInteractionSessionForUser(interactionSession)
+ if err != nil {
+ return completeInteractionResponse{}, err
+ }
+
+ return completeInteractionResponse{Interaction: &interaction}, nil
+}
+
+func (s *authorizationService) applyInteractionStep(ctx context.Context, interactionSession *InteractionSession, userID string, step interactionStep, reauthenticationToken string, authenticationTime time.Time, meta requestMeta) error {
+ switch step {
+ case interactionStepAuthenticate:
+ if err := bindInteractionSessionUser(interactionSession, userID); err != nil {
+ return err
+ }
+ interactionSession.AuthenticationRequired = false
+ return s.populatePostAuthenticationRequirements(ctx, userID, interactionSession, authenticationTime)
+ case interactionStepSelectAccount:
+ if err := s.switchInteractionSessionUser(ctx, interactionSession, userID, authenticationTime); err != nil {
+ return err
+ }
+ interactionSession.AccountSelectionRequired = false
+ return nil
+ case interactionStepReauthenticate:
+ return s.completeReauthenticationStep(ctx, interactionSession, userID, reauthenticationToken)
+ case interactionStepConsent:
+ return s.completeConsentStep(ctx, interactionSession, userID, meta)
+ default:
+ return &common.ValidationError{Message: "unknown interaction step " + string(step)}
+ }
+}
+
+func (s *authorizationService) completeReauthenticationStep(ctx context.Context, interactionSession *InteractionSession, userID, reauthenticationToken string) error {
+ if err := bindInteractionSessionUser(interactionSession, userID); err != nil {
+ return err
+ }
+ if reauthenticationToken == "" {
+ return &common.ValidationError{Message: "reauthentication token is required"}
+ }
+ reauthenticatedAt, err := s.reauth.ConsumeReauthenticationToken(ctx, dbFromContext(ctx, s.db), reauthenticationToken, userID)
+ if err != nil {
+ return err
+ }
+
+ interactionSession.ReauthenticationRequired = false
+ interactionSession.ReauthenticatedAt = new(datatype.DateTime(reauthenticatedAt))
+ return nil
+}
+
+func (s *authorizationService) completeConsentStep(ctx context.Context, interactionSession *InteractionSession, userID string, meta requestMeta) error {
+ if err := bindInteractionSessionUser(interactionSession, userID); err != nil {
+ return err
+ }
+ hasAlreadyAuthorizedClient, err := s.consent(ctx, userID, interactionSession.ClientID, interactionSession.Scopes)
+ if err != nil {
+ return err
+ }
+ if !hasAlreadyAuthorizedClient && s.auditLog != nil {
+ s.auditLog.Create(ctx, model.AuditLogEventNewClientAuthorization, meta.IPAddress, meta.UserAgent, userID, model.AuditLogData{"clientName": interactionSession.Client.Name}, dbFromContext(ctx, s.db))
+ }
+ interactionSession.ConsentRequired = false
+ return nil
+}
+
+func (s *authorizationService) populatePostAuthenticationRequirements(ctx context.Context, userID string, interactionSession *InteractionSession, authenticationTime time.Time) error {
+ if userID == "" {
+ return errors.New("user is required to complete authentication")
+ }
+
+ requirements, err := s.interactionRequirementsForUser(ctx, userID, interactionSession, authenticationTime)
+ if err != nil {
+ return err
+ }
+
+ interactionSession.ConsentRequired = interactionSession.ConsentRequired || requirements.ConsentRequired
+ interactionSession.AccountSelectionRequired = interactionSession.AccountSelectionRequired || requirements.AccountSelectionRequired
+ interactionSession.ReauthenticationRequired = interactionSession.ReauthenticationRequired || requirements.ReauthenticationRequired
+
+ return nil
+}
+
+func (s *authorizationService) interactionRequirementsForUser(ctx context.Context, userID string, interactionSession *InteractionSession, authenticationTime time.Time) (interactionRequirements, error) {
+ prompt := newPromptValues(interactionSession.Parameters["prompt"])
+ hasAlreadyAuthorizedClient, err := s.hasAuthorizedClient(ctx, interactionSession.ClientID, userID, interactionSession.Scopes)
+ if err != nil {
+ return interactionRequirements{}, err
+ }
+
+ maxAgeReauthenticationRequired, err := requiresReauthenticationForMaxAge(interactionSession.Parameters["max_age"], authenticationTime, time.Now().UTC())
+ if err != nil {
+ return interactionRequirements{}, err
+ }
+
+ return interactionRequirements{
+ ConsentRequired: !hasAlreadyAuthorizedClient || prompt.has("consent"),
+ ReauthenticationRequired: prompt.has("login") || interactionSession.Client.RequiresReauthentication || maxAgeReauthenticationRequired,
+ AccountSelectionRequired: prompt.has("select_account"),
+ AuthenticationRequired: false,
+ }, nil
+}
+
+// authorizeRedirectURL only references the interaction session; the authorize endpoint
+// restores the request parameters from it server-side.
+func authorizeRedirectURL(interactionSessionID string) string {
+ return "/authorize?interaction=" + url.QueryEscape(interactionSessionID)
+}
+
+// validateClientPKCERequirement enforces the per-client PkceEnabled flag. fosite only
+// enforces PKCE for public clients (EnforcePKCEForPublicClients).
+func validateClientPKCERequirement(client Client, requester fosite.AuthorizeRequester) error {
+ if !client.PkceEnabled {
+ return nil
+ }
+ if requester.GetRequestForm().Get("code_challenge") == "" {
+ return fosite.ErrInvalidRequest.WithHint("This client requires PKCE, but the 'code_challenge' parameter is missing.")
+ }
+ return nil
+}
+
+func requiresReauthenticationForMaxAge(maxAgeRaw string, authenticationTime time.Time, now time.Time) (bool, error) {
+ if maxAgeRaw == "" {
+ return false, nil
+ }
+
+ maxAge, err := strconv.ParseInt(maxAgeRaw, 10, 64)
+ if err != nil || maxAge < 0 {
+ return false, fosite.ErrInvalidRequest.WithHint("Parameter 'max_age' must be a non-negative integer.")
+ }
+
+ if authenticationTime.IsZero() {
+ return true, nil
+ }
+
+ return !now.Before(authenticationTime.UTC().Add(time.Duration(maxAge) * time.Second)), nil
+}
+
+func (s *authorizationService) consent(ctx context.Context, userID string, clientID string, scope []string) (hasAlreadyAuthorizedClient bool, err error) {
+ db := dbFromContext(ctx, s.db)
+
+ hasAlreadyAuthorizedClient, err = s.hasAuthorizedClient(ctx, clientID, userID, scope)
+ if err != nil {
+ return false, err
+ }
+
+ if hasAlreadyAuthorizedClient {
+ err = db.
+ Model(&model.UserAuthorizedOidcClient{}).
+ Where("user_id = ? AND client_id = ?", userID, clientID).
+ Update("last_used_at", datatype.DateTime(time.Now())).
+ Error
+
+ if err != nil {
+ return hasAlreadyAuthorizedClient, err
+ }
+
+ return hasAlreadyAuthorizedClient, nil
+ }
+
+ userAuthorizedClient := model.UserAuthorizedOidcClient{
+ UserID: userID,
+ ClientID: clientID,
+ Scope: scope,
+ LastUsedAt: datatype.DateTime(time.Now()),
+ }
+
+ err = db.
+ Clauses(clause.OnConflict{
+ Columns: []clause.Column{{Name: "user_id"}, {Name: "client_id"}},
+ DoUpdates: clause.AssignmentColumns([]string{"scope"}),
+ }).
+ Create(&userAuthorizedClient).
+ Error
+
+ return hasAlreadyAuthorizedClient, err
+}
+
+func (s *authorizationService) hasAuthorizedClient(ctx context.Context, clientID, userID string, scope []string) (bool, error) {
+ var userAuthorizedOidcClient model.UserAuthorizedOidcClient
+ err := dbFromContext(ctx, s.db).
+ First(&userAuthorizedOidcClient, "client_id = ? AND user_id = ?", clientID, userID).
+ Error
+ if err != nil {
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ return false, nil
+ }
+ return false, err
+ }
+
+ authorizedScopes := userAuthorizedOidcClient.Scope
+ for _, requestedScope := range scope {
+ if !slices.Contains(authorizedScopes, requestedScope) {
+ return false, nil
+ }
+ }
+
+ return true, nil
+}
+
+// IsUserGroupAllowedToAuthorize reports whether the user may use the group-restricted client.
+func IsUserGroupAllowedToAuthorize(user model.User, client model.OidcClient) bool {
+ if !client.IsGroupRestricted {
+ return true
+ }
+
+ for _, userGroup := range client.AllowedUserGroups {
+ for _, userGroupUser := range user.UserGroups {
+ if userGroup.ID == userGroupUser.ID {
+ return true
+ }
+ }
+ }
+
+ return false
+}
diff --git a/backend/internal/oidc/authorization_service_test.go b/backend/internal/oidc/authorization_service_test.go
new file mode 100644
index 00000000..15659015
--- /dev/null
+++ b/backend/internal/oidc/authorization_service_test.go
@@ -0,0 +1,918 @@
+package oidc
+
+import (
+ "context"
+ "net/url"
+ "testing"
+ "time"
+
+ "github.com/ory/fosite"
+ "github.com/stretchr/testify/require"
+
+ "github.com/pocket-id/pocket-id/backend/internal/common"
+ "github.com/pocket-id/pocket-id/backend/internal/model"
+ datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
+ testutils "github.com/pocket-id/pocket-id/backend/internal/utils/testing"
+ "gorm.io/gorm"
+)
+
+type fakeAuditLogger struct {
+ events []model.AuditLogEvent
+ data []model.AuditLogData
+}
+
+func (f *fakeAuditLogger) Create(_ context.Context, event model.AuditLogEvent, _, _, _ string, data model.AuditLogData, _ *gorm.DB) (model.AuditLog, bool) {
+ f.events = append(f.events, event)
+ f.data = append(f.data, data)
+ return model.AuditLog{}, true
+}
+
+func TestAuthorizationServiceAuthorizeLogsClientAuthorization(t *testing.T) {
+ db := testutils.NewDatabaseForTest(t)
+ auditLogger := &fakeAuditLogger{}
+ service := newAuthorizationService(db, newInteractionSessionService(db), newClaimsService(db, nil, "", nil), nil, auditLogger)
+
+ const (
+ userID = "test-user"
+ clientID = "test-client"
+ )
+
+ require.NoError(t, db.Create(&model.User{
+ Base: model.Base{ID: userID},
+ }).Error)
+ require.NoError(t, db.Create(&model.OidcClient{
+ Base: model.Base{ID: clientID},
+ Name: "Test Client",
+ }).Error)
+ require.NoError(t, db.Create(&model.UserAuthorizedOidcClient{
+ UserID: userID,
+ ClientID: clientID,
+ Scope: datatype.StringList{"openid"},
+ }).Error)
+
+ authorization, err := service.authorize(t.Context(), authorizeInput{
+ userID: userID,
+ authenticationTime: time.Now().UTC(),
+ requester: newTestAuthorizeRequester("audit-request", clientID, ""),
+ meta: requestMeta{IPAddress: "203.0.113.1", UserAgent: "test-agent"},
+ })
+ require.NoError(t, err)
+ require.False(t, authorization.RequiresInteraction)
+
+ require.Equal(t, []model.AuditLogEvent{model.AuditLogEventClientAuthorization}, auditLogger.events)
+ require.Equal(t, model.AuditLogData{"clientName": "Test Client"}, auditLogger.data[0])
+}
+
+func TestAuthorizationServiceConsentStepLogsNewClientAuthorization(t *testing.T) {
+ db := testutils.NewDatabaseForTest(t)
+ auditLogger := &fakeAuditLogger{}
+ service := newAuthorizationService(db, newInteractionSessionService(db), newClaimsService(db, nil, "", nil), nil, auditLogger)
+
+ const (
+ userID = "test-user"
+ clientID = "test-client"
+ interactionID = "test-interaction"
+ )
+
+ require.NoError(t, db.Create(&model.User{
+ Base: model.Base{ID: userID},
+ }).Error)
+ require.NoError(t, db.Create(&model.OidcClient{
+ Base: model.Base{ID: clientID},
+ Name: "Test Client",
+ }).Error)
+ require.NoError(t, db.Create(&InteractionSession{
+ Base: model.Base{ID: interactionID},
+ Scopes: datatype.StringList{"openid"},
+ ClientID: clientID,
+ ConsentRequired: true,
+ RequestedAt: datatype.DateTime(time.Now().UTC()),
+ Parameters: map[string]string{},
+ }).Error)
+
+ response, err := service.completeInteractionStep(t.Context(), interactionID, userID, interactionStepConsent, "", time.Now().UTC(), requestMeta{})
+ require.NoError(t, err)
+ require.NotEmpty(t, response.RedirectURL)
+
+ require.Equal(t, []model.AuditLogEvent{model.AuditLogEventNewClientAuthorization}, auditLogger.events)
+ require.Equal(t, model.AuditLogData{"clientName": "Test Client"}, auditLogger.data[0])
+}
+
+func TestAuthorizationServiceAuthorizeConsumesInteractionSession(t *testing.T) {
+ db := testutils.NewDatabaseForTest(t)
+ service := newAuthorizationService(db, newInteractionSessionService(db), newClaimsService(db, nil, "", nil), nil, nil)
+
+ const (
+ userID = "test-user"
+ clientID = "test-client"
+ interactionID = "test-interaction"
+ )
+
+ require.NoError(t, db.Create(&model.User{
+ Base: model.Base{ID: userID},
+ }).Error)
+ require.NoError(t, db.Create(&model.OidcClient{
+ Base: model.Base{ID: clientID},
+ Name: "Test Client",
+ }).Error)
+ require.NoError(t, db.Create(&model.UserAuthorizedOidcClient{
+ UserID: userID,
+ ClientID: clientID,
+ Scope: datatype.StringList{"openid"},
+ }).Error)
+ require.NoError(t, db.Create(&InteractionSession{
+ Base: model.Base{ID: interactionID},
+ Scopes: datatype.StringList{"openid"},
+ ClientID: clientID,
+ RequestedAt: datatype.DateTime(time.Now().UTC()),
+ Parameters: map[string]string{},
+ }).Error)
+
+ authorize := func() (authorizationResult, error) {
+ return service.authorize(t.Context(), authorizeInput{
+ userID: userID,
+ authenticationTime: time.Now().UTC(),
+ requester: newTestAuthorizeRequester("consume-request", clientID, ""),
+ interactionID: interactionID,
+ })
+ }
+
+ authorization, err := authorize()
+ require.NoError(t, err)
+ require.False(t, authorization.RequiresInteraction)
+
+ // The interaction session is consumed together with the grant and must be single-use
+ var count int64
+ require.NoError(t, db.Model(&InteractionSession{}).Where("id = ?", interactionID).Count(&count).Error)
+ require.Zero(t, count)
+
+ _, err = authorize()
+ require.ErrorIs(t, err, fosite.ErrInvalidRequest)
+}
+
+func TestInteractionSessionServiceGetRejectsExpiredSession(t *testing.T) {
+ db := testutils.NewDatabaseForTest(t)
+ service := newInteractionSessionService(db)
+
+ const (
+ clientID = "test-client"
+ interactionID = "test-interaction"
+ )
+
+ require.NoError(t, db.Create(&model.OidcClient{
+ Base: model.Base{ID: clientID},
+ Name: "Test Client",
+ }).Error)
+ require.NoError(t, db.Create(&InteractionSession{
+ Base: model.Base{ID: interactionID},
+ Scopes: datatype.StringList{"openid"},
+ ClientID: clientID,
+ RequestedAt: datatype.DateTime(time.Now().UTC()),
+ Parameters: map[string]string{},
+ }).Error)
+
+ _, err := service.get(t.Context(), interactionID)
+ require.NoError(t, err)
+
+ // Backdate the session past its lifetime; BeforeCreate stamps CreatedAt, so update directly
+ expiredCreatedAt := datatype.DateTime(time.Now().Add(-interactionSessionLifetime - time.Minute))
+ require.NoError(t, db.Model(&InteractionSession{}).Where("id = ?", interactionID).Update("created_at", expiredCreatedAt).Error)
+
+ _, err = service.get(t.Context(), interactionID)
+ require.ErrorIs(t, err, gorm.ErrRecordNotFound)
+}
+
+func TestAuthorizationServiceAuthorizeBindsScopesToInteractionSession(t *testing.T) {
+ db := testutils.NewDatabaseForTest(t)
+ service := newAuthorizationService(db, newInteractionSessionService(db), newClaimsService(db, nil, "", nil), nil, nil)
+
+ const (
+ userID = "test-user"
+ clientID = "test-client"
+ )
+
+ require.NoError(t, db.Create(&model.User{
+ Base: model.Base{ID: userID},
+ }).Error)
+ require.NoError(t, db.Create(&model.OidcClient{
+ Base: model.Base{ID: clientID},
+ Name: "Test Client",
+ }).Error)
+
+ newInteractionSession := func(id string) {
+ require.NoError(t, db.Create(&InteractionSession{
+ Base: model.Base{ID: id},
+ Scopes: datatype.StringList{"openid"},
+ ClientID: clientID,
+ RequestedAt: datatype.DateTime(time.Now().UTC()),
+ Parameters: map[string]string{},
+ }).Error)
+ }
+
+ authorize := func(interactionID string, scopes ...string) (authorizationResult, error) {
+ requester := newTestAuthorizeRequester("scope-binding-request", clientID, "")
+ requester.(*fosite.AuthorizeRequest).RequestedScope = fosite.Arguments(scopes)
+ return service.authorize(t.Context(), authorizeInput{
+ userID: userID,
+ authenticationTime: time.Now().UTC(),
+ requester: requester,
+ interactionID: interactionID,
+ })
+ }
+
+ // Scopes matching the consented interaction session are granted
+ newInteractionSession("matching-interaction")
+ authorization, err := authorize("matching-interaction", "openid")
+ require.NoError(t, err)
+ require.False(t, authorization.RequiresInteraction)
+
+ // Scopes added to the URL after consent must not be granted silently
+ newInteractionSession("escalated-interaction")
+ _, err = authorize("escalated-interaction", "openid", "profile", "email", "groups")
+ require.ErrorIs(t, err, fosite.ErrInvalidRequest)
+
+ // The user must not have been recorded as having authorized the escalated scopes
+ var authorizedClient model.UserAuthorizedOidcClient
+ require.NoError(t, db.First(&authorizedClient, "user_id = ? AND client_id = ?", userID, clientID).Error)
+ require.Equal(t, datatype.StringList{"openid"}, authorizedClient.Scope)
+}
+
+func TestAuthorizationServiceAuthorizeRejectsInteractionSessionOfOtherClient(t *testing.T) {
+ db := testutils.NewDatabaseForTest(t)
+ service := newAuthorizationService(db, newInteractionSessionService(db), newClaimsService(db, nil, "", nil), nil, nil)
+
+ const (
+ userID = "test-user"
+ clientID = "test-client"
+ otherClientID = "other-client"
+ interactionID = "test-interaction"
+ )
+
+ require.NoError(t, db.Create(&model.User{
+ Base: model.Base{ID: userID},
+ }).Error)
+ require.NoError(t, db.Create(&model.OidcClient{
+ Base: model.Base{ID: clientID},
+ Name: "Test Client",
+ }).Error)
+ require.NoError(t, db.Create(&model.OidcClient{
+ Base: model.Base{ID: otherClientID},
+ Name: "Other Client",
+ }).Error)
+ require.NoError(t, db.Create(&InteractionSession{
+ Base: model.Base{ID: interactionID},
+ Scopes: datatype.StringList{"openid"},
+ ClientID: clientID,
+ RequestedAt: datatype.DateTime(time.Now().UTC()),
+ Parameters: map[string]string{},
+ }).Error)
+
+ _, err := service.authorize(t.Context(), authorizeInput{
+ userID: userID,
+ authenticationTime: time.Now().UTC(),
+ requester: newTestAuthorizeRequester("other-client-request", otherClientID, ""),
+ interactionID: interactionID,
+ })
+ require.ErrorIs(t, err, fosite.ErrInvalidRequest)
+}
+
+func TestAuthorizationServiceAuthorizeSwitchesUserAndResetsRequirements(t *testing.T) {
+ db := testutils.NewDatabaseForTest(t)
+ service := newAuthorizationService(db, newInteractionSessionService(db), newClaimsService(db, nil, "", nil), nil, nil)
+
+ const (
+ userID = "test-user"
+ otherUserID = "other-user"
+ clientID = "test-client"
+ interactionID = "test-interaction"
+ )
+
+ require.NoError(t, db.Create(&model.User{
+ Base: model.Base{ID: userID},
+ Username: "test-user",
+ }).Error)
+ require.NoError(t, db.Create(&model.User{
+ Base: model.Base{ID: otherUserID},
+ Username: "other-user",
+ }).Error)
+ require.NoError(t, db.Create(&model.OidcClient{
+ Base: model.Base{ID: clientID},
+ Name: "Test Client",
+ }).Error)
+ require.NoError(t, db.Create(&model.UserAuthorizedOidcClient{
+ UserID: userID,
+ ClientID: clientID,
+ Scope: datatype.StringList{"openid"},
+ }).Error)
+ reauthenticatedAt := datatype.DateTime(time.Now().Add(-time.Minute).UTC())
+ require.NoError(t, db.Create(&InteractionSession{
+ Base: model.Base{ID: interactionID},
+ Scopes: datatype.StringList{"openid"},
+ ClientID: clientID,
+ UserID: stringPointer(userID),
+ ReauthenticatedAt: &reauthenticatedAt,
+ ReauthenticationRequired: false,
+ RequestedAt: datatype.DateTime(time.Now().UTC()),
+ Parameters: map[string]string{
+ "prompt": "login",
+ },
+ }).Error)
+
+ authorization, err := service.authorize(t.Context(), authorizeInput{
+ userID: otherUserID,
+ authenticationTime: time.Now().UTC(),
+ requester: newTestAuthorizeRequester("other-user-request", clientID, ""),
+ interactionID: interactionID,
+ })
+ require.NoError(t, err)
+ require.True(t, authorization.RequiresInteraction)
+ require.Equal(t, interactionID, authorization.InteractionID)
+
+ var interactionSession InteractionSession
+ require.NoError(t, db.First(&interactionSession, "id = ?", interactionID).Error)
+ require.NotNil(t, interactionSession.UserID)
+ require.Equal(t, otherUserID, *interactionSession.UserID)
+ require.True(t, interactionSession.ConsentRequired)
+ require.True(t, interactionSession.ReauthenticationRequired)
+ require.Nil(t, interactionSession.ReauthenticatedAt)
+}
+
+func TestAuthorizationServiceAuthorizeRequiresLoginForUserBoundInteraction(t *testing.T) {
+ db := testutils.NewDatabaseForTest(t)
+ service := newAuthorizationService(db, newInteractionSessionService(db), newClaimsService(db, nil, "", nil), nil, nil)
+
+ const (
+ userID = "test-user"
+ clientID = "test-client"
+ interactionID = "test-interaction"
+ )
+
+ require.NoError(t, db.Create(&model.User{
+ Base: model.Base{ID: userID},
+ }).Error)
+ require.NoError(t, db.Create(&model.OidcClient{
+ Base: model.Base{ID: clientID},
+ Name: "Test Client",
+ }).Error)
+ require.NoError(t, db.Create(&InteractionSession{
+ Base: model.Base{ID: interactionID},
+ Scopes: datatype.StringList{"openid"},
+ ClientID: clientID,
+ UserID: stringPointer(userID),
+ RequestedAt: datatype.DateTime(time.Now().UTC()),
+ Parameters: map[string]string{},
+ }).Error)
+
+ _, err := service.authorize(t.Context(), authorizeInput{
+ requester: newTestAuthorizeRequester("unauthenticated-bound-request", clientID, ""),
+ interactionID: interactionID,
+ })
+ require.ErrorIs(t, err, fosite.ErrLoginRequired)
+}
+
+func TestAuthorizationServiceCompleteInteractionBindsUserToSession(t *testing.T) {
+ db := testutils.NewDatabaseForTest(t)
+ service := newAuthorizationService(db, newInteractionSessionService(db), newClaimsService(db, nil, "", nil), nil, nil)
+
+ const (
+ userID = "test-user"
+ clientID = "test-client"
+ interactionID = "test-interaction"
+ )
+
+ require.NoError(t, db.Create(&model.User{
+ Base: model.Base{ID: userID},
+ }).Error)
+ require.NoError(t, db.Create(&model.OidcClient{
+ Base: model.Base{ID: clientID},
+ Name: "Test Client",
+ }).Error)
+ require.NoError(t, db.Create(&InteractionSession{
+ Base: model.Base{ID: interactionID},
+ Scopes: datatype.StringList{"openid"},
+ ClientID: clientID,
+ ConsentRequired: true,
+ RequestedAt: datatype.DateTime(time.Now().UTC()),
+ Parameters: map[string]string{},
+ }).Error)
+
+ response, err := service.completeInteractionStep(t.Context(), interactionID, userID, interactionStepConsent, "", time.Now().UTC(), requestMeta{})
+ require.NoError(t, err)
+ require.NotEmpty(t, response.RedirectURL)
+
+ var interactionSession InteractionSession
+ require.NoError(t, db.First(&interactionSession, "id = ?", interactionID).Error)
+ require.NotNil(t, interactionSession.UserID)
+ require.Equal(t, userID, *interactionSession.UserID)
+}
+
+func TestAuthorizationServiceCompleteInteractionSwitchesUserAndResetsRequirements(t *testing.T) {
+ db := testutils.NewDatabaseForTest(t)
+ service := newAuthorizationService(db, newInteractionSessionService(db), newClaimsService(db, nil, "", nil), nil, nil)
+
+ const (
+ userID = "test-user"
+ otherUserID = "other-user"
+ clientID = "test-client"
+ interactionID = "test-interaction"
+ )
+
+ require.NoError(t, db.Create(&model.User{
+ Base: model.Base{ID: userID},
+ Username: "test-user",
+ }).Error)
+ require.NoError(t, db.Create(&model.User{
+ Base: model.Base{ID: otherUserID},
+ Username: "other-user",
+ }).Error)
+ require.NoError(t, db.Create(&model.OidcClient{
+ Base: model.Base{ID: clientID},
+ Name: "Test Client",
+ }).Error)
+ require.NoError(t, db.Create(&InteractionSession{
+ Base: model.Base{ID: interactionID},
+ Scopes: datatype.StringList{"openid"},
+ ClientID: clientID,
+ UserID: stringPointer(userID),
+ AccountSelectionRequired: true,
+ RequestedAt: datatype.DateTime(time.Now().UTC()),
+ Parameters: map[string]string{
+ "prompt": "select_account",
+ },
+ }).Error)
+
+ response, err := service.completeInteractionStep(t.Context(), interactionID, otherUserID, interactionStepSelectAccount, "", time.Now().UTC(), requestMeta{})
+ require.NoError(t, err)
+ require.Empty(t, response.RedirectURL)
+ require.NotNil(t, response.Interaction)
+ require.Equal(t, interactionStepConsent, response.Interaction.CurrentStep)
+
+ var interactionSession InteractionSession
+ require.NoError(t, db.First(&interactionSession, "id = ?", interactionID).Error)
+ require.NotNil(t, interactionSession.UserID)
+ require.Equal(t, otherUserID, *interactionSession.UserID)
+ require.False(t, interactionSession.AccountSelectionRequired)
+ require.True(t, interactionSession.ConsentRequired)
+}
+
+// TestAuthorizationServiceSelectAccountRecomputesConsentForSelectedUser ensures consent is
+// evaluated for the FINAL selected user. When an already-consented user starts
+// prompt=select_account and a different, not-yet-consented user is selected, consent must
+// still be required for that user rather than being inherited from the initiator.
+func TestAuthorizationServiceSelectAccountRecomputesConsentForSelectedUser(t *testing.T) {
+ db := testutils.NewDatabaseForTest(t)
+ service := newAuthorizationService(db, newInteractionSessionService(db), newClaimsService(db, nil, "", nil), nil, nil)
+
+ const (
+ initiatorID = "initiator-user"
+ switchedID = "switched-user"
+ clientID = "test-client"
+ )
+
+ require.NoError(t, db.Create(&model.User{Base: model.Base{ID: initiatorID}, Username: "initiator"}).Error)
+ require.NoError(t, db.Create(&model.User{Base: model.Base{ID: switchedID}, Username: "switched"}).Error)
+ require.NoError(t, db.Create(&model.OidcClient{Base: model.Base{ID: clientID}, Name: "Test Client"}).Error)
+
+ // The initiator has previously consented to the client; the switched-to user has not.
+ require.NoError(t, db.Create(&model.UserAuthorizedOidcClient{
+ UserID: initiatorID,
+ ClientID: clientID,
+ Scope: datatype.StringList{"openid"},
+ }).Error)
+
+ authorization, err := service.authorize(t.Context(), authorizeInput{
+ userID: initiatorID,
+ authenticationTime: time.Now().UTC(),
+ requester: newTestAuthorizeRequester("select-account-request", clientID, "select_account"),
+ requestParams: map[string]string{"prompt": "select_account"},
+ })
+ require.NoError(t, err)
+ require.True(t, authorization.RequiresInteraction)
+
+ response, err := service.completeInteractionStep(t.Context(), authorization.InteractionID, switchedID, interactionStepSelectAccount, "", time.Now().UTC(), requestMeta{})
+ require.NoError(t, err)
+
+ // The flow must not be granted yet: consent is still pending for the switched-to user.
+ require.Empty(t, response.RedirectURL)
+ require.NotNil(t, response.Interaction)
+ require.Equal(t, interactionStepConsent, response.Interaction.CurrentStep)
+
+ // No authorization record may have been silently created for the switched-to user.
+ var count int64
+ require.NoError(t, db.Model(&model.UserAuthorizedOidcClient{}).
+ Where("user_id = ? AND client_id = ?", switchedID, clientID).
+ Count(&count).Error)
+ require.Zero(t, count)
+}
+
+func TestValidateClientPKCERequirement(t *testing.T) {
+ pkceClient := Client{OidcClient: model.OidcClient{Base: model.Base{ID: "c"}, PkceEnabled: true}}
+ plainClient := Client{OidcClient: model.OidcClient{Base: model.Base{ID: "c"}}}
+
+ withChallenge := newTestAuthorizeRequesterWithForm("with-challenge", "c", url.Values{"code_challenge": {"abc123"}})
+ withoutChallenge := newTestAuthorizeRequesterWithForm("without-challenge", "c", url.Values{})
+
+ require.NoError(t, validateClientPKCERequirement(plainClient, withoutChallenge))
+ require.NoError(t, validateClientPKCERequirement(pkceClient, withChallenge))
+
+ err := validateClientPKCERequirement(pkceClient, withoutChallenge)
+ require.ErrorIs(t, err, fosite.ErrInvalidRequest)
+}
+
+// TestAuthorizationServiceAuthorizeEnforcesPerClientPKCE proves the per-client PkceEnabled
+// flag is honored for confidential clients (fosite only enforces PKCE for public clients).
+func TestAuthorizationServiceAuthorizeEnforcesPerClientPKCE(t *testing.T) {
+ db := testutils.NewDatabaseForTest(t)
+ service := newAuthorizationService(db, newInteractionSessionService(db), newClaimsService(db, nil, "", nil), nil, nil)
+
+ const (
+ userID = "test-user"
+ clientID = "pkce-client"
+ )
+ require.NoError(t, db.Create(&model.User{Base: model.Base{ID: userID}, Username: "test-user"}).Error)
+ require.NoError(t, db.Create(&model.OidcClient{Base: model.Base{ID: clientID}, Name: "PKCE Client", PkceEnabled: true}).Error)
+ require.NoError(t, db.Create(&model.UserAuthorizedOidcClient{UserID: userID, ClientID: clientID, Scope: datatype.StringList{"openid"}}).Error)
+
+ pkceClient := Client{OidcClient: model.OidcClient{Base: model.Base{ID: clientID}, Name: "PKCE Client", PkceEnabled: true}}
+
+ // Without a code_challenge the request is rejected before any interaction.
+ missing := newTestAuthorizeRequesterWithForm("authz-no-pkce", clientID, url.Values{})
+ missing.(*fosite.AuthorizeRequest).Client = pkceClient
+ _, err := service.authorize(t.Context(), authorizeInput{
+ userID: userID,
+ authenticationTime: time.Now().UTC(),
+ requester: missing,
+ })
+ require.ErrorIs(t, err, fosite.ErrInvalidRequest)
+
+ // With a code_challenge the PKCE gate passes and the (already-consented) request is granted.
+ withChallenge := newTestAuthorizeRequesterWithForm("authz-pkce", clientID, url.Values{"code_challenge": {"abc123"}})
+ withChallenge.(*fosite.AuthorizeRequest).Client = pkceClient
+ result, err := service.authorize(t.Context(), authorizeInput{
+ userID: userID,
+ authenticationTime: time.Now().UTC(),
+ requester: withChallenge,
+ })
+ require.NoError(t, err)
+ require.False(t, result.RequiresInteraction)
+}
+
+func TestAuthorizationServiceAuthorizePARRequiredClient(t *testing.T) {
+ db := testutils.NewDatabaseForTest(t)
+ service := newAuthorizationService(db, newInteractionSessionService(db), newClaimsService(db, nil, "", nil), nil, nil)
+
+ const (
+ userID = "test-user"
+ clientID = "test-client"
+ interactionID = "test-interaction"
+ )
+
+ require.NoError(t, db.Create(&model.User{
+ Base: model.Base{ID: userID},
+ }).Error)
+ require.NoError(t, db.Create(&model.OidcClient{
+ Base: model.Base{ID: clientID},
+ Name: "Test Client",
+ RequiresPushedAuthorizationRequests: true,
+ }).Error)
+ require.NoError(t, db.Create(&model.UserAuthorizedOidcClient{
+ UserID: userID,
+ ClientID: clientID,
+ Scope: datatype.StringList{"openid"},
+ }).Error)
+
+ authorize := func(interactionID string, hasPushedAuthorizationRequest bool) (authorizationResult, error) {
+ requester := newTestAuthorizeRequester("par-request", clientID, "")
+ requester.(*fosite.AuthorizeRequest).Client = Client{
+ OidcClient: model.OidcClient{
+ Base: model.Base{ID: clientID},
+ Name: "Test Client",
+ RequiresPushedAuthorizationRequests: true,
+ },
+ }
+ return service.authorize(t.Context(), authorizeInput{
+ userID: userID,
+ authenticationTime: time.Now().UTC(),
+ requester: requester,
+ hasPushedAuthorizationRequest: hasPushedAuthorizationRequest,
+ interactionID: interactionID,
+ })
+ }
+
+ // Without a pushed authorization request the client must be rejected
+ _, err := authorize("", false)
+ var parRequiredError *common.OidcPARRequiredError
+ require.ErrorAs(t, err, &parRequiredError)
+
+ // Re-entry after a completed interaction carries no request_uri, but the bound
+ // interaction session proves the original request was PAR-validated
+ require.NoError(t, db.Create(&InteractionSession{
+ Base: model.Base{ID: interactionID},
+ Scopes: datatype.StringList{"openid"},
+ ClientID: clientID,
+ RequestedAt: datatype.DateTime(time.Now().UTC()),
+ Parameters: map[string]string{},
+ }).Error)
+
+ authorization, err := authorize(interactionID, false)
+ require.NoError(t, err)
+ require.False(t, authorization.RequiresInteraction)
+
+ // An unknown interaction ID must not satisfy the PAR requirement
+ _, err = authorize("nonexistent", false)
+ require.ErrorIs(t, err, fosite.ErrInvalidRequest)
+}
+
+func TestAuthorizationServiceInteractionRequestQuery(t *testing.T) {
+ db := testutils.NewDatabaseForTest(t)
+ service := newAuthorizationService(db, newInteractionSessionService(db), newClaimsService(db, nil, "", nil), nil, nil)
+
+ const (
+ clientID = "test-client"
+ interactionID = "test-interaction"
+ )
+
+ require.NoError(t, db.Create(&model.OidcClient{
+ Base: model.Base{ID: clientID},
+ Name: "Test Client",
+ }).Error)
+ require.NoError(t, db.Create(&InteractionSession{
+ Base: model.Base{ID: interactionID},
+ Scopes: datatype.StringList{"openid"},
+ ClientID: clientID,
+ RequestedAt: datatype.DateTime(time.Now().UTC()),
+ Parameters: map[string]string{
+ "client_id": clientID,
+ "response_type": "code",
+ "scope": "openid",
+ "redirect_uri": "https://client.example/callback",
+ "state": "test-state",
+ },
+ }).Error)
+
+ query, err := service.interactionRequestQuery(t.Context(), interactionID)
+ require.NoError(t, err)
+ require.Equal(t, clientID, query.Get("client_id"))
+ require.Equal(t, "code", query.Get("response_type"))
+ require.Equal(t, "openid", query.Get("scope"))
+ require.Equal(t, "https://client.example/callback", query.Get("redirect_uri"))
+ require.Equal(t, "test-state", query.Get("state"))
+ require.Equal(t, interactionID, query.Get("interaction"))
+
+ _, err = service.interactionRequestQuery(t.Context(), "nonexistent")
+ require.ErrorIs(t, err, fosite.ErrInvalidRequest)
+}
+
+func TestAuthorizationServiceAuthorizeUsesLoginAuthenticationTime(t *testing.T) {
+ db := testutils.NewDatabaseForTest(t)
+ service := newAuthorizationService(db, newInteractionSessionService(db), newClaimsService(db, nil, "", nil), nil, nil)
+
+ const (
+ userID = "test-user"
+ clientID = "test-client"
+ )
+ loginTime := time.Now().Add(-10 * time.Minute).UTC().Truncate(time.Second)
+
+ require.NoError(t, db.Create(&model.User{
+ Base: model.Base{ID: userID},
+ }).Error)
+ require.NoError(t, db.Create(&model.OidcClient{
+ Base: model.Base{ID: clientID},
+ Name: "Test Client",
+ }).Error)
+ require.NoError(t, db.Create(&model.UserAuthorizedOidcClient{
+ UserID: userID,
+ ClientID: clientID,
+ Scope: datatype.StringList{"openid"},
+ }).Error)
+
+ firstAuthorization, err := service.authorize(t.Context(), authorizeInput{
+ userID: userID,
+ authenticationTime: loginTime,
+ requester: newTestAuthorizeRequester("first-request", clientID, ""),
+ })
+ require.NoError(t, err)
+ require.False(t, firstAuthorization.RequiresInteraction)
+ require.Equal(t, loginTime, firstAuthorization.Session.IDTokenClaims().AuthTime)
+
+ secondAuthorization, err := service.authorize(t.Context(), authorizeInput{
+ userID: userID,
+ authenticationTime: loginTime,
+ requester: newTestAuthorizeRequester("second-request", clientID, "none"),
+ })
+ require.NoError(t, err)
+ require.False(t, secondAuthorization.RequiresInteraction)
+ require.Equal(t, loginTime, secondAuthorization.Session.IDTokenClaims().AuthTime)
+}
+
+func TestAuthorizationServiceAuthorizeRequiresReauthenticationWhenMaxAgeExceeded(t *testing.T) {
+ db := testutils.NewDatabaseForTest(t)
+ service := newAuthorizationService(db, newInteractionSessionService(db), newClaimsService(db, nil, "", nil), nil, nil)
+
+ const (
+ userID = "test-user"
+ clientID = "test-client"
+ )
+ loginTime := time.Now().Add(-2 * time.Minute).UTC()
+
+ require.NoError(t, db.Create(&model.User{
+ Base: model.Base{ID: userID},
+ }).Error)
+ require.NoError(t, db.Create(&model.OidcClient{
+ Base: model.Base{ID: clientID},
+ Name: "Test Client",
+ }).Error)
+ require.NoError(t, db.Create(&model.UserAuthorizedOidcClient{
+ UserID: userID,
+ ClientID: clientID,
+ Scope: datatype.StringList{"openid"},
+ }).Error)
+
+ authorization, err := service.authorize(t.Context(), authorizeInput{
+ userID: userID,
+ authenticationTime: loginTime,
+ requester: newTestAuthorizeRequesterWithForm("max-age-request", clientID, url.Values{"max_age": {"1"}}),
+ requestParams: map[string]string{"max_age": "1"},
+ })
+ require.NoError(t, err)
+ require.True(t, authorization.RequiresInteraction)
+
+ interaction, err := service.getInteractionSession(t.Context(), authorization.InteractionID)
+ require.NoError(t, err)
+ require.Equal(t, interactionStepReauthenticate, interaction.CurrentStep)
+}
+
+func TestAuthorizationServiceAuthorizeUsesCompletedReauthenticationTime(t *testing.T) {
+ db := testutils.NewDatabaseForTest(t)
+ service := newAuthorizationService(db, newInteractionSessionService(db), newClaimsService(db, nil, "", nil), nil, nil)
+
+ const (
+ userID = "test-user"
+ clientID = "test-client"
+ interactionID = "test-interaction"
+ )
+ loginTime := time.Now().Add(-2 * time.Minute).UTC()
+ reauthenticatedAt := time.Now().Add(-1 * time.Second).UTC().Truncate(time.Second)
+
+ require.NoError(t, db.Create(&model.User{
+ Base: model.Base{ID: userID},
+ }).Error)
+ require.NoError(t, db.Create(&model.OidcClient{
+ Base: model.Base{ID: clientID},
+ Name: "Test Client",
+ }).Error)
+ require.NoError(t, db.Create(&model.UserAuthorizedOidcClient{
+ UserID: userID,
+ ClientID: clientID,
+ Scope: datatype.StringList{"openid"},
+ }).Error)
+ reauthenticatedAtValue := datatype.DateTime(reauthenticatedAt)
+ require.NoError(t, db.Create(&InteractionSession{
+ Base: model.Base{ID: interactionID},
+ Scopes: datatype.StringList{"openid"},
+ ClientID: clientID,
+ ReauthenticationRequired: false,
+ ReauthenticatedAt: &reauthenticatedAtValue,
+ Parameters: map[string]string{
+ "max_age": "1",
+ },
+ }).Error)
+
+ authorization, err := service.authorize(t.Context(), authorizeInput{
+ userID: userID,
+ authenticationTime: loginTime,
+ requester: newTestAuthorizeRequesterWithForm("final-request", clientID, url.Values{"max_age": {"1"}}),
+ interactionID: interactionID,
+ requestParams: map[string]string{"max_age": "1"},
+ })
+ require.NoError(t, err)
+ require.False(t, authorization.RequiresInteraction)
+ require.Equal(t, reauthenticatedAt, authorization.Session.IDTokenClaims().AuthTime)
+}
+
+func TestAuthorizationServiceAuthorizeUsesOriginalInteractionRequestTime(t *testing.T) {
+ db := testutils.NewDatabaseForTest(t)
+ service := newAuthorizationService(db, newInteractionSessionService(db), newClaimsService(db, nil, "", nil), nil, nil)
+
+ const (
+ userID = "test-user"
+ clientID = "test-client"
+ interactionID = "test-interaction"
+ )
+ originalRequestedAt := time.Now().Add(-10 * time.Second).UTC().Truncate(time.Second)
+ reauthenticatedAt := originalRequestedAt.Add(5 * time.Second)
+ continuationRequestedAt := reauthenticatedAt.Add(5 * time.Second)
+
+ require.NoError(t, db.Create(&model.User{
+ Base: model.Base{ID: userID},
+ }).Error)
+ require.NoError(t, db.Create(&model.OidcClient{
+ Base: model.Base{ID: clientID},
+ Name: "Test Client",
+ }).Error)
+ require.NoError(t, db.Create(&model.UserAuthorizedOidcClient{
+ UserID: userID,
+ ClientID: clientID,
+ Scope: datatype.StringList{"openid"},
+ }).Error)
+ reauthenticatedAtValue := datatype.DateTime(reauthenticatedAt)
+ require.NoError(t, db.Create(&InteractionSession{
+ Base: model.Base{ID: interactionID},
+ Scopes: datatype.StringList{"openid"},
+ ClientID: clientID,
+ ReauthenticationRequired: false,
+ RequestedAt: datatype.DateTime(originalRequestedAt),
+ ReauthenticatedAt: &reauthenticatedAtValue,
+ Parameters: map[string]string{
+ "prompt": "login",
+ },
+ }).Error)
+
+ requester := newTestAuthorizeRequesterWithForm(
+ "final-request",
+ clientID,
+ url.Values{"prompt": {"login"}},
+ )
+ requester.(*fosite.AuthorizeRequest).RequestedAt = continuationRequestedAt
+
+ authorization, err := service.authorize(t.Context(), authorizeInput{
+ userID: userID,
+ authenticationTime: originalRequestedAt.Add(-time.Minute),
+ requester: requester,
+ interactionID: interactionID,
+ requestParams: map[string]string{"prompt": "login"},
+ })
+ require.NoError(t, err)
+ require.False(t, authorization.RequiresInteraction)
+ require.Equal(t, originalRequestedAt, authorization.Session.IDTokenClaims().RequestedAt)
+ require.Equal(t, reauthenticatedAt, authorization.Session.IDTokenClaims().AuthTime)
+ require.False(t, authorization.Session.IDTokenClaims().AuthTime.Before(authorization.Session.IDTokenClaims().RequestedAt))
+}
+
+func TestInteractionSessionServiceSavePersistsParameters(t *testing.T) {
+ db := testutils.NewDatabaseForTest(t)
+ service := newInteractionSessionService(db)
+
+ const (
+ clientID = "test-client"
+ interactionID = "test-interaction"
+ )
+
+ require.NoError(t, db.Create(&model.OidcClient{
+ Base: model.Base{ID: clientID},
+ Name: "Test Client",
+ }).Error)
+
+ interactionSession, err := service.create(t.Context(), InteractionSession{
+ Base: model.Base{ID: interactionID},
+ Scopes: datatype.StringList{"openid"},
+ ClientID: clientID,
+ ReauthenticationRequired: true,
+ RequestedAt: datatype.DateTime(time.Now().UTC()),
+ Parameters: map[string]string{
+ "max_age": "1",
+ },
+ })
+ require.NoError(t, err)
+
+ reauthenticatedAt := datatype.DateTime(time.Now().UTC().Truncate(time.Second))
+ interactionSession.ReauthenticationRequired = false
+ interactionSession.ReauthenticatedAt = &reauthenticatedAt
+ require.NoError(t, service.update(t.Context(), interactionSession))
+
+ storedInteractionSession, err := service.get(t.Context(), interactionID)
+ require.NoError(t, err)
+ require.False(t, storedInteractionSession.ReauthenticationRequired)
+ require.Equal(t, "1", storedInteractionSession.Parameters["max_age"])
+ require.NotNil(t, storedInteractionSession.ReauthenticatedAt)
+ require.Equal(t, reauthenticatedAt.UTC(), storedInteractionSession.ReauthenticatedAt.UTC())
+}
+
+func newTestAuthorizeRequester(requestID, clientID, prompt string) fosite.AuthorizeRequester {
+ form := url.Values{}
+ if prompt != "" {
+ form.Set("prompt", prompt)
+ }
+ return newTestAuthorizeRequesterWithForm(requestID, clientID, form)
+}
+
+func newTestAuthorizeRequesterWithForm(requestID, clientID string, form url.Values) fosite.AuthorizeRequester {
+ requester := fosite.NewAuthorizeRequest()
+ requester.ID = requestID
+ requester.RequestedAt = time.Now().UTC()
+ requester.Client = Client{
+ OidcClient: model.OidcClient{
+ Base: model.Base{ID: clientID},
+ Name: "Test Client",
+ },
+ }
+ requester.RequestedScope = fosite.Arguments{"openid"}
+ requester.ResponseTypes = fosite.Arguments{"code"}
+ requester.RedirectURI = &url.URL{Scheme: "https", Host: "client.example", Path: "/callback"}
+ requester.Form = form
+ return requester
+}
+
+func stringPointer(value string) *string {
+ return &value
+}
diff --git a/backend/internal/oidc/claims_service.go b/backend/internal/oidc/claims_service.go
new file mode 100644
index 00000000..ef8f2fd7
--- /dev/null
+++ b/backend/internal/oidc/claims_service.go
@@ -0,0 +1,166 @@
+package oidc
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "slices"
+
+ "github.com/ory/fosite"
+ "github.com/pocket-id/pocket-id/backend/internal/common"
+ "github.com/pocket-id/pocket-id/backend/internal/model"
+ "gorm.io/gorm"
+)
+
+const (
+ idTokenType = "id-token"
+)
+
+type ClaimsService struct {
+ db *gorm.DB
+ customClaims CustomClaimSource
+ baseURL string
+ signer TokenSigner
+}
+
+func newClaimsService(db *gorm.DB, customClaims CustomClaimSource, baseURL string, signer TokenSigner) *ClaimsService {
+ return &ClaimsService{
+ db: db,
+ customClaims: customClaims,
+ baseURL: baseURL,
+ signer: signer,
+ }
+}
+
+// ValidateUserAccess re-checks, at token-issuance time, that the user behind a grant is
+// still allowed to obtain tokens for the client.
+func (s *ClaimsService) ValidateUserAccess(ctx context.Context, userID string, client Client) error {
+ // Grants without a resource owner (e.g. client_credentials) carry an empty subject
+ // and have no user to validate.
+ if userID == "" {
+ return nil
+ }
+
+ var user model.User
+ err := dbFromContext(ctx, s.db).
+ Preload("UserGroups").
+ First(&user, "id = ?", userID).
+ Error
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ return fosite.ErrInvalidGrant.WithHint("The user account no longer exists.")
+ }
+ if err != nil {
+ return err
+ }
+
+ if user.Disabled {
+ return fosite.ErrInvalidGrant.WithHint("The user account is disabled.")
+ }
+
+ if !IsUserGroupAllowedToAuthorize(user, client.OidcClient) {
+ return fosite.ErrAccessDenied.WithHint("You are not allowed to access this service.")
+ }
+
+ return nil
+}
+
+// applyIDTokenClaims applies the claims of a user to the ID token claims in the session based on the requested scopes.
+func (s *ClaimsService) applyIDTokenClaims(ctx context.Context, session *Session, scopes fosite.Arguments) error {
+ userID := session.Subject
+ if userID == "" {
+ return nil
+ }
+
+ claims, err := s.GetUserClaims(ctx, userID, scopes)
+ if err != nil {
+ return err
+ }
+
+ // Record the signing algorithm on the ID token header so fosite derives the at_hash/
+ // c_hash digest from it (e.g. RS384 -> SHA-384, ES512 -> SHA-512). Without this the
+ // header is empty and fosite defaults to SHA-256, producing wrong hashes whenever the
+ // signing key is not a 256-bit algorithm. ToMap() strips "alg" before signing, so this
+ // never overrides the real JWS header. The signer is always wired in production; it is
+ // only nil in unit tests that do not assert hash correctness.
+ if s.signer != nil {
+ alg, err := s.signer.GetKeyAlg()
+ if err != nil {
+ return err
+ }
+ session.IDTokenHeaders().Add("alg", alg.String())
+ }
+
+ applyUserClaimsToIDToken(session, userID, claims)
+ return nil
+}
+
+func applyUserClaimsToIDToken(session *Session, userID string, claims map[string]any) {
+ idTokenClaims := session.IDTokenClaims()
+ idTokenClaims.Subject = userID
+ idTokenClaims.Extra = claims
+ idTokenClaims.Extra[common.TokenTypeClaim] = idTokenType
+ if session.AuthenticationMethod != "" {
+ idTokenClaims.AuthenticationMethodsReferences = []string{session.AuthenticationMethod}
+ }
+}
+
+// GetUserClaims retrieves the claims for a user based on the requested scopes. It includes standard claims
+// like "sub" and "email" as well as any custom claims defined for the user or their groups.
+func (s *ClaimsService) GetUserClaims(ctx context.Context, userID string, scopes []string) (map[string]any, error) {
+ db := dbFromContext(ctx, s.db)
+
+ var user model.User
+ err := db.
+ Preload("UserGroups").
+ First(&user, "id = ?", userID).
+ Error
+ if err != nil {
+ return nil, err
+ }
+
+ claims := make(map[string]any, 10)
+
+ if slices.Contains(scopes, "profile") {
+ customClaims, err := s.customClaims.GetCustomClaimsForUserWithUserGroups(ctx, user.ID, db)
+ if err != nil {
+ return nil, err
+ }
+
+ for _, customClaim := range customClaims {
+ // A custom claim value can be a JSON document or a plain string
+ var jsonValue any
+ if err := json.Unmarshal([]byte(customClaim.Value), &jsonValue); err == nil {
+ claims[customClaim.Key] = jsonValue
+ } else {
+ claims[customClaim.Key] = customClaim.Value
+ }
+ }
+
+ claims["given_name"] = user.FirstName
+ claims["family_name"] = user.LastName
+ claims["name"] = user.FullName()
+ claims["display_name"] = user.DisplayName
+ claims["preferred_username"] = user.Username
+ claims["picture"] = s.baseURL + "/api/users/" + user.ID + "/profile-picture.png"
+ }
+
+ claims["sub"] = user.ID
+
+ // Only release the email claims when the user actually has an email. Emitting
+ // email_verified alongside a null/absent email (OIDC Core §5.1) is malformed and can
+ // mislead relying parties that key trust decisions on email_verified.
+ if slices.Contains(scopes, "email") && user.Email != nil && *user.Email != "" {
+ claims["email"] = *user.Email
+ claims["email_verified"] = user.EmailVerified
+ }
+
+ if slices.Contains(scopes, "groups") {
+ userGroups := make([]string, len(user.UserGroups))
+ for i, group := range user.UserGroups {
+ userGroups[i] = group.Name
+ }
+ claims["groups"] = userGroups
+ }
+
+ return claims, nil
+}
diff --git a/backend/internal/oidc/claims_service_test.go b/backend/internal/oidc/claims_service_test.go
new file mode 100644
index 00000000..418fa630
--- /dev/null
+++ b/backend/internal/oidc/claims_service_test.go
@@ -0,0 +1,173 @@
+package oidc
+
+import (
+ "context"
+ "testing"
+
+ "github.com/lestrrat-go/jwx/v3/jwa"
+ "github.com/ory/fosite"
+ "github.com/stretchr/testify/require"
+ "gorm.io/gorm"
+
+ "github.com/pocket-id/pocket-id/backend/internal/model"
+ testutils "github.com/pocket-id/pocket-id/backend/internal/utils/testing"
+)
+
+// TestClaimsServiceValidateUserAccess covers the per-grant re-validation that the token
+// endpoint performs on every grant (notably refresh_token, which fosite replays without
+// reloading the user). A disabled user, a user removed from a group-restricted client, or
+// a deleted user must be rejected so they cannot keep minting tokens from a still-valid
+// refresh token.
+func TestClaimsServiceValidateUserAccess(t *testing.T) {
+ db := testutils.NewDatabaseForTest(t)
+ claimsService := newClaimsService(db, nil, "", nil)
+
+ group := model.UserGroup{Base: model.Base{ID: "group-allowed"}, Name: "allowed", FriendlyName: "Allowed"}
+ require.NoError(t, db.Create(&group).Error)
+
+ enabledUser := model.User{Base: model.Base{ID: "user-enabled"}, Username: "enabled"}
+ require.NoError(t, db.Create(&enabledUser).Error)
+ require.NoError(t, db.Model(&enabledUser).Association("UserGroups").Append(&group))
+
+ disabledUser := model.User{Base: model.Base{ID: "user-disabled"}, Username: "disabled", Disabled: true}
+ require.NoError(t, db.Create(&disabledUser).Error)
+
+ outsiderUser := model.User{Base: model.Base{ID: "user-outsider"}, Username: "outsider"}
+ require.NoError(t, db.Create(&outsiderUser).Error)
+
+ openClient := Client{OidcClient: model.OidcClient{Base: model.Base{ID: "client-open"}, Name: "Open"}}
+ restrictedClient := Client{OidcClient: model.OidcClient{
+ Base: model.Base{ID: "client-restricted"},
+ Name: "Restricted",
+ IsGroupRestricted: true,
+ AllowedUserGroups: []model.UserGroup{group},
+ }}
+
+ t.Run("empty subject is allowed (client_credentials)", func(t *testing.T) {
+ require.NoError(t, claimsService.ValidateUserAccess(t.Context(), "", openClient))
+ })
+
+ t.Run("enabled user is allowed", func(t *testing.T) {
+ require.NoError(t, claimsService.ValidateUserAccess(t.Context(), enabledUser.ID, openClient))
+ })
+
+ t.Run("disabled user is rejected with invalid_grant", func(t *testing.T) {
+ err := claimsService.ValidateUserAccess(t.Context(), disabledUser.ID, openClient)
+ require.ErrorIs(t, err, fosite.ErrInvalidGrant)
+ })
+
+ t.Run("user in an allowed group may use a group-restricted client", func(t *testing.T) {
+ require.NoError(t, claimsService.ValidateUserAccess(t.Context(), enabledUser.ID, restrictedClient))
+ })
+
+ t.Run("user outside the allowed groups is rejected with access_denied", func(t *testing.T) {
+ err := claimsService.ValidateUserAccess(t.Context(), outsiderUser.ID, restrictedClient)
+ require.ErrorIs(t, err, fosite.ErrAccessDenied)
+ })
+
+ t.Run("deleted user is rejected with invalid_grant", func(t *testing.T) {
+ err := claimsService.ValidateUserAccess(t.Context(), "does-not-exist", openClient)
+ require.ErrorIs(t, err, fosite.ErrInvalidGrant)
+ })
+}
+
+type fakeCustomClaimSource struct {
+ claims []model.CustomClaim
+}
+
+func (f fakeCustomClaimSource) GetCustomClaimsForUserWithUserGroups(_ context.Context, _ string, _ *gorm.DB) ([]model.CustomClaim, error) {
+ return f.claims, nil
+}
+
+// TestClaimsServiceGetUserClaims pins the scope-to-claims mapping that powers both the ID
+// token and the userinfo endpoint: each OIDC scope must only release its own claims, "sub"
+// is always present, and custom claims are emitted as parsed JSON when the stored value is
+// valid JSON and as a raw string otherwise.
+func TestClaimsServiceGetUserClaims(t *testing.T) {
+ db := testutils.NewDatabaseForTest(t)
+ const (
+ baseURL = "https://id.example.com"
+ userID = "user-1"
+ )
+
+ customClaims := fakeCustomClaimSource{claims: []model.CustomClaim{
+ {Key: "department", Value: "engineering"}, // plain string
+ {Key: "roles", Value: `["admin","dev"]`}, // JSON document
+ }}
+ service := newClaimsService(db, customClaims, baseURL, nil)
+
+ group := model.UserGroup{Base: model.Base{ID: "group-1"}, Name: "developers", FriendlyName: "Developers"}
+ require.NoError(t, db.Create(&group).Error)
+
+ user := model.User{
+ Base: model.Base{ID: userID},
+ Username: "tim",
+ FirstName: "Tim",
+ LastName: "Cook",
+ DisplayName: "Tim Cook",
+ Email: stringPointer("tim@example.com"),
+ EmailVerified: true,
+ }
+ require.NoError(t, db.Create(&user).Error)
+ require.NoError(t, db.Model(&user).Association("UserGroups").Append(&group))
+
+ t.Run("openid only releases sub", func(t *testing.T) {
+ claims, err := service.GetUserClaims(t.Context(), userID, []string{"openid"})
+ require.NoError(t, err)
+ require.Equal(t, map[string]any{"sub": userID}, claims)
+ })
+
+ t.Run("email scope releases email claims", func(t *testing.T) {
+ claims, err := service.GetUserClaims(t.Context(), userID, []string{"openid", "email"})
+ require.NoError(t, err)
+ require.Equal(t, userID, claims["sub"])
+ require.Equal(t, "tim@example.com", claims["email"])
+ require.Equal(t, true, claims["email_verified"])
+ require.NotContains(t, claims, "given_name")
+ require.NotContains(t, claims, "groups")
+ })
+
+ t.Run("groups scope releases group names", func(t *testing.T) {
+ claims, err := service.GetUserClaims(t.Context(), userID, []string{"groups"})
+ require.NoError(t, err)
+ require.Equal(t, []string{"developers"}, claims["groups"])
+ })
+
+ t.Run("profile scope releases profile and custom claims", func(t *testing.T) {
+ claims, err := service.GetUserClaims(t.Context(), userID, []string{"profile"})
+ require.NoError(t, err)
+ require.Equal(t, "Tim", claims["given_name"])
+ require.Equal(t, "Cook", claims["family_name"])
+ require.Equal(t, "Tim Cook", claims["name"])
+ require.Equal(t, "Tim Cook", claims["display_name"])
+ require.Equal(t, "tim", claims["preferred_username"])
+ require.Equal(t, baseURL+"/api/users/"+userID+"/profile-picture.png", claims["picture"])
+
+ // Custom claims: plain string stays a string, JSON document is decoded.
+ require.Equal(t, "engineering", claims["department"])
+ require.Equal(t, []any{"admin", "dev"}, claims["roles"])
+
+ // Profile must not leak email when the email scope was not requested.
+ require.NotContains(t, claims, "email")
+ })
+}
+
+// TestClaimsServiceAppliesSigningAlgToIDTokenHeader verifies the ID token header carries the
+// signing algorithm so fosite derives the at_hash/c_hash digest from it (e.g. RS384 ->
+// SHA-384, ES512 -> SHA-512) instead of always defaulting to SHA-256.
+func TestClaimsServiceAppliesSigningAlgToIDTokenHeader(t *testing.T) {
+ db := testutils.NewDatabaseForTest(t)
+ require.NoError(t, db.Create(&model.User{Base: model.Base{ID: "alg-user"}, Username: "alg"}).Error)
+
+ for _, alg := range []jwa.SignatureAlgorithm{jwa.RS256(), jwa.RS384(), jwa.ES512()} {
+ t.Run(alg.String(), func(t *testing.T) {
+ service := newClaimsService(db, nil, "", algTestSigner{alg: alg})
+
+ session := NewEmptySession()
+ session.Subject = "alg-user"
+
+ require.NoError(t, service.applyIDTokenClaims(t.Context(), session, fosite.Arguments{"openid"}))
+ require.Equal(t, alg.String(), session.IDTokenHeaders().Get("alg"))
+ })
+ }
+}
diff --git a/backend/internal/oidc/cleanup.go b/backend/internal/oidc/cleanup.go
new file mode 100644
index 00000000..064a6429
--- /dev/null
+++ b/backend/internal/oidc/cleanup.go
@@ -0,0 +1,37 @@
+package oidc
+
+import (
+ "context"
+ "time"
+
+ datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
+ "gorm.io/gorm"
+)
+
+// CleanupExpiredOAuth2Sessions deletes OAuth2 sessions whose tokens or codes have
+// expired.
+//
+// Invalidated-but-unexpired rows are intentionally KEPT until their original expiry: fosite relies on
+// finding the inactive row to detect a reuse and revoke the affected token family.
+func CleanupExpiredOAuth2Sessions(ctx context.Context, db *gorm.DB) (int64, error) {
+ st := db.
+ WithContext(ctx).
+ Delete(&OAuth2Session{}, "expires_at < ?", datatype.DateTime(time.Now()))
+ return st.RowsAffected, st.Error
+}
+
+// CleanupExpiredClientAssertionJTIs deletes expired JWT IDs used for client assertion replay protection.
+func CleanupExpiredClientAssertionJTIs(ctx context.Context, db *gorm.DB) (int64, error) {
+ st := db.
+ WithContext(ctx).
+ Delete(&clientAssertionJTI{}, "expires_at < ?", datatype.DateTime(time.Now()))
+ return st.RowsAffected, st.Error
+}
+
+// CleanupAbandonedInteractionSessions removes interaction sessions that were never completed.
+func CleanupAbandonedInteractionSessions(ctx context.Context, db *gorm.DB) (int64, error) {
+ st := db.
+ WithContext(ctx).
+ Delete(&InteractionSession{}, "created_at < ?", datatype.DateTime(time.Now().Add(-interactionSessionLifetime)))
+ return st.RowsAffected, st.Error
+}
diff --git a/backend/internal/oidc/cleanup_test.go b/backend/internal/oidc/cleanup_test.go
new file mode 100644
index 00000000..1e367907
--- /dev/null
+++ b/backend/internal/oidc/cleanup_test.go
@@ -0,0 +1,40 @@
+package oidc
+
+import (
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/require"
+
+ "github.com/pocket-id/pocket-id/backend/internal/model"
+ datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
+ testutils "github.com/pocket-id/pocket-id/backend/internal/utils/testing"
+)
+
+// TestCleanupExpiredOAuth2SessionsKeepsInvalidatedButUnexpiredSessions verifies that
+// rotated/consumed (active=false) sessions are kept until their original expiry, so fosite
+// can still detect refresh-token reuse and revoke the affected token family. Only rows
+// past their expiry are removed.
+func TestCleanupExpiredOAuth2SessionsKeepsInvalidatedButUnexpiredSessions(t *testing.T) {
+ db := testutils.NewDatabaseForTest(t)
+
+ past := datatype.DateTime(time.Now().Add(-time.Hour))
+ future := datatype.DateTime(time.Now().Add(time.Hour))
+
+ rows := []OAuth2Session{
+ {Base: model.Base{ID: "expired"}, Kind: "access_token", Key: "k-expired", RequestID: "r1", Active: true, RequestData: "{}", ExpiresAt: &past},
+ {Base: model.Base{ID: "rotated"}, Kind: "refresh_token", Key: "k-rotated", RequestID: "r2", Active: false, RequestData: "{}", ExpiresAt: &future},
+ {Base: model.Base{ID: "active"}, Kind: "refresh_token", Key: "k-active", RequestID: "r3", Active: true, RequestData: "{}", ExpiresAt: &future},
+ }
+ for i := range rows {
+ require.NoError(t, db.Create(&rows[i]).Error)
+ }
+
+ deleted, err := CleanupExpiredOAuth2Sessions(t.Context(), db)
+ require.NoError(t, err)
+ require.Equal(t, int64(1), deleted)
+
+ var remaining []string
+ require.NoError(t, db.Model(&OAuth2Session{}).Pluck("id", &remaining).Error)
+ require.ElementsMatch(t, []string{"active", "rotated"}, remaining)
+}
diff --git a/backend/internal/oidc/client.go b/backend/internal/oidc/client.go
new file mode 100644
index 00000000..3623e238
--- /dev/null
+++ b/backend/internal/oidc/client.go
@@ -0,0 +1,61 @@
+package oidc
+
+import (
+ "github.com/ory/fosite"
+ "github.com/pocket-id/pocket-id/backend/internal/model"
+)
+
+var _ fosite.Client = (*Client)(nil)
+var _ fosite.ResponseModeClient = (*Client)(nil)
+
+type Client struct {
+ model.OidcClient
+}
+
+func (c Client) GetID() string {
+ return c.ID
+}
+
+func (c Client) GetHashedSecret() []byte {
+ return []byte(c.Secret)
+}
+
+func (c Client) GetRedirectURIs() []string {
+ return c.CallbackURLs
+}
+
+func (c Client) GetGrantTypes() fosite.Arguments {
+ grantTypes := fosite.Arguments{
+ string(fosite.GrantTypeAuthorizationCode),
+ string(fosite.GrantTypeRefreshToken),
+ string(fosite.GrantTypeDeviceCode),
+ }
+ if !c.IsPublic() {
+ grantTypes = append(grantTypes, string(fosite.GrantTypeClientCredentials))
+ }
+ return grantTypes
+}
+
+func (c Client) GetResponseTypes() fosite.Arguments {
+ return fosite.Arguments{"code"}
+}
+
+func (c Client) GetScopes() fosite.Arguments {
+ return fosite.Arguments{"openid", "profile", "email", "groups"}
+}
+
+func (c Client) IsPublic() bool {
+ return c.OidcClient.IsPublic
+}
+
+func (c Client) GetAudience() fosite.Arguments {
+ return fosite.Arguments{c.ID}
+}
+
+func (c Client) GetResponseModes() []fosite.ResponseModeType {
+ return []fosite.ResponseModeType{
+ fosite.ResponseModeQuery,
+ fosite.ResponseModeFragment,
+ fosite.ResponseModeFormPost,
+ }
+}
diff --git a/backend/internal/oidc/device_handler.go b/backend/internal/oidc/device_handler.go
new file mode 100644
index 00000000..38ac6620
--- /dev/null
+++ b/backend/internal/oidc/device_handler.go
@@ -0,0 +1,87 @@
+package oidc
+
+import (
+ "errors"
+ "log/slog"
+ "net/http"
+ "time"
+
+ "github.com/gin-gonic/gin"
+ "github.com/ory/fosite"
+ "github.com/pocket-id/pocket-id/backend/internal/common"
+ "github.com/pocket-id/pocket-id/backend/internal/utils/cookie"
+)
+
+type deviceHandler struct {
+ provider fosite.OAuth2Provider
+ deviceService *deviceService
+}
+
+func newDeviceHandler(provider fosite.OAuth2Provider, deviceService *deviceService) *deviceHandler {
+ return &deviceHandler{
+ provider: provider,
+ deviceService: deviceService,
+ }
+}
+
+func (h *deviceHandler) authorizeDevice(c *gin.Context) {
+ ctx := c.Request.Context()
+
+ response, request, err := h.deviceService.createDeviceAuthorization(ctx, c.Request)
+ if err != nil {
+ slog.ErrorContext(ctx, "Failed to create device authorization", "error", err)
+ h.provider.WriteAccessError(ctx, c.Writer, request, err)
+ return
+ }
+
+ c.Header("Cache-Control", "no-store")
+ c.JSON(http.StatusOK, response)
+}
+
+func (h *deviceHandler) verifyDeviceCode(c *gin.Context) {
+ authenticationTime, _ := c.Get("authenticationTime")
+ typedAuthenticationTime, _ := authenticationTime.(time.Time)
+ reauthenticationToken, _ := c.Cookie(cookie.ReauthenticationTokenCookieName)
+
+ userCode := c.Query("code")
+ if userCode == "" {
+ _ = c.Error(&common.ValidationError{Message: "code is required"})
+ return
+ }
+
+ err := h.deviceService.acceptDeviceCode(
+ c.Request.Context(),
+ userCode,
+ c.GetString("userID"),
+ c.GetString("authenticationMethod"),
+ typedAuthenticationTime,
+ reauthenticationToken,
+ requestMetaFromGin(c),
+ )
+ if err != nil {
+ if errors.Is(err, fosite.ErrAccessDenied) {
+ c.JSON(http.StatusForbidden, gin.H{"error": "You're not allowed to access this service."})
+ return
+ }
+ _ = c.Error(err)
+ return
+ }
+
+ c.Status(http.StatusNoContent)
+}
+
+func (h *deviceHandler) deviceCodeInfo(c *gin.Context) {
+ userCode := c.Query("code")
+ if userCode == "" {
+ _ = c.Error(&common.ValidationError{Message: "code is required"})
+ return
+ }
+
+ deviceCodeInfo, err := h.deviceService.getDeviceCodeInfo(c.Request.Context(), userCode, c.GetString("userID"))
+ if err != nil {
+ _ = c.Error(err)
+ return
+ }
+
+ c.JSON(http.StatusOK, deviceCodeInfo)
+}
diff --git a/backend/internal/oidc/device_service.go b/backend/internal/oidc/device_service.go
new file mode 100644
index 00000000..29661203
--- /dev/null
+++ b/backend/internal/oidc/device_service.go
@@ -0,0 +1,200 @@
+package oidc
+
+import (
+ "context"
+ "errors"
+ "net/http"
+ "time"
+
+ "github.com/ory/fosite"
+ "github.com/ory/fosite/handler/rfc8628"
+ "github.com/pocket-id/pocket-id/backend/internal/common"
+ "github.com/pocket-id/pocket-id/backend/internal/dto"
+ "github.com/pocket-id/pocket-id/backend/internal/model"
+ "gorm.io/gorm"
+)
+
+type deviceService struct {
+ provider fosite.OAuth2Provider
+ store *Store
+ userCodeStrategy rfc8628.UserCodeStrategy
+ authorizationService *authorizationService
+ claimsService *ClaimsService
+ auditLog AuditLogger
+ db *gorm.DB
+}
+
+func newDeviceService(
+ provider fosite.OAuth2Provider,
+ store *Store,
+ userCodeStrategy rfc8628.UserCodeStrategy,
+ authorizationService *authorizationService,
+ claimsService *ClaimsService,
+ auditLog AuditLogger,
+ db *gorm.DB,
+) *deviceService {
+ return &deviceService{
+ provider: provider,
+ store: store,
+ userCodeStrategy: userCodeStrategy,
+ authorizationService: authorizationService,
+ claimsService: claimsService,
+ auditLog: auditLog,
+ db: db,
+ }
+}
+
+func (s *deviceService) createDeviceAuthorization(ctx context.Context, req *http.Request) (*dto.OidcDeviceAuthorizationResponseDto, fosite.Requester, error) {
+ request, err := s.provider.NewDeviceRequest(ctx, req)
+ if err != nil {
+ return nil, request, err
+ }
+
+ session := NewEmptySession()
+ response, err := s.provider.NewDeviceResponse(ctx, request, session)
+ if err != nil {
+ return nil, request, err
+ }
+
+ return &dto.OidcDeviceAuthorizationResponseDto{
+ DeviceCode: response.GetDeviceCode(),
+ UserCode: response.GetUserCode(),
+ VerificationURI: response.GetVerificationURI(),
+ VerificationURIComplete: response.GetVerificationURIComplete(),
+ ExpiresIn: int(response.GetExpiresIn()),
+ Interval: response.GetInterval(),
+ }, request, nil
+}
+
+func (s *deviceService) acceptDeviceCode(ctx context.Context, userCode, userID, authenticationMethod string, authenticationTime time.Time, reauthenticationToken string, meta requestMeta) error {
+ request, userCodeSignature, err := s.deviceRequestFromUserCode(ctx, userCode)
+ if err != nil {
+ return err
+ }
+
+ // A user code may be approved only once. Rejecting an already-approved code prevents a second
+ // logged-in user from rebinding a pending device authorization to themselves before the device
+ // polls for its token.
+ if request.GetUserCodeState() != fosite.UserCodeUnused {
+ return &common.OidcInvalidDeviceCodeError{}
+ }
+
+ client := request.GetClient().(Client)
+ var user model.User
+ if err = s.db.WithContext(ctx).Preload("UserGroups").First(&user, "id = ?", userID).Error; err != nil {
+ return err
+ }
+ if !IsUserGroupAllowedToAuthorize(user, client.OidcClient) {
+ return fosite.ErrAccessDenied.WithHint("You are not allowed to access this service.")
+ }
+
+ for _, scope := range request.GetRequestedScopes() {
+ request.GrantScope(scope)
+ }
+ for _, audience := range request.GetRequestedAudience() {
+ request.GrantAudience(audience)
+ }
+
+ return withTx(ctx, s.db, func(ctx context.Context) error {
+ if client.RequiresReauthentication {
+ if reauthenticationToken == "" || s.authorizationService == nil || s.authorizationService.reauth == nil {
+ return &common.ReauthenticationRequiredError{}
+ }
+
+ reauthenticatedAt, err := s.authorizationService.reauth.ConsumeReauthenticationToken(ctx, dbFromContext(ctx, s.db), reauthenticationToken, userID)
+ if err != nil {
+ return err
+ }
+ authenticationTime = reauthenticatedAt
+ }
+ if authenticationTime.IsZero() {
+ authenticationTime = time.Now().UTC()
+ }
+
+ session := NewAuthenticatedSession(userID, authenticationMethod, authenticationTime, request.GetRequestedAt())
+
+ if err = s.claimsService.applyIDTokenClaims(ctx, session, request.GetGrantedScopes()); err != nil {
+ return err
+ }
+ request.SetSession(session)
+
+ hasAlreadyAuthorizedClient, err := s.authorizationService.consent(ctx, userID, client.GetID(), request.GetRequestedScopes())
+ if err != nil {
+ return err
+ }
+
+ event := model.AuditLogEventDeviceCodeAuthorization
+ if !hasAlreadyAuthorizedClient {
+ event = model.AuditLogEventNewDeviceCodeAuthorization
+ }
+ s.auditLog.Create(ctx, event, meta.IPAddress, meta.UserAgent, userID, model.AuditLogData{"clientName": client.Name}, dbFromContext(ctx, s.db))
+
+ deviceCodeSignature, err := s.store.AcceptDeviceCodeSessionByUserCodeSignature(ctx, userCodeSignature, request)
+ if err != nil {
+ return err
+ }
+
+ if request.GetGrantedScopes().Has("openid") {
+ if err := s.store.CreateOpenIDConnectSession(ctx, deviceCodeSignature, request); err != nil {
+ return err
+ }
+ }
+
+ return nil
+ })
+}
+
+func (s *deviceService) getDeviceCodeInfo(ctx context.Context, userCode, userID string) (*dto.DeviceCodeInfoDto, error) {
+ request, _, err := s.deviceRequestFromUserCode(ctx, userCode)
+ if err != nil {
+ return nil, err
+ }
+
+ client := request.GetClient().(Client)
+ authorizationRequired := true
+ if userID != "" {
+ hasAuthorizedClient, err := s.authorizationService.hasAuthorizedClient(ctx, client.GetID(), userID, request.GetRequestedScopes())
+ if err != nil {
+ return nil, err
+ }
+ authorizationRequired = !hasAuthorizedClient
+ }
+
+ return &dto.DeviceCodeInfoDto{
+ Client: dto.OidcClientMetaDataDto{
+ ID: client.ID,
+ Name: client.Name,
+ HasLogo: client.HasLogo(),
+ HasDarkLogo: client.HasDarkLogo(),
+ LaunchURL: client.LaunchURL,
+ RequiresReauthentication: client.RequiresReauthentication,
+ },
+ Scope: request.GetRequestedScopes(),
+ AuthorizationRequired: authorizationRequired,
+ ReauthenticationRequired: client.RequiresReauthentication,
+ }, nil
+}
+
+func (s *deviceService) deviceRequestFromUserCode(ctx context.Context, userCode string) (fosite.DeviceRequester, string, error) {
+ userCodeSignature, err := s.userCodeStrategy.UserCodeSignature(ctx, userCode)
+ if err != nil {
+ return nil, "", err
+ }
+
+ request, err := s.store.GetDeviceCodeSessionByUserCodeSignature(ctx, userCodeSignature)
+ if errors.Is(err, fosite.ErrNotFound) {
+ return nil, "", &common.OidcInvalidDeviceCodeError{}
+ }
+ if err != nil {
+ return nil, "", err
+ }
+
+ if err = s.userCodeStrategy.ValidateUserCode(ctx, request, userCode); err != nil {
+ if errors.Is(err, fosite.ErrDeviceExpiredToken) {
+ return nil, "", &common.OidcDeviceCodeExpiredError{}
+ }
+ return nil, "", err
+ }
+
+ return request, userCodeSignature, nil
+}
diff --git a/backend/internal/oidc/device_service_test.go b/backend/internal/oidc/device_service_test.go
new file mode 100644
index 00000000..989301c3
--- /dev/null
+++ b/backend/internal/oidc/device_service_test.go
@@ -0,0 +1,122 @@
+package oidc
+
+import (
+ "context"
+ "crypto/rand"
+ "crypto/rsa"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/pocket-id/pocket-id/backend/internal/common"
+ "github.com/pocket-id/pocket-id/backend/internal/model"
+ testutils "github.com/pocket-id/pocket-id/backend/internal/utils/testing"
+ "github.com/stretchr/testify/require"
+ "gorm.io/gorm"
+)
+
+type fakeReauthenticationConsumer struct {
+ token string
+ userID string
+ reauthenticatedAt time.Time
+ calls int
+}
+
+func (f *fakeReauthenticationConsumer) ConsumeReauthenticationToken(_ context.Context, _ *gorm.DB, token string, userID string) (time.Time, error) {
+ f.calls++
+ if token != f.token || userID != f.userID {
+ return time.Time{}, &common.ReauthenticationRequiredError{}
+ }
+
+ return f.reauthenticatedAt, nil
+}
+
+func TestDeviceServiceAcceptRequiresReauthenticationTokenWhenClientRequiresIt(t *testing.T) {
+ const (
+ userID = "test-user"
+ clientID = "test-client"
+ )
+ reauth := &fakeReauthenticationConsumer{ //nolint:gosec // test fixture token, not a real credential
+ token: "valid-reauth-token",
+ userID: userID,
+ reauthenticatedAt: time.Now().UTC().Truncate(time.Second),
+ }
+ service, _, _, userCode, _ := newTestDeviceServiceWithCode(t, clientID, userID, true, reauth)
+
+ err := service.acceptDeviceCode(t.Context(), userCode, userID, "phr", time.Now().UTC(), "", requestMeta{})
+ require.ErrorAs(t, err, new(*common.ReauthenticationRequiredError))
+ require.Zero(t, reauth.calls)
+
+ info, err := service.getDeviceCodeInfo(t.Context(), userCode, userID)
+ require.NoError(t, err)
+ require.True(t, info.ReauthenticationRequired)
+
+ err = service.acceptDeviceCode(t.Context(), userCode, userID, "phr", time.Now().UTC(), reauth.token, requestMeta{})
+ require.NoError(t, err)
+ require.Equal(t, 1, reauth.calls)
+}
+
+func TestDeviceServiceAcceptUsesReauthenticationTimeForDeviceSession(t *testing.T) {
+ const (
+ userID = "test-user"
+ clientID = "test-client"
+ )
+ reauthenticatedAt := time.Now().Add(-30 * time.Second).UTC().Truncate(time.Second)
+ reauth := &fakeReauthenticationConsumer{ //nolint:gosec // test fixture token, not a real credential
+ token: "valid-reauth-token",
+ userID: userID,
+ reauthenticatedAt: reauthenticatedAt,
+ }
+ service, store, provider, userCode, deviceCode := newTestDeviceServiceWithCode(t, clientID, userID, true, reauth)
+
+ err := service.acceptDeviceCode(t.Context(), userCode, userID, "phr", time.Now().Add(-time.Hour).UTC(), reauth.token, requestMeta{})
+ require.NoError(t, err)
+
+ deviceCodeSignature, err := provider.deviceStrategy.DeviceCodeSignature(t.Context(), deviceCode)
+ require.NoError(t, err)
+ acceptedRequest, err := store.GetDeviceCodeSession(t.Context(), deviceCodeSignature, NewEmptySession())
+ require.NoError(t, err)
+ session := acceptedRequest.GetSession().(*Session)
+ require.Equal(t, reauthenticatedAt, session.IDTokenClaims().AuthTime)
+}
+
+func newTestDeviceServiceWithCode(t *testing.T, clientID, userID string, requiresReauthentication bool, reauth ReauthenticationTokenConsumer) (*deviceService, *Store, *oidcProvider, string, string) {
+ t.Helper()
+
+ db := testutils.NewDatabaseForTest(t)
+ require.NoError(t, db.Create(&model.User{Base: model.Base{ID: userID}}).Error)
+ require.NoError(t, db.Create(&model.OidcClient{
+ Base: model.Base{ID: clientID},
+ Name: "Test Client",
+ IsPublic: true,
+ RequiresReauthentication: requiresReauthentication,
+ }).Error)
+
+ store := NewStore(db)
+ signerKey, err := rsa.GenerateKey(rand.Reader, 2048)
+ require.NoError(t, err)
+ provider, err := newProvider(store, nil, testTokenSigner{key: signerKey}, Config{ //nolint:gosec // static test-only provider secret
+ BaseURL: "https://issuer.example.com",
+ TokenBaseURL: "https://issuer.example.com",
+ Secret: "test-secret",
+ })
+ require.NoError(t, err)
+
+ claimsService := newClaimsService(db, nil, "", nil)
+ authorizationService := newAuthorizationService(db, newInteractionSessionService(db), claimsService, reauth, &fakeAuditLogger{})
+ service := newDeviceService(provider, store, provider.deviceStrategy, authorizationService, claimsService, &fakeAuditLogger{}, db)
+
+ form := url.Values{
+ "client_id": {clientID},
+ "scope": {"openid"},
+ }
+ req := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/api/oidc/device/authorize", strings.NewReader(form.Encode()))
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ response, _, err := service.createDeviceAuthorization(t.Context(), req)
+ require.NoError(t, err)
+
+ return service, store, provider, response.UserCode, response.DeviceCode
+}
diff --git a/backend/internal/oidc/end_session_handler.go b/backend/internal/oidc/end_session_handler.go
new file mode 100644
index 00000000..185378a7
--- /dev/null
+++ b/backend/internal/oidc/end_session_handler.go
@@ -0,0 +1,58 @@
+package oidc
+
+import (
+ "log/slog"
+ "net/http"
+
+ "github.com/gin-gonic/gin"
+ "github.com/pocket-id/pocket-id/backend/internal/dto"
+ "github.com/pocket-id/pocket-id/backend/internal/utils/cookie"
+)
+
+type endSessionHandler struct {
+ endSessionService *endSessionService
+ baseURL string
+}
+
+func newEndSessionHandler(endSessionService *endSessionService, baseURL string) *endSessionHandler {
+ return &endSessionHandler{
+ endSessionService: endSessionService,
+ baseURL: baseURL,
+ }
+}
+
+func (h *endSessionHandler) endSession(c *gin.Context) {
+ input, err := bindEndSessionRequest(c)
+ if err != nil {
+ _ = c.Error(err)
+ return
+ }
+
+ callbackURL, err := h.endSessionService.endSession(c.Request.Context(), input, c.GetString("userID"))
+ if err != nil {
+ slog.WarnContext(c.Request.Context(), "Error getting logout callback URL, the user has to confirm the logout manually", "error", err)
+ c.Redirect(http.StatusFound, h.baseURL+"/logout")
+ return
+ }
+
+ cookie.AddAccessTokenCookie(c, 0, "")
+ if callbackURL == "" {
+ c.Redirect(http.StatusFound, h.baseURL+"/logout")
+ return
+ }
+
+ c.Redirect(http.StatusFound, appendStateToURL(callbackURL, input.State))
+}
+
+func bindEndSessionRequest(c *gin.Context) (dto.OidcLogoutDto, error) {
+ var input dto.OidcLogoutDto
+
+ switch c.Request.Method {
+ case http.MethodGet:
+ return input, c.ShouldBindQuery(&input)
+ case http.MethodPost:
+ return input, c.ShouldBind(&input)
+ default:
+ return input, nil
+ }
+}
diff --git a/backend/internal/oidc/end_session_service.go b/backend/internal/oidc/end_session_service.go
new file mode 100644
index 00000000..7dde1844
--- /dev/null
+++ b/backend/internal/oidc/end_session_service.go
@@ -0,0 +1,153 @@
+package oidc
+
+import (
+ "context"
+ "errors"
+ "net/url"
+ "time"
+
+ "github.com/lestrrat-go/jwx/v3/jwt"
+ "github.com/pocket-id/pocket-id/backend/internal/common"
+ "github.com/pocket-id/pocket-id/backend/internal/dto"
+ "github.com/pocket-id/pocket-id/backend/internal/model"
+ "github.com/pocket-id/pocket-id/backend/internal/utils"
+ "gorm.io/gorm"
+)
+
+type endSessionService struct {
+ db *gorm.DB
+ store *Store
+ signer TokenSigner
+ baseURL string
+}
+
+func newEndSessionService(db *gorm.DB, store *Store, signer TokenSigner, baseURL string) *endSessionService {
+ return &endSessionService{
+ db: db,
+ store: store,
+ signer: signer,
+ baseURL: baseURL,
+ }
+}
+
+// endSession revokes the sessions belonging to the ID token hint and returns the
+// client's post-logout callback URL (empty if none is configured).
+func (s *endSessionService) endSession(ctx context.Context, input dto.OidcLogoutDto, userID string) (string, error) {
+ if input.IdTokenHint == "" {
+ return "", &common.TokenInvalidError{}
+ }
+
+ token, err := s.verifyIDTokenHint(input.IdTokenHint)
+ if err != nil {
+ return "", &common.TokenInvalidError{}
+ }
+
+ clientIDs, ok := token.Audience()
+ if !ok || len(clientIDs) == 0 {
+ return "", &common.TokenInvalidError{}
+ }
+ clientID := clientIDs[0]
+ if input.ClientId != "" && clientID != input.ClientId {
+ return "", &common.OidcClientIdNotMatchingError{}
+ }
+
+ subject, ok := token.Subject()
+ if !ok || subject == "" {
+ return "", &common.TokenInvalidError{}
+ }
+ if userID != "" && subject != userID {
+ return "", &common.TokenInvalidError{}
+ }
+ userID = subject
+
+ idTokenJTI, ok := token.JwtID()
+ if !ok {
+ return "", &common.TokenInvalidError{}
+ }
+
+ var callbackURL string
+ err = withTx(ctx, s.db, func(ctx context.Context) error {
+ var authorizedClient model.UserAuthorizedOidcClient
+ err := dbFromContext(ctx, s.db).
+ Preload("Client").
+ First(&authorizedClient, "client_id = ? AND user_id = ?", clientID, userID).
+ Error
+ if err != nil {
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ return &common.OidcMissingAuthorizationError{}
+ }
+ return err
+ }
+
+ callbackURL, err = logoutCallbackURL(&authorizedClient.Client, input.PostLogoutRedirectUri)
+ if err != nil {
+ return err
+ }
+
+ return s.store.RevokeSessionsByIDTokenHint(ctx, userID, clientID, idTokenJTI)
+ })
+ if err != nil {
+ return "", err
+ }
+
+ return callbackURL, nil
+}
+
+func (s *endSessionService) verifyIDTokenHint(tokenString string) (jwt.Token, error) {
+ alg, err := s.signer.GetKeyAlg()
+ if err != nil {
+ return nil, err
+ }
+
+ token, err := jwt.ParseString(
+ tokenString,
+ jwt.WithValidate(true),
+ jwt.WithKey(alg, s.signer.GetPrivateKey()),
+ jwt.WithAcceptableSkew(time.Minute),
+ jwt.WithResetValidators(true),
+ jwt.WithIssuer(s.baseURL),
+ jwt.WithValidator(jwt.IsIssuedAtValid()),
+ jwt.WithValidator(jwt.IsNbfValid()),
+ )
+ if err != nil {
+ return nil, err
+ }
+
+ // id_token_hint must be an ID token, never an access token (both are signed with the same
+ // key). An expired ID token is still accepted here, as required by OIDC RP-Initiated Logout.
+ var tokenType string
+ if err := token.Get(common.TokenTypeClaim, &tokenType); err != nil || tokenType != idTokenType {
+ return nil, &common.TokenInvalidError{}
+ }
+
+ return token, nil
+}
+
+func logoutCallbackURL(client *model.OidcClient, inputLogoutCallbackURL string) (string, error) {
+ if len(client.LogoutCallbackURLs) == 0 {
+ return "", nil
+ }
+ if inputLogoutCallbackURL == "" {
+ return client.LogoutCallbackURLs[0], nil
+ }
+
+ matched, err := utils.GetCallbackURLFromList(client.LogoutCallbackURLs, inputLogoutCallbackURL)
+ if err != nil || matched == "" {
+ return "", &common.OidcInvalidCallbackURLError{}
+ }
+
+ return matched, nil
+}
+
+func appendStateToURL(callbackURL string, state string) string {
+ parsed, err := url.Parse(callbackURL)
+ if err != nil {
+ return callbackURL
+ }
+ if state != "" {
+ q := parsed.Query()
+ q.Set("state", state)
+ parsed.RawQuery = q.Encode()
+ }
+ return parsed.String()
+}
diff --git a/backend/internal/oidc/end_session_service_test.go b/backend/internal/oidc/end_session_service_test.go
new file mode 100644
index 00000000..3342fe8b
--- /dev/null
+++ b/backend/internal/oidc/end_session_service_test.go
@@ -0,0 +1,307 @@
+package oidc
+
+import (
+ "crypto/rand"
+ "crypto/rsa"
+ "net/url"
+ "testing"
+ "time"
+
+ "github.com/lestrrat-go/jwx/v3/jwa"
+ "github.com/lestrrat-go/jwx/v3/jwt"
+ "github.com/stretchr/testify/require"
+
+ "github.com/pocket-id/pocket-id/backend/internal/common"
+ "github.com/pocket-id/pocket-id/backend/internal/dto"
+ "github.com/pocket-id/pocket-id/backend/internal/model"
+ testutils "github.com/pocket-id/pocket-id/backend/internal/utils/testing"
+)
+
+// TestLogoutCallbackURL locks the post-logout redirect resolution that replaced the
+// deleted callback_url_util helper. An attacker-supplied post_logout_redirect_uri must
+// only be honored when it exactly matches one the client registered, otherwise logout
+// would be an open redirect.
+func TestLogoutCallbackURL(t *testing.T) {
+ noURLs := &model.OidcClient{Base: model.Base{ID: "c"}}
+ withURLs := &model.OidcClient{Base: model.Base{ID: "c"}, LogoutCallbackURLs: model.UrlList{
+ "https://app.example/logout",
+ "https://app.example/logout2",
+ "https://*.example/logout",
+ }}
+
+ t.Run("no configured logout URLs yields no callback", func(t *testing.T) {
+ got, err := logoutCallbackURL(noURLs, "")
+ require.NoError(t, err)
+ require.Empty(t, got)
+ })
+
+ t.Run("empty input falls back to first registered URL", func(t *testing.T) {
+ got, err := logoutCallbackURL(withURLs, "")
+ require.NoError(t, err)
+ require.Equal(t, "https://app.example/logout", got)
+ })
+
+ t.Run("exact match is honored", func(t *testing.T) {
+ got, err := logoutCallbackURL(withURLs, "https://app.example/logout2")
+ require.NoError(t, err)
+ require.Equal(t, "https://app.example/logout2", got)
+ })
+
+ t.Run("wildcard match is honored with the requested URL", func(t *testing.T) {
+ got, err := logoutCallbackURL(withURLs, "https://tenant.example/logout")
+ require.NoError(t, err)
+ require.Equal(t, "https://tenant.example/logout", got)
+ })
+
+ t.Run("unregistered URL is rejected (no open redirect)", func(t *testing.T) {
+ _, err := logoutCallbackURL(withURLs, "https://evil.example/steal")
+ var target *common.OidcInvalidCallbackURLError
+ require.ErrorAs(t, err, &target)
+ })
+}
+
+func TestAppendStateToURL(t *testing.T) {
+ require.Equal(t, "https://app.example/cb", appendStateToURL("https://app.example/cb", ""))
+ require.Equal(t, "https://app.example/cb?state=xyz", appendStateToURL("https://app.example/cb", "xyz"))
+
+ got := appendStateToURL("https://app.example/cb?foo=bar", "xyz")
+ parsed, err := url.Parse(got)
+ require.NoError(t, err)
+ require.Equal(t, "bar", parsed.Query().Get("foo"))
+ require.Equal(t, "xyz", parsed.Query().Get("state"))
+}
+
+// TestEndSessionService exercises the RP-initiated logout validation and the session
+// revocation it triggers. The ID token hint must be a valid, first-party token whose
+// subject identifies the logged-out user and whose audience matches the client; only then
+// is the client's registered post-logout URL returned and the user/client sessions revoked.
+func TestEndSessionService(t *testing.T) {
+ const (
+ baseURL = "https://issuer.example.com"
+ userID = "user-1"
+ clientID = "client-1"
+ jti = "id-token-jti"
+ )
+
+ key, err := rsa.GenerateKey(rand.Reader, 2048)
+ require.NoError(t, err)
+ signer := testTokenSigner{key: key}
+
+ type tokenOptions struct {
+ issuer string
+ subject string
+ audience string
+ jti string
+ omitSubject bool
+ omitJTI bool
+ omitAud bool
+ omitType bool
+ }
+ signToken := func(t *testing.T, opts tokenOptions) string {
+ t.Helper()
+ builder := jwt.NewBuilder().IssuedAt(time.Now())
+ if opts.issuer != "" {
+ builder = builder.Issuer(opts.issuer)
+ }
+ if !opts.omitSubject {
+ builder = builder.Subject(opts.subject)
+ }
+ if !opts.omitAud {
+ builder = builder.Audience([]string{opts.audience})
+ }
+ if !opts.omitJTI {
+ builder = builder.JwtID(opts.jti)
+ }
+ if !opts.omitType {
+ builder = builder.Claim(common.TokenTypeClaim, idTokenType)
+ }
+ token, err := builder.Build()
+ require.NoError(t, err)
+ signed, err := jwt.Sign(token, jwt.WithKey(jwa.RS256(), key))
+ require.NoError(t, err)
+ return string(signed)
+ }
+
+ newService := func(t *testing.T) (*endSessionService, *Store) {
+ t.Helper()
+ db := testutils.NewDatabaseForTest(t)
+ require.NoError(t, db.Create(&model.OidcClient{
+ Base: model.Base{ID: clientID},
+ Name: "Test Client",
+ LogoutCallbackURLs: model.UrlList{"https://app.example/logout"},
+ }).Error)
+ require.NoError(t, db.Create(&model.User{Base: model.Base{ID: userID}, Username: "tim"}).Error)
+ require.NoError(t, db.Create(&model.UserAuthorizedOidcClient{UserID: userID, ClientID: clientID}).Error)
+ store := NewStore(db)
+ return newEndSessionService(db, store, signer, baseURL), store
+ }
+
+ validToken := tokenOptions{issuer: baseURL, subject: userID, audience: clientID, jti: jti}
+
+ t.Run("missing id_token_hint is rejected", func(t *testing.T) {
+ service, _ := newService(t)
+ _, err := service.endSession(t.Context(), dto.OidcLogoutDto{}, userID)
+ var target *common.TokenInvalidError
+ require.ErrorAs(t, err, &target)
+ })
+
+ t.Run("malformed id_token_hint is rejected", func(t *testing.T) {
+ service, _ := newService(t)
+ _, err := service.endSession(t.Context(), dto.OidcLogoutDto{IdTokenHint: "not-a-jwt"}, userID)
+ var target *common.TokenInvalidError
+ require.ErrorAs(t, err, &target)
+ })
+
+ t.Run("token signed by a foreign key is rejected", func(t *testing.T) {
+ service, _ := newService(t)
+ otherKey, err := rsa.GenerateKey(rand.Reader, 2048)
+ require.NoError(t, err)
+ token, err := jwt.NewBuilder().
+ Issuer(baseURL).Subject(userID).Audience([]string{clientID}).JwtID(jti).IssuedAt(time.Now()).Build()
+ require.NoError(t, err)
+ signed, err := jwt.Sign(token, jwt.WithKey(jwa.RS256(), otherKey))
+ require.NoError(t, err)
+
+ _, err = service.endSession(t.Context(), dto.OidcLogoutDto{IdTokenHint: string(signed)}, userID)
+ var target *common.TokenInvalidError
+ require.ErrorAs(t, err, &target)
+ })
+
+ t.Run("non-ID token (missing type claim) is rejected as id_token_hint", func(t *testing.T) {
+ // A first-party JWT access token of the same user/client (signed with the same key) must
+ // not be accepted as an id_token_hint; only genuine ID tokens carry the type claim.
+ service, _ := newService(t)
+ token := signToken(t, tokenOptions{issuer: baseURL, subject: userID, audience: clientID, jti: jti, omitType: true})
+ _, err := service.endSession(t.Context(), dto.OidcLogoutDto{IdTokenHint: token}, userID)
+ var target *common.TokenInvalidError
+ require.ErrorAs(t, err, &target)
+ })
+
+ t.Run("subject not matching the logged-in user is rejected", func(t *testing.T) {
+ service, _ := newService(t)
+ token := signToken(t, tokenOptions{issuer: baseURL, subject: "someone-else", audience: clientID, jti: jti})
+ _, err := service.endSession(t.Context(), dto.OidcLogoutDto{IdTokenHint: token}, userID)
+ var target *common.TokenInvalidError
+ require.ErrorAs(t, err, &target)
+ })
+
+ t.Run("client_id parameter not matching the token audience is rejected", func(t *testing.T) {
+ service, _ := newService(t)
+ token := signToken(t, validToken)
+ _, err := service.endSession(t.Context(), dto.OidcLogoutDto{IdTokenHint: token, ClientId: "different-client"}, userID)
+ var target *common.OidcClientIdNotMatchingError
+ require.ErrorAs(t, err, &target)
+ })
+
+ t.Run("user that never authorized the client is rejected", func(t *testing.T) {
+ service, _ := newService(t)
+ // A valid token for a user that has no authorization record for the client.
+ token := signToken(t, tokenOptions{issuer: baseURL, subject: "ghost-user", audience: clientID, jti: jti})
+ _, err := service.endSession(t.Context(), dto.OidcLogoutDto{IdTokenHint: token}, "ghost-user")
+ var target *common.OidcMissingAuthorizationError
+ require.ErrorAs(t, err, &target)
+ })
+
+ t.Run("unregistered post_logout_redirect_uri is rejected", func(t *testing.T) {
+ service, _ := newService(t)
+ token := signToken(t, validToken)
+ _, err := service.endSession(t.Context(), dto.OidcLogoutDto{
+ IdTokenHint: token,
+ PostLogoutRedirectUri: "https://evil.example/steal",
+ }, userID)
+ var target *common.OidcInvalidCallbackURLError
+ require.ErrorAs(t, err, &target)
+ })
+
+ t.Run("valid logout returns the callback URL and revokes the sessions", func(t *testing.T) {
+ service, store := newService(t)
+
+ // Seed an active refresh/access token pair tied to this user, client and ID token.
+ require.NoError(t, store.CreateRefreshTokenSession(t.Context(), "rt-sig", "at-sig", newTestRequester("logout-req", clientID, userID, jti)))
+ require.NoError(t, store.CreateAccessTokenSession(t.Context(), "at-sig", newTestRequester("logout-req", clientID, userID, jti)))
+
+ token := signToken(t, validToken)
+ callback, err := service.endSession(t.Context(), dto.OidcLogoutDto{IdTokenHint: token}, userID)
+ require.NoError(t, err)
+ require.Equal(t, "https://app.example/logout", callback)
+
+ var refresh OAuth2Session
+ require.NoError(t, service.db.First(&refresh, "kind = ? AND key = ?", sessionKindRefreshToken, "rt-sig").Error)
+ require.False(t, refresh.Active, "refresh token must be revoked on logout")
+
+ var accessCount int64
+ require.NoError(t, service.db.Model(&OAuth2Session{}).
+ Where("kind = ? AND key = ?", sessionKindAccessToken, "at-sig").
+ Count(&accessCount).Error)
+ require.Zero(t, accessCount, "access token must be deleted on logout")
+ })
+
+ t.Run("valid logout revokes only the session matching the id_token_hint", func(t *testing.T) {
+ service, store := newService(t)
+
+ require.NoError(t, store.CreateRefreshTokenSession(t.Context(), "rt-matching", "at-matching", newTestRequester("matching-req", clientID, userID, jti)))
+ require.NoError(t, store.CreateAccessTokenSession(t.Context(), "at-matching", newTestRequester("matching-req", clientID, userID, jti)))
+ require.NoError(t, store.CreateRefreshTokenSession(t.Context(), "rt-other", "at-other", newTestRequester("other-req", clientID, userID, "other-id-token-jti")))
+ require.NoError(t, store.CreateAccessTokenSession(t.Context(), "at-other", newTestRequester("other-req", clientID, userID, "other-id-token-jti")))
+
+ token := signToken(t, validToken)
+ callback, err := service.endSession(t.Context(), dto.OidcLogoutDto{IdTokenHint: token}, userID)
+ require.NoError(t, err)
+ require.Equal(t, "https://app.example/logout", callback)
+
+ var matchingRefresh OAuth2Session
+ require.NoError(t, service.db.First(&matchingRefresh, "kind = ? AND key = ?", sessionKindRefreshToken, "rt-matching").Error)
+ require.False(t, matchingRefresh.Active, "refresh token matching id_token_hint must be revoked on logout")
+
+ var otherRefresh OAuth2Session
+ require.NoError(t, service.db.First(&otherRefresh, "kind = ? AND key = ?", sessionKindRefreshToken, "rt-other").Error)
+ require.True(t, otherRefresh.Active, "unrelated refresh token for the same user/client must remain active")
+
+ var matchingAccessCount int64
+ require.NoError(t, service.db.Model(&OAuth2Session{}).
+ Where("kind = ? AND key = ?", sessionKindAccessToken, "at-matching").
+ Count(&matchingAccessCount).Error)
+ require.Zero(t, matchingAccessCount, "access token matching id_token_hint must be deleted on logout")
+
+ var otherAccessCount int64
+ require.NoError(t, service.db.Model(&OAuth2Session{}).
+ Where("kind = ? AND key = ?", sessionKindAccessToken, "at-other").
+ Count(&otherAccessCount).Error)
+ require.Equal(t, int64(1), otherAccessCount, "unrelated access token for the same user/client must remain active")
+ })
+
+ t.Run("valid logout without UI session derives the user from id_token_hint", func(t *testing.T) {
+ service, store := newService(t)
+
+ // The OP browser session is absent, but the ID token hint still identifies the
+ // End-User and RP session that should be logged out.
+ require.NoError(t, store.CreateRefreshTokenSession(t.Context(), "rt-no-session", "at-no-session", newTestRequester("logout-req", clientID, userID, jti)))
+ require.NoError(t, store.CreateAccessTokenSession(t.Context(), "at-no-session", newTestRequester("logout-req", clientID, userID, jti)))
+
+ token := signToken(t, validToken)
+ callback, err := service.endSession(t.Context(), dto.OidcLogoutDto{IdTokenHint: token}, "")
+ require.NoError(t, err)
+ require.Equal(t, "https://app.example/logout", callback)
+
+ var refresh OAuth2Session
+ require.NoError(t, service.db.First(&refresh, "kind = ? AND key = ?", sessionKindRefreshToken, "rt-no-session").Error)
+ require.False(t, refresh.Active, "refresh token must be revoked on logout")
+
+ var accessCount int64
+ require.NoError(t, service.db.Model(&OAuth2Session{}).
+ Where("kind = ? AND key = ?", sessionKindAccessToken, "at-no-session").
+ Count(&accessCount).Error)
+ require.Zero(t, accessCount, "access token must be deleted on logout")
+ })
+
+ t.Run("valid logout honors a registered post_logout_redirect_uri", func(t *testing.T) {
+ service, _ := newService(t)
+ token := signToken(t, validToken)
+ callback, err := service.endSession(t.Context(), dto.OidcLogoutDto{
+ IdTokenHint: token,
+ PostLogoutRedirectUri: "https://app.example/logout",
+ }, userID)
+ require.NoError(t, err)
+ require.Equal(t, "https://app.example/logout", callback)
+ })
+}
diff --git a/backend/internal/oidc/federated_client_auth.go b/backend/internal/oidc/federated_client_auth.go
new file mode 100644
index 00000000..22d5e05b
--- /dev/null
+++ b/backend/internal/oidc/federated_client_auth.go
@@ -0,0 +1,228 @@
+package oidc
+
+import (
+ "context"
+ "crypto/tls"
+ "errors"
+ "fmt"
+ "log/slog"
+ "net/http"
+ "net/url"
+ "strings"
+ "time"
+
+ "github.com/lestrrat-go/httprc/v3"
+ "github.com/lestrrat-go/httprc/v3/errsink"
+ "github.com/lestrrat-go/jwx/v3/jwk"
+ "github.com/lestrrat-go/jwx/v3/jws"
+ "github.com/lestrrat-go/jwx/v3/jwt"
+ "github.com/ory/fosite"
+)
+
+const clientAssertionTypeJWTBearer = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer" //nolint:gosec
+
+var errNoFederatedClientAssertion = errors.New("no federated client assertion")
+
+// federatedClientStore is the subset of the store the federated authenticator needs.
+type federatedClientStore interface {
+ GetClient(ctx context.Context, id string) (fosite.Client, error)
+ ClientAssertionJWTValid(ctx context.Context, jti string) error
+ SetClientAssertionJWT(ctx context.Context, jti string, exp time.Time) error
+}
+
+// federatedClientAuthenticator authenticates clients via JWT bearer assertions issued
+// by a federated identity provider configured per client.
+type federatedClientAuthenticator struct {
+ clients federatedClientStore
+ httpClient *http.Client
+ jwksCache *jwk.Cache
+ defaultAudience string
+}
+
+func newFederatedClientAuthenticator(ctx context.Context, clients federatedClientStore, httpClient *http.Client, defaultAudience string) (*federatedClientAuthenticator, error) {
+ authenticator := &federatedClientAuthenticator{
+ clients: clients,
+ httpClient: httpClient,
+ defaultAudience: defaultAudience,
+ }
+
+ jwksCache, err := authenticator.getJWKCache(ctx)
+ if err != nil {
+ return nil, err
+ }
+ authenticator.jwksCache = jwksCache
+
+ return authenticator, nil
+}
+
+func (a *federatedClientAuthenticator) getJWKCache(ctx context.Context) (*jwk.Cache, error) {
+ // We need to create a custom HTTP client to set a timeout.
+ client := a.httpClient
+ if client == nil {
+ client = &http.Client{
+ Timeout: 10 * time.Second,
+ }
+
+ defaultTransport, ok := http.DefaultTransport.(*http.Transport)
+ if !ok {
+ // Indicates a development-time error
+ panic("Default transport is not of type *http.Transport")
+ }
+ transport := defaultTransport.Clone()
+ transport.TLSClientConfig.MinVersion = tls.VersionTLS12
+ client.Transport = transport
+ }
+
+ return jwk.NewCache(ctx,
+ httprc.NewClient(
+ httprc.WithErrorSink(errsink.NewSlog(slog.Default())),
+ httprc.WithHTTPClient(client),
+ ),
+ )
+}
+
+// newClientAuthenticationStrategy accepts federated client assertions before falling
+// back to fosite's default client authentication.
+func newClientAuthenticationStrategy(authenticator *federatedClientAuthenticator, provider *fosite.Fosite) fosite.ClientAuthenticationStrategy {
+ return func(ctx context.Context, r *http.Request, form url.Values) (fosite.Client, error) {
+ client, err := authenticator.authenticateForm(ctx, form)
+ if err == nil {
+ return client, nil
+ }
+ if !errors.Is(err, errNoFederatedClientAssertion) {
+ return nil, err
+ }
+
+ return provider.DefaultClientAuthenticationStrategy(ctx, r, form)
+ }
+}
+
+// authenticateForm returns errNoFederatedClientAssertion when the form carries no
+// federated assertion, so the caller can fall back to other authentication methods.
+func (a *federatedClientAuthenticator) authenticateForm(ctx context.Context, form url.Values) (fosite.Client, error) {
+ if form.Get("client_assertion_type") != clientAssertionTypeJWTBearer || form.Get("client_assertion") == "" {
+ return nil, errNoFederatedClientAssertion
+ }
+
+ return a.authenticateAssertion(ctx, form.Get("client_assertion"), form.Get("client_id"))
+}
+
+// authenticateAssertion validates the assertion JWT against the client's configured
+// federated identity. An empty clientID falls back to the assertion's subject.
+func (a *federatedClientAuthenticator) authenticateAssertion(ctx context.Context, assertion string, clientID string) (fosite.Client, error) {
+ rawAssertion := []byte(assertion)
+ insecureToken, err := jwt.ParseInsecure(rawAssertion)
+ if err != nil {
+ return nil, fosite.ErrInvalidClient.WithHint("Invalid client assertion.").WithWrap(err)
+ }
+
+ issuer, _ := insecureToken.Issuer()
+ if issuer == "" {
+ return nil, fosite.ErrInvalidClient.WithHint("Client assertion is missing issuer.")
+ }
+
+ if clientID == "" {
+ clientID, _ = insecureToken.Subject()
+ }
+ if clientID == "" {
+ return nil, fosite.ErrInvalidClient.WithHint("Client assertion is missing subject.")
+ }
+
+ client, err := a.clients.GetClient(ctx, clientID)
+ if err != nil {
+ return nil, fosite.ErrInvalidClient.WithWrap(err)
+ }
+
+ oidcClient, ok := client.(Client)
+ if !ok {
+ return nil, errNoFederatedClientAssertion
+ }
+
+ federatedIdentity, ok := oidcClient.Credentials.FederatedIdentityForIssuer(issuer)
+ if !ok {
+ return nil, errNoFederatedClientAssertion
+ }
+
+ jwksURL := federatedIdentity.JWKS
+ if jwksURL == "" {
+ jwksURL = strings.TrimRight(issuer, "/") + "/.well-known/jwks.json"
+ }
+
+ jwks, err := a.fetchJWKSet(ctx, jwksURL)
+ if err != nil {
+ return nil, fosite.ErrInvalidClient.WithHint("Unable to fetch client assertion JWKS.").WithWrap(err)
+ }
+
+ audience := federatedIdentity.Audience
+ if audience == "" {
+ audience = a.defaultAudience
+ }
+ subject := federatedIdentity.Subject
+ if subject == "" {
+ subject = client.GetID()
+ }
+
+ parsed, err := jwt.Parse(rawAssertion,
+ jwt.WithValidate(true),
+ jwt.WithAcceptableSkew(30*time.Second),
+ jwt.WithRequiredClaim(jwt.ExpirationKey),
+ jwt.WithIssuer(issuer),
+ jwt.WithSubject(subject),
+ jwt.WithAudience(audience),
+ jwt.WithKeySet(jwks, jws.WithInferAlgorithmFromKey(true), jws.WithUseDefault(true)),
+ )
+ if err != nil {
+ return nil, fosite.ErrInvalidClient.WithHint("Invalid client assertion.").WithWrap(err)
+ }
+
+ if federatedIdentity.ReplayProtection {
+ jti, ok := parsed.JwtID()
+ if !ok || jti == "" {
+ return nil, fosite.ErrInvalidClient.WithHint("Client assertion is missing jti claim, which is required for replay protection.")
+ }
+
+ // Check if the jti has been used before
+ if err := a.clients.ClientAssertionJWTValid(ctx, jti); err != nil {
+ return nil, fosite.ErrInvalidClient.WithHint("Client assertion has already been used.").WithWrap(err)
+ }
+ // Store the jti to prevent future reuse
+ exp, _ := parsed.Expiration()
+ if err := a.clients.SetClientAssertionJWT(ctx, jti, exp); err != nil {
+ return nil, fosite.ErrInvalidClient.WithWrap(err)
+ }
+
+ }
+
+ return client, nil
+}
+
+func (a *federatedClientAuthenticator) fetchJWKSet(ctx context.Context, jwksURL string) (jwk.Set, error) {
+ if !a.jwksCache.IsRegistered(ctx, jwksURL) {
+ // We set a timeout because otherwise Register will keep trying in case of errors
+ registerCtx, registerCancel := context.WithTimeout(ctx, 15*time.Second)
+ defer registerCancel()
+
+ registerOptions := []jwk.RegisterOption{
+ jwk.WithMaxInterval(24 * time.Hour),
+ jwk.WithMinInterval(15 * time.Minute),
+ jwk.WithWaitReady(true),
+ }
+ if a.httpClient != nil {
+ registerOptions = append(registerOptions, jwk.WithHTTPClient(a.httpClient))
+ }
+
+ // We need to register the URL
+ err := a.jwksCache.Register(registerCtx, jwksURL, registerOptions...)
+ // In case of race conditions (two goroutines calling jwkCache.Register at the same time), it's possible we can get a conflict anyways, so we ignore that error
+ if err != nil && !errors.Is(err, httprc.ErrResourceAlreadyExists()) {
+ return nil, fmt.Errorf("failed to register JWK set: %w", err)
+ }
+ }
+
+ jwks, err := a.jwksCache.CachedSet(jwksURL)
+ if err != nil {
+ return nil, fmt.Errorf("failed to get cached JWK set: %w", err)
+ }
+
+ return jwks, nil
+}
diff --git a/backend/internal/oidc/federated_client_auth_test.go b/backend/internal/oidc/federated_client_auth_test.go
new file mode 100644
index 00000000..9ad529d7
--- /dev/null
+++ b/backend/internal/oidc/federated_client_auth_test.go
@@ -0,0 +1,310 @@
+package oidc
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "io"
+ "net/http"
+ "strings"
+ "sync/atomic"
+ "testing"
+ "time"
+
+ "github.com/lestrrat-go/jwx/v3/jwa"
+ "github.com/lestrrat-go/jwx/v3/jwk"
+ "github.com/lestrrat-go/jwx/v3/jwt"
+ "github.com/ory/fosite"
+ "github.com/stretchr/testify/require"
+
+ "github.com/pocket-id/pocket-id/backend/internal/model"
+ jwkutils "github.com/pocket-id/pocket-id/backend/internal/utils/jwk"
+)
+
+type fakeFederatedStore struct {
+ client fosite.Client
+ jtis map[string]time.Time
+}
+
+func (f *fakeFederatedStore) GetClient(_ context.Context, id string) (fosite.Client, error) {
+ if f.client == nil || f.client.GetID() != id {
+ return nil, fosite.ErrNotFound
+ }
+ return f.client, nil
+}
+
+func (f *fakeFederatedStore) ClientAssertionJWTValid(_ context.Context, jti string) error {
+ if exp, ok := f.jtis[jti]; ok && exp.After(time.Now()) {
+ return fosite.ErrJTIKnown
+ }
+ return nil
+}
+
+func (f *fakeFederatedStore) SetClientAssertionJWT(_ context.Context, jti string, exp time.Time) error {
+ f.jtis[jti] = exp
+ return nil
+}
+
+type roundTripFunc func(*http.Request) (*http.Response, error)
+
+func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
+ return f(req)
+}
+
+func newJWKSetHTTPClient(t *testing.T, set jwk.Set) *http.Client {
+ t.Helper()
+
+ client, _ := newCountingJWKSetHTTPClient(t, set, false)
+ return client
+}
+
+func newCountingJWKSetHTTPClient(t *testing.T, set jwk.Set, failAfterFirst bool) (*http.Client, *atomic.Int64) {
+ t.Helper()
+
+ raw, err := json.Marshal(set)
+ require.NoError(t, err)
+
+ var requests atomic.Int64
+ return &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
+ if failAfterFirst && requests.Load() > 0 {
+ return nil, errors.New("jwks endpoint unavailable")
+ }
+ requests.Add(1)
+ return &http.Response{
+ StatusCode: http.StatusOK,
+ Header: make(http.Header),
+ Body: io.NopCloser(strings.NewReader(string(raw))),
+ Request: req,
+ }, nil
+ })}, &requests
+}
+
+// TestFederatedClientAuthenticatorAssertionValidation covers federated client assertions
+// issued by providers that may cache and reuse tokens until their exp claim.
+func TestFederatedClientAuthenticatorAssertionValidation(t *testing.T) {
+ signingKey, err := jwkutils.GenerateKey(jwa.RS256().String(), "")
+ require.NoError(t, err)
+ signingAlg, ok := signingKey.Algorithm()
+ require.True(t, ok)
+
+ publicKey, err := signingKey.PublicKey()
+ require.NoError(t, err)
+ jwks := jwk.NewSet()
+ require.NoError(t, jwks.AddKey(publicKey))
+
+ const (
+ issuer = "https://idp.example.com"
+ clientID = "federated-client"
+ audience = "https://pocket-id.example.com"
+ jwksURL = "https://idp.example.com/jwks.json"
+ )
+
+ newAuthenticator := func(t *testing.T, replayProtection bool) (*federatedClientAuthenticator, *fakeFederatedStore) {
+ t.Helper()
+ store := &fakeFederatedStore{
+ client: Client{OidcClient: model.OidcClient{
+ Base: model.Base{ID: clientID},
+ Name: "Federated Client",
+ Credentials: model.OidcClientCredentials{
+ FederatedIdentities: []model.OidcClientFederatedIdentity{{
+ Issuer: issuer,
+ JWKS: jwksURL,
+ ReplayProtection: replayProtection,
+ }},
+ },
+ }},
+ jtis: map[string]time.Time{},
+ }
+ authenticator, err := newFederatedClientAuthenticator(t.Context(), store, newJWKSetHTTPClient(t, jwks), audience)
+ require.NoError(t, err)
+ return authenticator, store
+ }
+
+ signAssertion := func(t *testing.T, mutate func(b *jwt.Builder) *jwt.Builder) string {
+ t.Helper()
+ builder := jwt.NewBuilder().
+ Issuer(issuer).
+ Subject(clientID).
+ Audience([]string{audience}).
+ IssuedAt(time.Now())
+ token, err := mutate(builder).Build()
+ require.NoError(t, err)
+ signed, err := jwt.Sign(token, jwt.WithKey(signingAlg, signingKey))
+ require.NoError(t, err)
+ return string(signed)
+ }
+
+ t.Run("valid assertion with jti authenticates once when replay protection is enabled", func(t *testing.T) {
+ authenticator, _ := newAuthenticator(t, true)
+ assertion := signAssertion(t, func(b *jwt.Builder) *jwt.Builder {
+ return b.JwtID("replay-protected-token").Expiration(time.Now().Add(5 * time.Minute))
+ })
+
+ client, err := authenticator.authenticateAssertion(t.Context(), assertion, clientID)
+ require.NoError(t, err)
+ require.Equal(t, clientID, client.GetID())
+
+ _, err = authenticator.authenticateAssertion(t.Context(), assertion, clientID)
+ require.ErrorIs(t, err, fosite.ErrInvalidClient)
+ })
+
+ t.Run("valid assertion with jti can be reused when replay protection is disabled", func(t *testing.T) {
+ authenticator, store := newAuthenticator(t, false)
+ assertion := signAssertion(t, func(b *jwt.Builder) *jwt.Builder {
+ return b.JwtID("cached-provider-token").Expiration(time.Now().Add(5 * time.Minute))
+ })
+
+ client, err := authenticator.authenticateAssertion(t.Context(), assertion, clientID)
+ require.NoError(t, err)
+ require.Equal(t, clientID, client.GetID())
+
+ client, err = authenticator.authenticateAssertion(t.Context(), assertion, clientID)
+ require.NoError(t, err)
+ require.Equal(t, clientID, client.GetID())
+ require.Empty(t, store.jtis)
+ })
+
+ t.Run("assertion without exp is rejected", func(t *testing.T) {
+ authenticator, _ := newAuthenticator(t, true)
+ assertion := signAssertion(t, func(b *jwt.Builder) *jwt.Builder {
+ return b.JwtID("jti-no-exp")
+ })
+ _, err := authenticator.authenticateAssertion(t.Context(), assertion, clientID)
+ require.ErrorIs(t, err, fosite.ErrInvalidClient)
+ })
+
+ t.Run("assertion without jti is rejected when replay protection is enabled", func(t *testing.T) {
+ authenticator, _ := newAuthenticator(t, true)
+ assertion := signAssertion(t, func(b *jwt.Builder) *jwt.Builder {
+ return b.Expiration(time.Now().Add(5 * time.Minute))
+ })
+ _, err := authenticator.authenticateAssertion(t.Context(), assertion, clientID)
+ require.ErrorIs(t, err, fosite.ErrInvalidClient)
+ })
+
+ t.Run("assertion without jti authenticates when replay protection is disabled", func(t *testing.T) {
+ authenticator, store := newAuthenticator(t, false)
+ assertion := signAssertion(t, func(b *jwt.Builder) *jwt.Builder {
+ return b.Expiration(time.Now().Add(5 * time.Minute))
+ })
+ client, err := authenticator.authenticateAssertion(t.Context(), assertion, clientID)
+ require.NoError(t, err)
+ require.Equal(t, clientID, client.GetID())
+ require.Empty(t, store.jtis)
+ })
+}
+
+func TestFederatedClientAuthenticatorAllowsConfiguredSubjectDifferentFromClientID(t *testing.T) {
+ signingKey, err := jwkutils.GenerateKey(jwa.RS256().String(), "")
+ require.NoError(t, err)
+ signingAlg, ok := signingKey.Algorithm()
+ require.True(t, ok)
+
+ publicKey, err := signingKey.PublicKey()
+ require.NoError(t, err)
+ jwks := jwk.NewSet()
+ require.NoError(t, jwks.AddKey(publicKey))
+
+ const (
+ issuer = "https://idp.example.com"
+ clientID = "pocket-id-client"
+ subject = "external-workload-subject"
+ audience = "api://pocket-id"
+ jwksURL = "https://idp.example.com/jwks.json"
+ )
+
+ store := &fakeFederatedStore{
+ client: Client{OidcClient: model.OidcClient{
+ Base: model.Base{ID: clientID},
+ Name: "Federated Client",
+ Credentials: model.OidcClientCredentials{
+ FederatedIdentities: []model.OidcClientFederatedIdentity{{
+ Issuer: issuer,
+ Subject: subject,
+ Audience: audience,
+ JWKS: jwksURL,
+ }},
+ },
+ }},
+ jtis: map[string]time.Time{},
+ }
+ authenticator, err := newFederatedClientAuthenticator(t.Context(), store, newJWKSetHTTPClient(t, jwks), "unused-default-audience")
+ require.NoError(t, err)
+
+ token, err := jwt.NewBuilder().
+ Issuer(issuer).
+ Subject(subject).
+ Audience([]string{audience}).
+ IssuedAt(time.Now()).
+ Expiration(time.Now().Add(5 * time.Minute)).
+ JwtID("jti-custom-subject").
+ Build()
+ require.NoError(t, err)
+ assertion, err := jwt.Sign(token, jwt.WithKey(signingAlg, signingKey))
+ require.NoError(t, err)
+
+ client, err := authenticator.authenticateAssertion(t.Context(), string(assertion), clientID)
+ require.NoError(t, err)
+ require.Equal(t, clientID, client.GetID())
+}
+
+func TestFederatedClientAuthenticatorCachesJWKS(t *testing.T) {
+ signingKey, err := jwkutils.GenerateKey(jwa.RS256().String(), "")
+ require.NoError(t, err)
+ signingAlg, ok := signingKey.Algorithm()
+ require.True(t, ok)
+
+ publicKey, err := signingKey.PublicKey()
+ require.NoError(t, err)
+ jwks := jwk.NewSet()
+ require.NoError(t, jwks.AddKey(publicKey))
+
+ const (
+ issuer = "https://idp.example.com"
+ clientID = "federated-client"
+ audience = "https://pocket-id.example.com"
+ jwksURL = "https://idp.example.com/jwks.json"
+ )
+
+ httpClient, requests := newCountingJWKSetHTTPClient(t, jwks, true)
+ store := &fakeFederatedStore{
+ client: Client{OidcClient: model.OidcClient{
+ Base: model.Base{ID: clientID},
+ Name: "Federated Client",
+ Credentials: model.OidcClientCredentials{
+ FederatedIdentities: []model.OidcClientFederatedIdentity{{
+ Issuer: issuer,
+ JWKS: jwksURL,
+ }},
+ },
+ }},
+ jtis: map[string]time.Time{},
+ }
+ authenticator, err := newFederatedClientAuthenticator(t.Context(), store, httpClient, audience)
+ require.NoError(t, err)
+
+ signAssertion := func(t *testing.T, jti string) string {
+ t.Helper()
+ token, err := jwt.NewBuilder().
+ Issuer(issuer).
+ Subject(clientID).
+ Audience([]string{audience}).
+ IssuedAt(time.Now()).
+ Expiration(time.Now().Add(5 * time.Minute)).
+ JwtID(jti).
+ Build()
+ require.NoError(t, err)
+ signed, err := jwt.Sign(token, jwt.WithKey(signingAlg, signingKey))
+ require.NoError(t, err)
+ return string(signed)
+ }
+
+ client, err := authenticator.authenticateAssertion(t.Context(), signAssertion(t, "jti-cache-1"), clientID)
+ require.NoError(t, err)
+ require.Equal(t, clientID, client.GetID())
+
+ client, err = authenticator.authenticateAssertion(t.Context(), signAssertion(t, "jti-cache-2"), clientID)
+ require.NoError(t, err)
+ require.Equal(t, clientID, client.GetID())
+ require.EqualValues(t, 1, requests.Load())
+}
diff --git a/backend/internal/oidc/interaction_session_dto.go b/backend/internal/oidc/interaction_session_dto.go
new file mode 100644
index 00000000..0d685cf9
--- /dev/null
+++ b/backend/internal/oidc/interaction_session_dto.go
@@ -0,0 +1,70 @@
+package oidc
+
+import (
+ "github.com/pocket-id/pocket-id/backend/internal/dto"
+)
+
+type interactionStep string
+
+const (
+ interactionStepAuthenticate interactionStep = "authenticate"
+ interactionStepSelectAccount interactionStep = "select_account"
+ interactionStepReauthenticate interactionStep = "reauthenticate"
+ interactionStepConsent interactionStep = "consent"
+)
+
+type interactionSessionForUser struct {
+ ID string `json:"id"`
+ Scopes []string `json:"scopes"`
+ Client dto.OidcClientMetaDataDto `json:"client"`
+ CurrentStep interactionStep `json:"currentStep,omitempty"`
+ RequiredSteps []interactionStep `json:"requiredSteps"`
+}
+
+type completeInteractionRequest struct {
+ Step interactionStep `json:"step"`
+}
+
+type completeInteractionResponse struct {
+ Interaction *interactionSessionForUser `json:"interaction,omitempty"`
+ RedirectURL string `json:"redirectUrl,omitempty"`
+}
+
+func newInteractionSessionForUser(interactionSession InteractionSession) (interactionSessionForUser, error) {
+ var client dto.OidcClientMetaDataDto
+ if err := dto.MapStruct(interactionSession.Client, &client); err != nil {
+ return interactionSessionForUser{}, err
+ }
+
+ requiredSteps := requiredInteractionSteps(interactionSession)
+ var currentStep interactionStep
+ if len(requiredSteps) > 0 {
+ currentStep = requiredSteps[0]
+ }
+
+ return interactionSessionForUser{
+ ID: interactionSession.ID,
+ Scopes: interactionSession.Scopes,
+ Client: client,
+ CurrentStep: currentStep,
+ RequiredSteps: requiredSteps,
+ }, nil
+}
+
+func requiredInteractionSteps(interactionSession InteractionSession) []interactionStep {
+ steps := make([]interactionStep, 0, 4)
+ if interactionSession.AuthenticationRequired {
+ steps = append(steps, interactionStepAuthenticate)
+ }
+ if interactionSession.AccountSelectionRequired {
+ steps = append(steps, interactionStepSelectAccount)
+ }
+ if interactionSession.ReauthenticationRequired {
+ steps = append(steps, interactionStepReauthenticate)
+ }
+ if interactionSession.ConsentRequired {
+ steps = append(steps, interactionStepConsent)
+ }
+
+ return steps
+}
diff --git a/backend/internal/oidc/interaction_session_service.go b/backend/internal/oidc/interaction_session_service.go
new file mode 100644
index 00000000..a7d28b85
--- /dev/null
+++ b/backend/internal/oidc/interaction_session_service.go
@@ -0,0 +1,85 @@
+package oidc
+
+import (
+ "context"
+ "time"
+
+ "gorm.io/gorm"
+)
+
+// interactionSessionLifetime is how long a pending interaction stays valid before the
+// user has to restart the authorization flow.
+const interactionSessionLifetime = time.Hour
+
+type interactionSessionService struct {
+ db *gorm.DB
+}
+
+func newInteractionSessionService(db *gorm.DB) *interactionSessionService {
+ return &interactionSessionService{
+ db: db,
+ }
+}
+
+func (s *interactionSessionService) create(ctx context.Context, interactionSession InteractionSession) (InteractionSession, error) {
+ err := dbFromContext(ctx, s.db).
+ Create(&interactionSession).
+ Error
+ if err != nil {
+ return InteractionSession{}, err
+ }
+
+ return interactionSession, nil
+}
+
+func (s *interactionSessionService) get(ctx context.Context, id string) (InteractionSession, error) {
+ var interactionSession InteractionSession
+ err := dbFromContext(ctx, s.db).
+ Preload("Client").
+ First(&interactionSession, "id = ?", id).
+ Error
+ if err != nil {
+ return InteractionSession{}, err
+ }
+
+ if time.Since(interactionSession.CreatedAt.ToTime()) > interactionSessionLifetime {
+ return InteractionSession{}, gorm.ErrRecordNotFound
+ }
+
+ return interactionSession, nil
+}
+
+func (s *interactionSessionService) update(ctx context.Context, interactionSession InteractionSession) error {
+ return dbFromContext(ctx, s.db).
+ Model(&InteractionSession{}).
+ Where("id = ?", interactionSession.ID).
+ Updates(map[string]any{
+ "authentication_required": interactionSession.AuthenticationRequired,
+ "account_selection_required": interactionSession.AccountSelectionRequired,
+ "reauthentication_required": interactionSession.ReauthenticationRequired,
+ "consent_required": interactionSession.ConsentRequired,
+ "user_id": interactionSession.UserID,
+ "reauthenticated_at": interactionSession.ReauthenticatedAt,
+ "parameters": interactionSession.Parameters,
+ }).
+ Error
+}
+
+func (s *interactionSessionService) delete(ctx context.Context, id string) error {
+ tx := dbFromContext(ctx, s.db).Where("id = ?", id).Delete(&InteractionSession{})
+ if tx.Error != nil {
+ return tx.Error
+ }
+ if tx.RowsAffected == 0 {
+ return gorm.ErrRecordNotFound
+ }
+
+ return nil
+}
+
+func hasRemainingInteractionSteps(interactionSession InteractionSession) bool {
+ return interactionSession.AuthenticationRequired ||
+ interactionSession.AccountSelectionRequired ||
+ interactionSession.ReauthenticationRequired ||
+ interactionSession.ConsentRequired
+}
diff --git a/backend/internal/oidc/introspection_handler.go b/backend/internal/oidc/introspection_handler.go
new file mode 100644
index 00000000..3f1e4eff
--- /dev/null
+++ b/backend/internal/oidc/introspection_handler.go
@@ -0,0 +1,115 @@
+package oidc
+
+import (
+ "context"
+ "log/slog"
+ "net/url"
+ "strings"
+
+ "github.com/gin-gonic/gin"
+ "github.com/ory/fosite"
+)
+
+type introspectionHandler struct {
+ provider fosite.OAuth2Provider
+ authenticator *federatedClientAuthenticator
+ baseURL string
+}
+
+func newIntrospectionHandler(provider fosite.OAuth2Provider, authenticator *federatedClientAuthenticator, baseURL string) *introspectionHandler {
+ return &introspectionHandler{
+ provider: provider,
+ authenticator: authenticator,
+ baseURL: baseURL,
+ }
+}
+
+// introspectToken godoc
+// @Summary Introspect OIDC tokens
+// @Description Pass a token to verify if it is considered valid.
+// @Tags OIDC
+// @Produce json
+// @Param token formData string true "The token to be introspected."
+// @Success 200 {object} object "Response with the introspection result."
+// @Router /api/oidc/introspect [post]
+func (h *introspectionHandler) introspectToken(c *gin.Context) {
+ ctx := c.Request.Context()
+
+ if h.tryFederatedClientAssertionIntrospection(c) {
+ return
+ }
+
+ response, err := h.provider.NewIntrospectionRequest(ctx, c.Request, NewEmptySession())
+ if err != nil {
+ slog.ErrorContext(ctx, "Failed to create introspection request", "error", err)
+ h.provider.WriteIntrospectionError(ctx, c.Writer, err)
+ return
+ }
+
+ // A client may only introspect its own tokens. If it was issued to another client, report it as
+ // inactive instead of leaking its existence or contents.
+ callerClientID, err := h.callerClientID(ctx, c)
+ if err != nil || callerClientID == "" || response.GetAccessRequester().GetClient().GetID() != callerClientID {
+ h.provider.WriteIntrospectionResponse(ctx, c.Writer, &fosite.IntrospectionResponse{Active: false})
+ return
+ }
+
+ h.provider.WriteIntrospectionResponse(ctx, c.Writer, response)
+}
+
+// tryFederatedClientAssertionIntrospection handles introspection requests authenticated
+// with a federated client assertion passed as bearer token instead of client credentials.
+func (h *introspectionHandler) tryFederatedClientAssertionIntrospection(c *gin.Context) bool {
+ ctx := c.Request.Context()
+ assertion := fosite.AccessTokenFromRequest(c.Request)
+ clientID := c.PostForm("client_id")
+ if assertion == "" || clientID == "" {
+ return false
+ }
+
+ client, err := h.authenticator.authenticateAssertion(ctx, assertion, clientID)
+ if err != nil {
+ h.provider.WriteIntrospectionError(ctx, c.Writer, fosite.ErrRequestUnauthorized.WithWrap(err))
+ return true
+ }
+
+ tokenUse, accessRequester, err := h.provider.IntrospectToken(ctx, c.PostForm("token"), fosite.TokenUse(c.PostForm("token_type_hint")), NewEmptySession(), strings.Fields(c.PostForm("scope"))...)
+ if err != nil {
+ h.provider.WriteIntrospectionError(ctx, c.Writer, fosite.ErrInactiveToken.WithWrap(err))
+ return true
+ }
+
+ response := &fosite.IntrospectionResponse{
+ Active: true,
+ AccessRequester: accessRequester,
+ TokenUse: tokenUse,
+ }
+ if tokenUse == fosite.AccessToken {
+ response.AccessTokenType = fosite.BearerAccessToken
+ }
+
+ if accessRequester.GetClient().GetID() != client.GetID() {
+ h.provider.WriteIntrospectionResponse(ctx, c.Writer, &fosite.IntrospectionResponse{Active: false})
+ return true
+ }
+
+ h.provider.WriteIntrospectionResponse(ctx, c.Writer, response)
+ return true
+}
+
+// callerClientID resolves the client that authenticated this introspection request.
+func (h *introspectionHandler) callerClientID(ctx context.Context, c *gin.Context) (string, error) {
+ if bearer := fosite.AccessTokenFromRequest(c.Request); bearer != "" {
+ _, accessRequester, err := h.provider.IntrospectToken(ctx, bearer, fosite.AccessToken, NewEmptySession())
+ if err != nil {
+ return "", err
+ }
+ return accessRequester.GetClient().GetID(), nil
+ }
+
+ if id, _, ok := c.Request.BasicAuth(); ok {
+ return url.QueryUnescape(id)
+ }
+
+ return "", nil
+}
diff --git a/backend/internal/oidc/introspection_handler_test.go b/backend/internal/oidc/introspection_handler_test.go
new file mode 100644
index 00000000..c12c70f9
--- /dev/null
+++ b/backend/internal/oidc/introspection_handler_test.go
@@ -0,0 +1,207 @@
+package oidc
+
+import (
+ "crypto/rand"
+ "crypto/rsa"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/gin-gonic/gin"
+ "github.com/lestrrat-go/jwx/v3/jwa"
+ "github.com/lestrrat-go/jwx/v3/jwk"
+ "github.com/lestrrat-go/jwx/v3/jwt"
+ "github.com/ory/fosite"
+ "github.com/stretchr/testify/require"
+
+ "github.com/pocket-id/pocket-id/backend/internal/model"
+ jwkutils "github.com/pocket-id/pocket-id/backend/internal/utils/jwk"
+ testutils "github.com/pocket-id/pocket-id/backend/internal/utils/testing"
+)
+
+// TestIntrospectionHandlerBindsTokenToCallerClient verifies that an RFC 6750 bearer-auth
+// caller (which omits client_id) can only introspect its own client's tokens. A client
+// must not be able to introspect another client's token — doing so would leak the token's
+// validity and the user PII it carries.
+func TestIntrospectionHandlerBindsTokenToCallerClient(t *testing.T) {
+ gin.SetMode(gin.TestMode)
+
+ db := testutils.NewDatabaseForTest(t)
+ require.NoError(t, db.Create(&model.OidcClient{Base: model.Base{ID: "client-a"}, Name: "Client A"}).Error)
+ require.NoError(t, db.Create(&model.OidcClient{Base: model.Base{ID: "client-b"}, Name: "Client B"}).Error)
+
+ signerKey, err := rsa.GenerateKey(rand.Reader, 2048)
+ require.NoError(t, err)
+
+ provider, err := newProvider(NewStore(db), nil, testTokenSigner{key: signerKey}, Config{ //nolint:gosec // static test-only provider secret
+ BaseURL: "https://issuer.example.com",
+ TokenBaseURL: "https://issuer.example.com",
+ Secret: "test-secret",
+ })
+ require.NoError(t, err)
+
+ issueAccessToken := func(t *testing.T, requestID, clientID, subject string) string {
+ t.Helper()
+ session := NewEmptySession()
+ session.Subject = subject
+ session.SetExpiresAt(fosite.AccessToken, time.Now().UTC().Add(time.Hour))
+
+ request := fosite.NewAccessRequest(session)
+ request.ID = requestID
+ request.Client = Client{OidcClient: model.OidcClient{Base: model.Base{ID: clientID}}}
+ request.GrantTypes = fosite.Arguments{string(fosite.GrantTypeClientCredentials)}
+ request.RequestedScope = fosite.Arguments{"openid"}
+ request.GrantedScope = fosite.Arguments{"openid"}
+ request.RequestedAudience = fosite.Arguments{clientID}
+ request.GrantedAudience = fosite.Arguments{clientID}
+
+ response, err := provider.NewAccessResponse(t.Context(), request)
+ require.NoError(t, err)
+ return response.GetAccessToken()
+ }
+
+ clientAToken := issueAccessToken(t, "req-a", "client-a", "user-a")
+ clientBToken := issueAccessToken(t, "req-b", "client-b", "user-b")
+ clientBOtherToken := issueAccessToken(t, "req-b-2", "client-b", "user-b")
+
+ handler := newIntrospectionHandler(provider, nil, "https://issuer.example.com")
+
+ introspect := func(t *testing.T, bearer, token string) map[string]any {
+ t.Helper()
+ body := url.Values{"token": {token}}.Encode()
+ req := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/api/oidc/introspect", strings.NewReader(body))
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ req.Header.Set("Authorization", "Bearer "+bearer)
+
+ rec := httptest.NewRecorder()
+ c, _ := gin.CreateTestContext(rec)
+ c.Request = req
+
+ handler.introspectToken(c)
+
+ var out map[string]any
+ require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &out))
+ return out
+ }
+
+ // A client using its own token as bearer must not learn about another client's token.
+ cross := introspect(t, clientBToken, clientAToken)
+ require.Equal(t, false, cross["active"])
+
+ // A client may introspect another of its own tokens.
+ same := introspect(t, clientBToken, clientBOtherToken)
+ require.Equal(t, true, same["active"])
+}
+
+func TestIntrospectionHandlerAllowsReusedFederatedClientAssertion(t *testing.T) {
+ gin.SetMode(gin.TestMode)
+
+ const (
+ baseURL = "https://issuer.example.com"
+ issuer = "https://idp.example.com"
+ clientID = "federated-client"
+ subject = "external-workload"
+ audience = "api://pocket-id"
+ jwksURL = "https://idp.example.com/jwks.json"
+ )
+
+ signingKey, err := jwkutils.GenerateKey(jwa.RS256().String(), "")
+ require.NoError(t, err)
+ signingAlg, ok := signingKey.Algorithm()
+ require.True(t, ok)
+
+ publicKey, err := signingKey.PublicKey()
+ require.NoError(t, err)
+ jwks := jwk.NewSet()
+ require.NoError(t, jwks.AddKey(publicKey))
+
+ db := testutils.NewDatabaseForTest(t)
+ replayProtection := false
+ require.NoError(t, db.Create(&model.OidcClient{
+ Base: model.Base{ID: clientID},
+ Name: "Federated Client",
+ Credentials: model.OidcClientCredentials{
+ FederatedIdentities: []model.OidcClientFederatedIdentity{{
+ Issuer: issuer,
+ Subject: subject,
+ Audience: audience,
+ JWKS: jwksURL,
+ ReplayProtection: replayProtection,
+ }},
+ },
+ }).Error)
+
+ store := NewStore(db)
+ authenticator, err := newFederatedClientAuthenticator(t.Context(), store, newJWKSetHTTPClient(t, jwks), baseURL)
+ require.NoError(t, err)
+
+ signerKey, err := rsa.GenerateKey(rand.Reader, 2048)
+ require.NoError(t, err)
+ provider, err := newProvider(store, authenticator, testTokenSigner{key: signerKey}, Config{ //nolint:gosec // static test-only provider secret
+ BaseURL: baseURL,
+ TokenBaseURL: baseURL,
+ Secret: "test-secret",
+ })
+ require.NoError(t, err)
+
+ session := NewEmptySession()
+ session.Subject = "user-a"
+ session.SetExpiresAt(fosite.AccessToken, time.Now().UTC().Add(time.Hour))
+
+ accessRequest := fosite.NewAccessRequest(session)
+ accessRequest.ID = "replay-test-request"
+ accessRequest.Client = Client{OidcClient: model.OidcClient{Base: model.Base{ID: clientID}}}
+ accessRequest.GrantTypes = fosite.Arguments{string(fosite.GrantTypeClientCredentials)}
+ accessRequest.RequestedScope = fosite.Arguments{"openid"}
+ accessRequest.GrantedScope = fosite.Arguments{"openid"}
+ accessRequest.RequestedAudience = fosite.Arguments{clientID}
+ accessRequest.GrantedAudience = fosite.Arguments{clientID}
+
+ accessResponse, err := provider.NewAccessResponse(t.Context(), accessRequest)
+ require.NoError(t, err)
+
+ assertionToken, err := jwt.NewBuilder().
+ Issuer(issuer).
+ Subject(subject).
+ Audience([]string{audience}).
+ IssuedAt(time.Now()).
+ Expiration(time.Now().Add(5 * time.Minute)).
+ JwtID("cached-provider-token").
+ Build()
+ require.NoError(t, err)
+ signedAssertion, err := jwt.Sign(assertionToken, jwt.WithKey(signingAlg, signingKey))
+ require.NoError(t, err)
+
+ handler := newIntrospectionHandler(provider, authenticator, baseURL)
+ introspect := func(t *testing.T) (int, map[string]any) {
+ t.Helper()
+ body := url.Values{
+ "client_id": {clientID},
+ "token": {accessResponse.GetAccessToken()},
+ }.Encode()
+ req := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/api/oidc/introspect", strings.NewReader(body))
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ req.Header.Set("Authorization", "Bearer "+string(signedAssertion))
+
+ rec := httptest.NewRecorder()
+ c, _ := gin.CreateTestContext(rec)
+ c.Request = req
+ handler.introspectToken(c)
+
+ var out map[string]any
+ require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &out))
+ return rec.Code, out
+ }
+
+ status, body := introspect(t)
+ require.Equal(t, http.StatusOK, status)
+ require.Equal(t, true, body["active"])
+
+ status, body = introspect(t)
+ require.Equal(t, http.StatusOK, status)
+ require.Equal(t, true, body["active"])
+}
diff --git a/backend/internal/oidc/models.go b/backend/internal/oidc/models.go
new file mode 100644
index 00000000..b7ff9f09
--- /dev/null
+++ b/backend/internal/oidc/models.go
@@ -0,0 +1,75 @@
+package oidc
+
+import (
+ "database/sql/driver"
+ "encoding/json"
+
+ "github.com/pocket-id/pocket-id/backend/internal/model"
+ datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
+ "github.com/pocket-id/pocket-id/backend/internal/utils"
+)
+
+// OAuth2Session is a fosite requester persisted by the Store. A single table holds
+// all session kinds (authorization codes, tokens, PKCE, PAR, device codes, ...).
+type OAuth2Session struct {
+ model.Base
+
+ Kind string
+ Key string
+ RequestID string
+ AccessTokenSignature string
+ Active bool
+ RequestData string
+ ExpiresAt *datatype.DateTime
+}
+
+func (OAuth2Session) TableName() string {
+ return "oauth2_sessions"
+}
+
+// clientAssertionJTI stores consumed client assertion JWT IDs for replay protection.
+type clientAssertionJTI struct {
+ model.Base
+
+ JTI string `gorm:"not null;uniqueIndex"`
+ ExpiresAt datatype.DateTime `gorm:"not null;index"`
+}
+
+func (clientAssertionJTI) TableName() string {
+ return "oauth2_jtis"
+}
+
+// InteractionSession tracks the user-facing steps (authentication, account selection,
+// reauthentication, consent) that must be completed before an authorization request
+// can be granted.
+type InteractionSession struct {
+ model.Base
+
+ Scopes datatype.StringList
+
+ ClientID string
+ Client model.OidcClient
+
+ UserID *string
+ User model.User
+
+ ConsentRequired bool
+ ReauthenticationRequired bool
+ AuthenticationRequired bool
+ AccountSelectionRequired bool
+
+ RequestedAt datatype.DateTime
+ ReauthenticatedAt *datatype.DateTime
+
+ Parameters InteractionSessionParameters
+}
+
+type InteractionSessionParameters map[string]string //nolint:recvcheck
+
+func (p *InteractionSessionParameters) Scan(value any) error {
+ return utils.UnmarshalJSONFromDatabase(p, value)
+}
+
+func (p InteractionSessionParameters) Value() (driver.Value, error) {
+ return json.Marshal(p)
+}
diff --git a/backend/internal/oidc/module.go b/backend/internal/oidc/module.go
new file mode 100644
index 00000000..00b21b8d
--- /dev/null
+++ b/backend/internal/oidc/module.go
@@ -0,0 +1,121 @@
+package oidc
+
+import (
+ "context"
+ "fmt"
+ "net/http"
+ "time"
+
+ "github.com/gin-gonic/gin"
+ "github.com/lestrrat-go/jwx/v3/jwa"
+ "github.com/pocket-id/pocket-id/backend/internal/model"
+ "gorm.io/gorm"
+)
+
+type Config struct {
+ BaseURL string
+ TokenBaseURL string
+ Secret string
+}
+
+type TokenSigner interface {
+ GetPrivateKey() any
+ GetKeyAlg() (jwa.KeyAlgorithm, error)
+ GetKeyID() (string, bool)
+}
+
+type CustomClaimSource interface {
+ GetCustomClaimsForUserWithUserGroups(ctx context.Context, userID string, tx *gorm.DB) ([]model.CustomClaim, error)
+}
+
+type ReauthenticationTokenConsumer interface {
+ ConsumeReauthenticationToken(ctx context.Context, tx *gorm.DB, token string, userID string) (time.Time, error)
+}
+
+type AuditLogger interface {
+ Create(ctx context.Context, event model.AuditLogEvent, ipAddress, userAgent, userID string, data model.AuditLogData, tx *gorm.DB) (model.AuditLog, bool)
+}
+
+type Dependencies struct {
+ DB *gorm.DB
+ Config Config
+ HTTPClient *http.Client
+
+ Signer TokenSigner
+ CustomClaims CustomClaimSource
+ Reauth ReauthenticationTokenConsumer
+ AuditLog AuditLogger
+}
+
+type Module struct {
+ Preview *ClientPreviewBuilder
+
+ config Config
+ store *Store
+
+ authorizationHandler *authorizationHandler
+ tokenHandler *tokenHandler
+ userInfoHandler *userInfoHandler
+ parHandler *parHandler
+ introspectionHandler *introspectionHandler
+ endSessionHandler *endSessionHandler
+ deviceHandler *deviceHandler
+}
+
+func New(ctx context.Context, deps Dependencies) (*Module, error) {
+ store := NewStore(deps.DB)
+ authenticator, err := newFederatedClientAuthenticator(ctx, store, deps.HTTPClient, deps.Config.BaseURL)
+ if err != nil {
+ return nil, fmt.Errorf("failed to create federated client authenticator: %w", err)
+ }
+ provider, err := newProvider(store, authenticator, deps.Signer, deps.Config)
+ if err != nil {
+ return nil, fmt.Errorf("failed to create OAuth2 provider: %w", err)
+ }
+
+ claimsService := newClaimsService(deps.DB, deps.CustomClaims, deps.Config.BaseURL, deps.Signer)
+ previewBuilder := newClientPreviewBuilder(claimsService, provider.tokenStrategies)
+ interactionSessionService := newInteractionSessionService(deps.DB)
+ authorizationService := newAuthorizationService(deps.DB, interactionSessionService, claimsService, deps.Reauth, deps.AuditLog)
+ deviceService := newDeviceService(provider, store, provider.deviceStrategy, authorizationService, claimsService, deps.AuditLog, deps.DB)
+ endSessionService := newEndSessionService(deps.DB, store, deps.Signer, deps.Config.BaseURL)
+
+ return &Module{
+ Preview: previewBuilder,
+
+ config: deps.Config,
+ store: store,
+
+ authorizationHandler: newAuthorizationHandler(provider, authorizationService, deps.Config.BaseURL),
+ tokenHandler: newTokenHandler(provider, claimsService),
+ userInfoHandler: newUserInfoHandler(provider, claimsService),
+ parHandler: newPARHandler(provider),
+ introspectionHandler: newIntrospectionHandler(provider, authenticator, deps.Config.BaseURL),
+ endSessionHandler: newEndSessionHandler(endSessionService, deps.Config.BaseURL),
+ deviceHandler: newDeviceHandler(provider, deviceService),
+ }, nil
+}
+
+func (m *Module) RegisterRoutes(rootGroup *gin.RouterGroup, apiGroup *gin.RouterGroup, optionalBrowserAuth gin.HandlerFunc, browserAuth gin.HandlerFunc) {
+ rootGroup.GET("/authorize", optionalBrowserAuth, m.authorizationHandler.authorize)
+ rootGroup.POST("/authorize", optionalBrowserAuth, m.authorizationHandler.authorize)
+
+ apiGroup.GET("/oidc/interactions/:id", m.authorizationHandler.getInteractionSession)
+ apiGroup.POST("/oidc/interactions/:id/complete", browserAuth, m.authorizationHandler.completeInteraction)
+
+ apiGroup.POST("/oidc/par", m.parHandler.pushedAuthorizationRequest)
+
+ apiGroup.POST("/oidc/token", m.tokenHandler.token)
+
+ apiGroup.GET("/oidc/userinfo", m.userInfoHandler.userInfo)
+ apiGroup.POST("/oidc/userinfo", m.userInfoHandler.userInfo)
+
+ apiGroup.POST("/oidc/introspect", m.introspectionHandler.introspectToken)
+
+ apiGroup.GET("/oidc/end-session", optionalBrowserAuth, m.endSessionHandler.endSession)
+ apiGroup.POST("/oidc/end-session", optionalBrowserAuth, m.endSessionHandler.endSession)
+
+ apiGroup.POST("/oidc/device/authorize", m.deviceHandler.authorizeDevice)
+ apiGroup.POST("/oidc/device/verify", browserAuth, m.deviceHandler.verifyDeviceCode)
+ apiGroup.GET("/oidc/device/info", browserAuth, m.deviceHandler.deviceCodeInfo)
+}
diff --git a/backend/internal/oidc/par_handler.go b/backend/internal/oidc/par_handler.go
new file mode 100644
index 00000000..adf37d10
--- /dev/null
+++ b/backend/internal/oidc/par_handler.go
@@ -0,0 +1,38 @@
+package oidc
+
+import (
+ "log/slog"
+
+ "github.com/gin-gonic/gin"
+ "github.com/ory/fosite"
+)
+
+type parHandler struct {
+ provider fosite.OAuth2Provider
+}
+
+func newPARHandler(provider fosite.OAuth2Provider) *parHandler {
+ return &parHandler{
+ provider: provider,
+ }
+}
+
+func (h *parHandler) pushedAuthorizationRequest(c *gin.Context) {
+ ctx := c.Request.Context()
+
+ ar, err := h.provider.NewPushedAuthorizeRequest(ctx, c.Request)
+ if err != nil {
+ slog.ErrorContext(ctx, "Failed to create pushed authorize request", "error", err)
+ h.provider.WritePushedAuthorizeError(ctx, c.Writer, ar, err)
+ return
+ }
+
+ response, err := h.provider.NewPushedAuthorizeResponse(ctx, ar, NewEmptySession())
+ if err != nil {
+ slog.ErrorContext(ctx, "Failed to create pushed authorize response", "error", err)
+ h.provider.WritePushedAuthorizeError(ctx, c.Writer, ar, err)
+ return
+ }
+
+ h.provider.WritePushedAuthorizeResponse(ctx, c.Writer, ar, response)
+}
diff --git a/backend/internal/oidc/preview.go b/backend/internal/oidc/preview.go
new file mode 100644
index 00000000..50e0114c
--- /dev/null
+++ b/backend/internal/oidc/preview.go
@@ -0,0 +1,122 @@
+package oidc
+
+import (
+ "context"
+ "fmt"
+ "net/url"
+ "time"
+
+ jwxjwt "github.com/lestrrat-go/jwx/v3/jwt"
+ "github.com/ory/fosite"
+
+ "github.com/pocket-id/pocket-id/backend/internal/model"
+ "github.com/pocket-id/pocket-id/backend/internal/utils"
+)
+
+type ClientPreview struct {
+ IDToken map[string]any
+ AccessToken map[string]any
+ UserInfo map[string]any
+}
+
+type ClientPreviewBuilder struct {
+ claimsService *ClaimsService
+ strategies tokenStrategies
+}
+
+func newClientPreviewBuilder(claimsService *ClaimsService, strategies tokenStrategies) *ClientPreviewBuilder {
+ return &ClientPreviewBuilder{
+ claimsService: claimsService,
+ strategies: strategies,
+ }
+}
+
+func (b *ClientPreviewBuilder) BuildClientPreview(ctx context.Context, client model.OidcClient, userID string, scopes []string, authenticationMethod string) (*ClientPreview, error) {
+ scopeArgs, err := b.validatedScopes(ctx, client, scopes)
+ if err != nil {
+ return nil, err
+ }
+
+ userInfo, err := b.claimsService.GetUserClaims(ctx, userID, scopeArgs)
+ if err != nil {
+ return nil, err
+ }
+
+ request := b.newPreviewRequest(ctx, client, userID, scopeArgs, authenticationMethod)
+ session := request.GetSession().(*Session)
+ applyUserClaimsToIDToken(session, userID, userInfo)
+
+ idToken, err := b.strategies.idToken.GenerateIDToken(ctx, b.strategies.config.GetIDTokenLifespan(ctx), request)
+ if err != nil {
+ return nil, fmt.Errorf("failed to generate preview ID token: %w", err)
+ }
+
+ accessToken, _, err := b.strategies.accessToken.GenerateAccessToken(ctx, request)
+ if err != nil {
+ return nil, fmt.Errorf("failed to generate preview access token: %w", err)
+ }
+
+ idTokenPayload, err := claimsFromJWTString(idToken)
+ if err != nil {
+ return nil, fmt.Errorf("failed to decode preview ID token: %w", err)
+ }
+
+ accessTokenPayload, err := claimsFromJWTString(accessToken)
+ if err != nil {
+ return nil, fmt.Errorf("failed to decode preview access token: %w", err)
+ }
+
+ return &ClientPreview{
+ IDToken: idTokenPayload,
+ AccessToken: accessTokenPayload,
+ UserInfo: userInfo,
+ }, nil
+}
+
+func (b *ClientPreviewBuilder) validatedScopes(ctx context.Context, client model.OidcClient, scopes []string) (fosite.Arguments, error) {
+ scopeStrategy := b.strategies.config.GetScopeStrategy(ctx)
+ clientScopes := Client{OidcClient: client}.GetScopes()
+
+ scopeArgs := make(fosite.Arguments, 0, len(scopes))
+ for _, scope := range fosite.RemoveEmpty(scopes) {
+ if !scopeStrategy(clientScopes, scope) {
+ return nil, fosite.ErrInvalidScope.WithHintf("The OAuth 2.0 Client is not allowed to request scope '%s'.", scope)
+ }
+ if !scopeArgs.Has(scope) {
+ scopeArgs = append(scopeArgs, scope)
+ }
+ }
+
+ return scopeArgs, nil
+}
+
+func (b *ClientPreviewBuilder) newPreviewRequest(ctx context.Context, client model.OidcClient, userID string, scopes fosite.Arguments, authenticationMethod string) *fosite.Request {
+ now := time.Now().UTC()
+ session := NewAuthenticatedSession(userID, authenticationMethod, now, now)
+ session.SetExpiresAt(fosite.AccessToken, now.Add(b.strategies.config.GetAccessTokenLifespan(ctx)))
+
+ request := fosite.NewRequest()
+ request.RequestedAt = now
+ request.Client = Client{OidcClient: client}
+ request.RequestedScope = scopes
+ request.GrantedScope = scopes
+ request.RequestedAudience = fosite.Arguments{client.ID}
+ request.GrantedAudience = fosite.Arguments{client.ID}
+ request.Form = url.Values{}
+ request.Session = session
+
+ return request
+}
+
+func claimsFromJWTString(tokenString string) (map[string]any, error) {
+ token, err := jwxjwt.ParseString(
+ tokenString,
+ jwxjwt.WithValidate(false),
+ jwxjwt.WithVerify(false),
+ )
+ if err != nil {
+ return nil, err
+ }
+
+ return utils.GetClaimsFromToken(token)
+}
diff --git a/backend/internal/oidc/preview_test.go b/backend/internal/oidc/preview_test.go
new file mode 100644
index 00000000..4ba9737e
--- /dev/null
+++ b/backend/internal/oidc/preview_test.go
@@ -0,0 +1,102 @@
+package oidc
+
+import (
+ "crypto/rand"
+ "crypto/rsa"
+ "testing"
+
+ "github.com/stretchr/testify/require"
+
+ "github.com/pocket-id/pocket-id/backend/internal/model"
+ testutils "github.com/pocket-id/pocket-id/backend/internal/utils/testing"
+)
+
+func TestClientPreviewBuilderUsesFositeTokenStrategies(t *testing.T) {
+ db := testutils.NewDatabaseForTest(t)
+ signerKey, err := rsa.GenerateKey(rand.Reader, 2048)
+ require.NoError(t, err)
+
+ provider, err := newProvider(NewStore(db), nil, testTokenSigner{key: signerKey}, Config{ //nolint:gosec // static test-only provider secret
+ BaseURL: "https://issuer.example.com",
+ TokenBaseURL: "https://issuer.example.com",
+ Secret: "test-secret",
+ })
+ require.NoError(t, err)
+
+ builder := newClientPreviewBuilder(newClaimsService(db, nil, "https://issuer.example.com", nil), provider.tokenStrategies)
+
+ const (
+ userID = "test-user"
+ clientID = "test-client"
+ )
+ email := "user@example.com"
+ require.NoError(t, db.Create(&model.User{
+ Base: model.Base{ID: userID},
+ Username: "test-user",
+ Email: &email,
+ EmailVerified: true,
+ }).Error)
+
+ preview, err := builder.BuildClientPreview(t.Context(), model.OidcClient{
+ Base: model.Base{ID: clientID},
+ Name: "Test Client",
+ }, userID, []string{"openid", "email"}, "phr")
+ require.NoError(t, err)
+
+ require.Equal(t, "https://issuer.example.com", preview.AccessToken["iss"])
+ require.ElementsMatch(t, []string{"openid", "email"}, stringSliceClaim(t, preview.AccessToken["scp"]))
+ require.ElementsMatch(t, []string{clientID}, stringSliceClaim(t, preview.AccessToken["aud"]))
+ require.NotContains(t, preview.AccessToken, "type")
+
+ require.Equal(t, userID, preview.IDToken["sub"])
+ // ID tokens carry the "type" marker (so the end-session endpoint can reject access tokens
+ // passed as id_token_hint) and the amr from the authentication method.
+ require.Equal(t, idTokenType, preview.IDToken["type"])
+ require.ElementsMatch(t, []string{"phr"}, stringSliceClaim(t, preview.IDToken["amr"]))
+
+ require.Equal(t, email, preview.UserInfo["email"])
+ require.Equal(t, true, preview.UserInfo["email_verified"])
+}
+
+func TestClientPreviewBuilderRejectsInvalidScope(t *testing.T) {
+ db := testutils.NewDatabaseForTest(t)
+ signerKey, err := rsa.GenerateKey(rand.Reader, 2048)
+ require.NoError(t, err)
+
+ provider, err := newProvider(NewStore(db), nil, testTokenSigner{key: signerKey}, Config{ //nolint:gosec // static test-only provider secret
+ BaseURL: "https://issuer.example.com",
+ TokenBaseURL: "https://issuer.example.com",
+ Secret: "test-secret",
+ })
+ require.NoError(t, err)
+
+ builder := newClientPreviewBuilder(newClaimsService(db, nil, "https://issuer.example.com", nil), provider.tokenStrategies)
+ _, err = builder.BuildClientPreview(t.Context(), model.OidcClient{
+ Base: model.Base{ID: "test-client"},
+ Name: "Test Client",
+ }, "test-user", []string{"openid", "unknown"}, "")
+ require.Error(t, err)
+ require.ErrorContains(t, err, "invalid_scope")
+}
+
+func stringSliceClaim(t *testing.T, value any) []string {
+ t.Helper()
+
+ switch typed := value.(type) {
+ case []string:
+ return typed
+ case []any:
+ values := make([]string, 0, len(typed))
+ for _, item := range typed {
+ value, ok := item.(string)
+ require.Truef(t, ok, "expected string claim item, got %T", item)
+ values = append(values, value)
+ }
+ return values
+ case string:
+ return []string{typed}
+ default:
+ require.Failf(t, "unexpected claim type", "expected string slice claim, got %T", value)
+ return nil
+ }
+}
diff --git a/backend/internal/oidc/provider.go b/backend/internal/oidc/provider.go
new file mode 100644
index 00000000..8d7e70fa
--- /dev/null
+++ b/backend/internal/oidc/provider.go
@@ -0,0 +1,137 @@
+package oidc
+
+import (
+ "context"
+ "crypto/sha256"
+ "io"
+ "log/slog"
+ "net/url"
+ "time"
+
+ "github.com/ory/fosite"
+ "github.com/ory/fosite/compose"
+ fositeoauth2 "github.com/ory/fosite/handler/oauth2"
+ "github.com/ory/fosite/handler/openid"
+ "github.com/ory/fosite/handler/rfc8628"
+ "github.com/pocket-id/pocket-id/backend/internal/utils"
+ "golang.org/x/crypto/hkdf"
+)
+
+type oidcProvider struct {
+ fosite.OAuth2Provider
+ deviceStrategy *rfc8628.DefaultDeviceStrategy
+ tokenStrategies
+}
+
+type tokenStrategies struct {
+ accessToken fositeoauth2.AccessTokenStrategy
+ idToken openid.OpenIDConnectTokenStrategy
+ config *fosite.Config
+}
+
+func newProvider(store *Store, authenticator *federatedClientAuthenticator, signer TokenSigner, config Config) (*oidcProvider, error) {
+ secret, err := DeriveGlobalSecret(config.Secret)
+ if err != nil {
+ return nil, err
+ }
+
+ var fositeConfig = &fosite.Config{
+ RefreshTokenLifespan: 30 * 24 * time.Hour,
+ DeviceAndUserCodeLifespan: 15 * time.Minute,
+ DeviceAuthTokenPollingInterval: 5 * time.Second,
+ DeviceVerificationURL: config.BaseURL + "/device",
+ PushedAuthorizeContextLifespan: 90 * time.Second,
+ IDTokenIssuer: config.BaseURL,
+ AccessTokenIssuer: config.BaseURL,
+ TokenURL: config.TokenBaseURL + "/api/oidc/token",
+ ScopeStrategy: fosite.ExactScopeStrategy,
+ AudienceMatchingStrategy: fosite.ExactAudienceMatchingStrategy,
+ RedirectURIMatcher: matchRedirectURI,
+ EnforcePKCEForPublicClients: true,
+ EnablePKCEPlainChallengeMethod: true,
+ RefreshTokenScopes: []string{},
+ GlobalSecret: secret,
+ }
+
+ keyGetter := func(context.Context) (interface{}, error) {
+ return SigningKeyFromSigner(signer)
+ }
+ sig := newJWTSigner(keyGetter)
+ coreStrategy := compose.NewOAuth2HMACStrategy(fositeConfig)
+ deviceStrategy := compose.NewDeviceStrategy(fositeConfig)
+ accessTokenStrategy := &fositeoauth2.DefaultJWTStrategy{
+ Signer: sig,
+ HMACSHAStrategy: coreStrategy,
+ Config: fositeConfig,
+ }
+ idTokenStrategy := &openid.DefaultStrategy{
+ Signer: sig,
+ Config: fositeConfig,
+ }
+ provider := compose.Compose(
+ fositeConfig,
+ store,
+ &compose.CommonStrategy{
+ CoreStrategy: accessTokenStrategy,
+ RFC8628CodeStrategy: deviceStrategy,
+ OpenIDConnectTokenStrategy: idTokenStrategy,
+ Signer: sig,
+ },
+ compose.OAuth2AuthorizeExplicitFactory,
+ compose.OAuth2ClientCredentialsGrantFactory,
+ compose.OAuth2RefreshTokenGrantFactory,
+ compose.RFC8628DeviceFactory,
+ compose.RFC8628DeviceAuthorizationTokenFactory,
+ compose.OpenIDConnectExplicitFactory,
+ compose.OpenIDConnectRefreshFactory,
+ compose.OpenIDConnectDeviceFactory,
+ compose.OAuth2TokenIntrospectionFactory,
+ compose.OAuth2PKCEFactory,
+ compose.PushedAuthorizeHandlerFactory,
+ ).(*fosite.Fosite)
+
+ fositeConfig.ClientAuthenticationStrategy = newClientAuthenticationStrategy(authenticator, provider)
+ return &oidcProvider{
+ OAuth2Provider: provider,
+ deviceStrategy: deviceStrategy,
+ tokenStrategies: tokenStrategies{
+ accessToken: accessTokenStrategy,
+ idToken: idTokenStrategy,
+ config: fositeConfig,
+ },
+ }, nil
+}
+
+func matchRedirectURI(rawurl string, client fosite.Client) (*url.URL, error) {
+ redirectURI, err := fosite.MatchRedirectURIWithClientRedirectURIs(rawurl, client)
+ if err == nil || rawurl == "" {
+ return redirectURI, err
+ }
+
+ invalidRedirectErr := err
+ matchedURL, matchErr := utils.GetCallbackURLFromList(client.GetRedirectURIs(), rawurl)
+ if matchErr != nil || matchedURL == "" {
+ slog.Debug("Redirect URI does not match any of the registered callback URLs", "rawurl", rawurl, "client_id", client.GetID(), "error", matchErr)
+ return nil, invalidRedirectErr
+ }
+
+ redirectURI, err = url.Parse(matchedURL)
+ if err != nil || !fosite.IsValidRedirectURI(redirectURI) {
+ slog.Debug("Matched callback URL is invalid", "matchedURL", matchedURL, "client_id", client.GetID(), "error", err)
+ return nil, invalidRedirectErr
+ }
+
+ return redirectURI, nil
+}
+
+// DeriveGlobalSecret derives a 32-byte secret from the provided secret.
+func DeriveGlobalSecret(secret string) ([]byte, error) {
+ const info = "pocketid/fosite_global_secret"
+ r := hkdf.New(sha256.New, []byte(secret), nil, []byte(info))
+
+ key := make([]byte, 32)
+ if _, err := io.ReadFull(r, key); err != nil {
+ return nil, err
+ }
+ return key, nil
+}
diff --git a/backend/internal/oidc/provider_test.go b/backend/internal/oidc/provider_test.go
new file mode 100644
index 00000000..8d9b5a03
--- /dev/null
+++ b/backend/internal/oidc/provider_test.go
@@ -0,0 +1,272 @@
+package oidc
+
+import (
+ "crypto/ecdsa"
+ "crypto/ed25519"
+ "crypto/elliptic"
+ "crypto/rand"
+ "crypto/rsa"
+ "encoding/base64"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/lestrrat-go/jwx/v3/jwa"
+ "github.com/ory/fosite"
+ "github.com/pocket-id/pocket-id/backend/internal/model"
+ testutils "github.com/pocket-id/pocket-id/backend/internal/utils/testing"
+ "github.com/stretchr/testify/require"
+)
+
+type testTokenSigner struct {
+ key *rsa.PrivateKey
+}
+
+func (s testTokenSigner) GetPrivateKey() any {
+ return s.key
+}
+
+func (s testTokenSigner) GetKeyAlg() (jwa.KeyAlgorithm, error) {
+ return jwa.RS256(), nil
+}
+
+func (s testTokenSigner) GetKeyID() (string, bool) {
+ return "test-key-id", true
+}
+
+func TestProviderIssuesJWTAccessTokens(t *testing.T) {
+ db := testutils.NewDatabaseForTest(t)
+ signerKey, err := rsa.GenerateKey(rand.Reader, 2048)
+ require.NoError(t, err)
+
+ provider, err := newProvider(NewStore(db), nil, testTokenSigner{key: signerKey}, Config{ //nolint:gosec // static test-only provider secret
+ BaseURL: "https://issuer.example.com",
+ TokenBaseURL: "https://issuer.example.com",
+ Secret: "test-secret",
+ })
+ require.NoError(t, err)
+
+ session := NewEmptySession()
+ session.Subject = "test-user"
+ session.SetExpiresAt(fosite.AccessToken, time.Now().UTC().Add(time.Hour))
+
+ request := fosite.NewAccessRequest(session)
+ request.ID = "test-request"
+ request.Client = Client{OidcClient: model.OidcClient{Base: model.Base{ID: "test-client"}}}
+ request.GrantTypes = fosite.Arguments{string(fosite.GrantTypeClientCredentials)}
+ request.RequestedScope = fosite.Arguments{"openid"}
+ request.GrantedScope = fosite.Arguments{"openid"}
+ request.RequestedAudience = fosite.Arguments{"test-client"}
+ request.GrantedAudience = fosite.Arguments{"test-client"}
+
+ response, err := provider.NewAccessResponse(t.Context(), request)
+ require.NoError(t, err)
+ require.Len(t, strings.Split(response.GetAccessToken(), "."), 3)
+
+ // The issued JWT must carry a `kid` header matching the signing key so RPs can
+ // select the verification key from the published JWKS (esp. after key rotation).
+ header := decodeJWTPart(t, response.GetAccessToken(), 0)
+ require.Equal(t, "test-key-id", header["kid"])
+}
+
+func TestProviderAcceptsWildcardRedirectURI(t *testing.T) {
+ db := testutils.NewDatabaseForTest(t)
+ signerKey, err := rsa.GenerateKey(rand.Reader, 2048)
+ require.NoError(t, err)
+ require.NoError(t, db.Create(&model.OidcClient{
+ Base: model.Base{ID: "test-client"},
+ Name: "Test Client",
+ CallbackURLs: model.UrlList{"https://*.example.com/callback"},
+ }).Error)
+
+ provider, err := newProvider(NewStore(db), nil, testTokenSigner{key: signerKey}, Config{ //nolint:gosec // static test-only provider secret
+ BaseURL: "https://issuer.example.com",
+ TokenBaseURL: "https://issuer.example.com",
+ Secret: "test-secret",
+ })
+ require.NoError(t, err)
+
+ const requestedRedirectURI = "https://tenant.example.com/callback"
+ req := httptest.NewRequestWithContext(
+ t.Context(),
+ http.MethodGet,
+ "/api/oidc/authorize?client_id=test-client&response_type=code&scope=openid&state=state-with-enough-entropy&redirect_uri="+requestedRedirectURI,
+ nil,
+ )
+
+ ar, err := provider.NewAuthorizeRequest(req.Context(), req)
+ require.NoError(t, err)
+ require.Equal(t, requestedRedirectURI, ar.GetRedirectURI().String())
+ require.True(t, ar.IsRedirectURIValid())
+ require.NotContains(t, ar.GetClient().GetRedirectURIs(), requestedRedirectURI)
+}
+
+func TestProviderAcceptsPushedAuthorizationWildcardRedirectURI(t *testing.T) {
+ db := testutils.NewDatabaseForTest(t)
+ signerKey, err := rsa.GenerateKey(rand.Reader, 2048)
+ require.NoError(t, err)
+ require.NoError(t, db.Create(&model.OidcClient{
+ Base: model.Base{ID: "test-client"},
+ Name: "Test Client",
+ CallbackURLs: model.UrlList{"https://*.example.com/callback"},
+ IsPublic: true,
+ }).Error)
+
+ provider, err := newProvider(NewStore(db), nil, testTokenSigner{key: signerKey}, Config{ //nolint:gosec // static test-only provider secret
+ BaseURL: "https://issuer.example.com",
+ TokenBaseURL: "https://issuer.example.com",
+ Secret: "test-secret",
+ })
+ require.NoError(t, err)
+
+ const requestedRedirectURI = "https://tenant.example.com/callback"
+ req := httptest.NewRequestWithContext(
+ t.Context(),
+ http.MethodPost,
+ "/api/oidc/par?client_id=test-client&response_type=code&scope=openid&state=state-with-enough-entropy&redirect_uri="+requestedRedirectURI,
+ nil,
+ )
+
+ ar, err := provider.NewPushedAuthorizeRequest(req.Context(), req)
+ require.NoError(t, err)
+ require.Equal(t, requestedRedirectURI, ar.GetRedirectURI().String())
+ require.True(t, ar.IsRedirectURIValid())
+ require.NotContains(t, ar.GetClient().GetRedirectURIs(), requestedRedirectURI)
+}
+
+func TestProviderRejectsUnmatchedWildcardRedirectURI(t *testing.T) {
+ db := testutils.NewDatabaseForTest(t)
+ signerKey, err := rsa.GenerateKey(rand.Reader, 2048)
+ require.NoError(t, err)
+ require.NoError(t, db.Create(&model.OidcClient{
+ Base: model.Base{ID: "test-client"},
+ Name: "Test Client",
+ CallbackURLs: model.UrlList{"https://*.example.com/callback"},
+ }).Error)
+
+ provider, err := newProvider(NewStore(db), nil, testTokenSigner{key: signerKey}, Config{ //nolint:gosec // static test-only provider secret
+ BaseURL: "https://issuer.example.com",
+ TokenBaseURL: "https://issuer.example.com",
+ Secret: "test-secret",
+ })
+ require.NoError(t, err)
+
+ const requestedRedirectURI = "https://evil.example.net/callback"
+ req := httptest.NewRequestWithContext(
+ t.Context(),
+ http.MethodGet,
+ "/api/oidc/authorize?client_id=test-client&response_type=code&scope=openid&state=state-with-enough-entropy&redirect_uri="+requestedRedirectURI,
+ nil,
+ )
+
+ _, err = provider.NewAuthorizeRequest(req.Context(), req)
+ require.ErrorIs(t, err, fosite.ErrInvalidRequest)
+}
+
+// decodeJWTPart base64url-decodes the header (index 0) or claims (index 1) segment of a
+// JWT without verifying the signature, for assertions in tests.
+func decodeJWTPart(t *testing.T, token string, index int) map[string]any {
+ t.Helper()
+ parts := strings.Split(token, ".")
+ require.Len(t, parts, 3)
+ raw, err := base64.RawURLEncoding.DecodeString(parts[index])
+ require.NoError(t, err)
+ var out map[string]any
+ require.NoError(t, json.Unmarshal(raw, &out))
+ return out
+}
+
+type algTestSigner struct {
+ key any
+ alg jwa.KeyAlgorithm
+}
+
+func (s algTestSigner) GetPrivateKey() any { return s.key }
+func (s algTestSigner) GetKeyAlg() (jwa.KeyAlgorithm, error) { return s.alg, nil }
+func (s algTestSigner) GetKeyID() (string, bool) { return "test-key-id", true }
+
+// TestProviderIssuesAndValidatesTokensForSupportedAlgorithms guards against the
+// regression where fosite's DefaultSigner derived the JWT algorithm from the Go key type
+// alone: it broke EdDSA/ES384/ES512 token issuance entirely and silently downgraded
+// RS384/RS512 to RS256. The provider must both ISSUE (NewAccessResponse) and VALIDATE
+// (IntrospectToken -> Signer.Decode, the path used by introspection and userinfo) tokens
+// for every signing algorithm Pocket ID supports.
+func TestProviderIssuesAndValidatesTokensForSupportedAlgorithms(t *testing.T) {
+ cases := []struct {
+ name string
+ alg jwa.KeyAlgorithm
+ gen func(t *testing.T) any
+ }{
+ {"RS256", jwa.RS256(), generateRSATestKey},
+ {"RS384", jwa.RS384(), generateRSATestKey},
+ {"RS512", jwa.RS512(), generateRSATestKey},
+ {"ES256", jwa.ES256(), func(t *testing.T) any { return generateECTestKey(t, elliptic.P256()) }},
+ {"ES384", jwa.ES384(), func(t *testing.T) any { return generateECTestKey(t, elliptic.P384()) }},
+ {"ES512", jwa.ES512(), func(t *testing.T) any { return generateECTestKey(t, elliptic.P521()) }},
+ {"EdDSA", jwa.EdDSA(), generateEd25519TestKey},
+ }
+
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ db := testutils.NewDatabaseForTest(t)
+ require.NoError(t, db.Create(&model.OidcClient{Base: model.Base{ID: "test-client"}, Name: "Test Client"}).Error)
+
+ provider, err := newProvider(NewStore(db), nil, algTestSigner{key: tc.gen(t), alg: tc.alg}, Config{ //nolint:gosec // static test-only provider secret
+ BaseURL: "https://issuer.example.com",
+ TokenBaseURL: "https://issuer.example.com",
+ Secret: "test-secret",
+ })
+ require.NoError(t, err)
+
+ session := NewEmptySession()
+ session.Subject = "test-user"
+ session.SetExpiresAt(fosite.AccessToken, time.Now().UTC().Add(time.Hour))
+
+ request := fosite.NewAccessRequest(session)
+ request.ID = "test-request-" + tc.name
+ request.Client = Client{OidcClient: model.OidcClient{Base: model.Base{ID: "test-client"}}}
+ request.GrantTypes = fosite.Arguments{string(fosite.GrantTypeClientCredentials)}
+ request.RequestedScope = fosite.Arguments{"openid"}
+ request.GrantedScope = fosite.Arguments{"openid"}
+ request.RequestedAudience = fosite.Arguments{"test-client"}
+ request.GrantedAudience = fosite.Arguments{"test-client"}
+
+ response, err := provider.NewAccessResponse(t.Context(), request)
+ require.NoError(t, err)
+
+ accessToken := response.GetAccessToken()
+ require.Len(t, strings.Split(accessToken, "."), 3)
+ header := decodeJWTPart(t, accessToken, 0)
+ require.Equal(t, tc.alg.String(), header["alg"])
+
+ tokenUse, introspected, err := provider.IntrospectToken(t.Context(), accessToken, fosite.AccessToken, NewEmptySession())
+ require.NoError(t, err)
+ require.Equal(t, fosite.AccessToken, tokenUse)
+ require.Equal(t, "test-client", introspected.GetClient().GetID())
+ })
+ }
+}
+
+func generateRSATestKey(t *testing.T) any {
+ t.Helper()
+ key, err := rsa.GenerateKey(rand.Reader, 2048)
+ require.NoError(t, err)
+ return key
+}
+
+func generateECTestKey(t *testing.T, curve elliptic.Curve) any {
+ t.Helper()
+ key, err := ecdsa.GenerateKey(curve, rand.Reader)
+ require.NoError(t, err)
+ return key
+}
+
+func generateEd25519TestKey(t *testing.T) any {
+ t.Helper()
+ _, key, err := ed25519.GenerateKey(rand.Reader)
+ require.NoError(t, err)
+ return key
+}
diff --git a/backend/internal/oidc/session.go b/backend/internal/oidc/session.go
new file mode 100644
index 00000000..4c0c0e09
--- /dev/null
+++ b/backend/internal/oidc/session.go
@@ -0,0 +1,148 @@
+package oidc
+
+import (
+ "encoding/json"
+ "time"
+
+ "github.com/google/uuid"
+ "github.com/ory/fosite"
+ fositeoauth2 "github.com/ory/fosite/handler/oauth2"
+ "github.com/ory/fosite/handler/openid"
+ fositejwt "github.com/ory/fosite/token/jwt"
+)
+
+var _ openid.Session = (*Session)(nil)
+var _ fositeoauth2.JWTSessionContainer = (*Session)(nil)
+
+type Session struct {
+ Claims *fositejwt.IDTokenClaims `json:"id_token_claims"`
+ Headers *fositejwt.Headers `json:"headers"`
+ JWTClaims *fositejwt.JWTClaims `json:"jwt_claims,omitempty"`
+ JWTHeader *fositejwt.Headers `json:"jwt_header,omitempty"`
+ ExpiresAt map[fosite.TokenType]time.Time `json:"expires_at,omitempty"`
+ Subject string `json:"subject"`
+ AuthenticationMethod string `json:"authentication_method,omitempty"`
+}
+
+func NewEmptySession() *Session {
+ return &Session{
+ Claims: &fositejwt.IDTokenClaims{
+ RequestedAt: time.Now().UTC(),
+ Extra: map[string]interface{}{},
+ },
+ }
+}
+
+func NewAuthenticatedSession(subject, authenticationMethod string, authenticationTime, requestedAt time.Time) *Session {
+ now := time.Now().UTC()
+ if authenticationTime.IsZero() {
+ authenticationTime = now
+ }
+ if requestedAt.IsZero() {
+ requestedAt = now
+ }
+
+ session := NewEmptySession()
+ session.Subject = subject
+ session.AuthenticationMethod = authenticationMethod
+ session.Claims.Subject = subject
+ session.Claims.AuthTime = authenticationTime.UTC()
+ session.Claims.RequestedAt = requestedAt.UTC()
+ session.Claims.JTI = uuid.NewString()
+
+ return session
+}
+
+func (s *Session) SetExpiresAt(key fosite.TokenType, exp time.Time) {
+ if s.ExpiresAt == nil {
+ s.ExpiresAt = make(map[fosite.TokenType]time.Time)
+ }
+ s.ExpiresAt[key] = exp
+}
+
+func (s *Session) GetExpiresAt(key fosite.TokenType) time.Time {
+ if s.ExpiresAt == nil {
+ s.ExpiresAt = make(map[fosite.TokenType]time.Time)
+ }
+ return s.ExpiresAt[key]
+}
+
+func (s *Session) GetUsername() string {
+ return s.GetSubject()
+}
+
+func (s *Session) GetExtraClaims() map[string]interface{} {
+ if s == nil || s.Claims == nil || s.Claims.Issuer == "" {
+ return map[string]interface{}{}
+ }
+
+ return map[string]interface{}{
+ "iss": s.Claims.Issuer,
+ }
+}
+
+func (s *Session) GetSubject() string {
+ if s == nil {
+ return ""
+ }
+ return s.Subject
+}
+
+func (s *Session) Clone() fosite.Session {
+ if s == nil {
+ return nil
+ }
+
+ var clone Session
+ data, err := json.Marshal(s)
+ if err != nil {
+ return NewEmptySession()
+ }
+ if err = json.Unmarshal(data, &clone); err != nil {
+ return NewEmptySession()
+ }
+ return &clone
+}
+
+func (s *Session) IDTokenClaims() *fositejwt.IDTokenClaims {
+ if s.Claims == nil {
+ s.Claims = &fositejwt.IDTokenClaims{}
+ }
+ if s.Claims.Extra == nil {
+ s.Claims.Extra = map[string]interface{}{}
+ }
+ return s.Claims
+}
+
+func (s *Session) IDTokenHeaders() *fositejwt.Headers {
+ if s.Headers == nil {
+ s.Headers = &fositejwt.Headers{}
+ }
+ if s.Headers.Extra == nil {
+ s.Headers.Extra = map[string]interface{}{}
+ }
+ return s.Headers
+}
+
+func (s *Session) GetJWTClaims() fositejwt.JWTClaimsContainer {
+ if s.JWTClaims == nil {
+ s.JWTClaims = &fositejwt.JWTClaims{}
+ }
+ if s.JWTClaims.Subject == "" {
+ s.JWTClaims.Subject = s.GetSubject()
+ }
+ if s.JWTClaims.Extra == nil {
+ s.JWTClaims.Extra = map[string]interface{}{}
+ }
+ return s.JWTClaims
+}
+
+func (s *Session) GetJWTHeader() *fositejwt.Headers {
+ if s.JWTHeader == nil {
+ s.JWTHeader = &fositejwt.Headers{}
+ }
+ if s.JWTHeader.Extra == nil {
+ s.JWTHeader.Extra = map[string]interface{}{}
+ }
+ return s.JWTHeader
+}
diff --git a/backend/internal/oidc/session_test.go b/backend/internal/oidc/session_test.go
new file mode 100644
index 00000000..c641de1a
--- /dev/null
+++ b/backend/internal/oidc/session_test.go
@@ -0,0 +1,34 @@
+package oidc
+
+import (
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/require"
+)
+
+func TestNewAuthenticatedSession(t *testing.T) {
+ authenticationTime := time.Date(2026, 6, 16, 10, 0, 0, 0, time.FixedZone("CEST", 2*60*60))
+ requestedAt := time.Date(2026, 6, 16, 9, 59, 0, 0, time.FixedZone("CEST", 2*60*60))
+
+ session := NewAuthenticatedSession("user-id", "passkey", authenticationTime, requestedAt)
+
+ require.Equal(t, "user-id", session.Subject)
+ require.Equal(t, "user-id", session.Claims.Subject)
+ require.Equal(t, "passkey", session.AuthenticationMethod)
+ require.Equal(t, authenticationTime.UTC(), session.Claims.AuthTime)
+ require.Equal(t, requestedAt.UTC(), session.Claims.RequestedAt)
+ require.NotNil(t, session.Claims.Extra)
+}
+
+func TestNewAuthenticatedSessionDefaultsTimes(t *testing.T) {
+ before := time.Now().UTC()
+ session := NewAuthenticatedSession("user-id", "passkey", time.Time{}, time.Time{})
+ after := time.Now().UTC()
+
+ require.False(t, session.Claims.AuthTime.IsZero())
+ require.False(t, session.Claims.RequestedAt.IsZero())
+ require.False(t, session.Claims.AuthTime.Before(before))
+ require.False(t, session.Claims.AuthTime.After(after))
+ require.Equal(t, session.Claims.AuthTime, session.Claims.RequestedAt)
+}
diff --git a/backend/internal/oidc/signer.go b/backend/internal/oidc/signer.go
new file mode 100644
index 00000000..3d4d0ce6
--- /dev/null
+++ b/backend/internal/oidc/signer.go
@@ -0,0 +1,71 @@
+package oidc
+
+import (
+ "context"
+ "errors"
+
+ jose "github.com/go-jose/go-jose/v4"
+ fositejwt "github.com/ory/fosite/token/jwt"
+)
+
+// SigningKeyFromSigner wraps the raw signing key in a *jose.JSONWebKey that carries the
+// key's algorithm.
+//
+// fosite's DefaultSigner picks the JOSE algorithm from the Go key TYPE alone: every
+// *rsa.PrivateKey is signed with RS256 and every *ecdsa.PrivateKey with ES256, while an
+// ed25519.PrivateKey is not supported at all.
+func SigningKeyFromSigner(signer TokenSigner) (*jose.JSONWebKey, error) {
+ rawKey := signer.GetPrivateKey()
+ if rawKey == nil {
+ return nil, errors.New("signing key is not available")
+ }
+
+ alg, err := signer.GetKeyAlg()
+ if err != nil {
+ return nil, err
+ }
+
+ signingKey := &jose.JSONWebKey{
+ Key: rawKey,
+ Algorithm: alg.String(),
+ }
+
+ if keyID, ok := signer.GetKeyID(); ok {
+ signingKey.KeyID = keyID
+ }
+
+ return signingKey, nil
+}
+
+type jwtSigner struct {
+ *fositejwt.DefaultSigner
+}
+
+func newJWTSigner(keyGetter fositejwt.GetPrivateKeyFunc) *jwtSigner {
+ return &jwtSigner{
+ DefaultSigner: &fositejwt.DefaultSigner{GetPrivateKey: keyGetter},
+ }
+}
+
+func (s *jwtSigner) Validate(ctx context.Context, token string) (string, error) {
+ if _, err := s.Decode(ctx, token); err != nil {
+ return "", err
+ }
+ return s.GetSignature(ctx, token)
+}
+
+func (s *jwtSigner) Decode(ctx context.Context, token string) (*fositejwt.Token, error) {
+ key, err := s.GetPrivateKey(ctx)
+ if err != nil {
+ return nil, err
+ }
+
+ verificationKey := key
+ if jsonWebKey, ok := key.(*jose.JSONWebKey); ok {
+ verificationKey = new(jsonWebKey.Public())
+ }
+
+ return fositejwt.Parse(token, func(*fositejwt.Token) (any, error) {
+ return verificationKey, nil
+ })
+}
diff --git a/backend/internal/oidc/store.go b/backend/internal/oidc/store.go
new file mode 100644
index 00000000..b6991043
--- /dev/null
+++ b/backend/internal/oidc/store.go
@@ -0,0 +1,818 @@
+package oidc
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "net/url"
+ "time"
+
+ "github.com/ory/fosite"
+ fositeoauth2 "github.com/ory/fosite/handler/oauth2"
+ "github.com/ory/fosite/handler/openid"
+ "github.com/ory/fosite/handler/pkce"
+ "github.com/ory/fosite/handler/rfc8628"
+ fositestorage "github.com/ory/fosite/storage"
+ "github.com/pocket-id/pocket-id/backend/internal/model"
+ datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
+ "gorm.io/gorm"
+ "gorm.io/gorm/clause"
+)
+
+const (
+ sessionKindAuthorizeCode = "authorize_code"
+ sessionKindAccessToken = "access_token"
+ sessionKindRefreshToken = "refresh_token"
+ sessionKindPKCE = "pkce"
+ sessionKindOpenID = "openid"
+ sessionKindPAR = "par"
+ sessionKindDeviceCode = "device_code"
+ sessionKindUserCode = "user_code"
+)
+
+var (
+ _ fosite.Storage = (*Store)(nil)
+ _ fosite.PARStorage = (*Store)(nil)
+ _ fositeoauth2.CoreStorage = (*Store)(nil)
+ _ fositeoauth2.TokenRevocationStorage = (*Store)(nil)
+ _ rfc8628.RFC8628CoreStorage = (*Store)(nil)
+ _ openid.OpenIDConnectRequestStorage = (*Store)(nil)
+ _ pkce.PKCERequestStorage = (*Store)(nil)
+ _ fositestorage.Transactional = (*Store)(nil)
+)
+
+// NewStore creates the fosite storage. Exported for packages that need to seed or
+// revoke sessions (e.g. the e2e test service).
+func NewStore(db *gorm.DB) *Store {
+ return &Store{db: db}
+}
+
+type Store struct {
+ db *gorm.DB
+}
+
+type storedRequester struct {
+ Authorize bool `json:"authorize,omitempty"`
+
+ ID string `json:"id"`
+ RequestedAt time.Time `json:"requested_at"`
+ ClientID string `json:"client_id"`
+ RequestedScope fosite.Arguments `json:"requested_scope,omitempty"`
+ GrantedScope fosite.Arguments `json:"granted_scope,omitempty"`
+ Form url.Values `json:"form,omitempty"`
+ Session *Session `json:"session,omitempty"`
+ RequestedAudience fosite.Arguments `json:"requested_audience,omitempty"`
+ GrantedAudience fosite.Arguments `json:"granted_audience,omitempty"`
+ Device bool `json:"device,omitempty"`
+ UserCodeState fosite.UserCodeState `json:"user_code_state,omitempty"`
+
+ ResponseTypes fosite.Arguments `json:"response_types,omitempty"`
+ RedirectURI string `json:"redirect_uri,omitempty"`
+ State string `json:"state,omitempty"`
+ HandledResponseTypes fosite.Arguments `json:"handled_response_types,omitempty"`
+ ResponseMode fosite.ResponseModeType `json:"response_mode,omitempty"`
+ DefaultResponseMode fosite.ResponseModeType `json:"default_response_mode,omitempty"`
+}
+
+// Satisfies fosite.Storage
+
+func (s *Store) GetClient(ctx context.Context, id string) (fosite.Client, error) {
+ var client model.OidcClient
+ err := s.dbFor(ctx).
+ Preload("AllowedUserGroups").
+ First(&client, "id = ?", id).
+ Error
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ return nil, fosite.ErrNotFound
+ }
+ if err != nil {
+ return nil, err
+ }
+
+ return Client{OidcClient: client}, nil
+}
+
+func (s *Store) ClientAssertionJWTValid(ctx context.Context, jti string) error {
+ var count int64
+ err := s.dbFor(ctx).
+ Model(&clientAssertionJTI{}).
+ Where("jti = ? AND expires_at > ?", jti, datatype.DateTime(time.Now())).
+ Count(&count).
+ Error
+ if err != nil {
+ return err
+ }
+ if count > 0 {
+ return fosite.ErrJTIKnown
+ }
+ return nil
+}
+
+func (s *Store) SetClientAssertionJWT(ctx context.Context, jti string, exp time.Time) error {
+ err := s.dbFor(ctx).Create(&clientAssertionJTI{
+ JTI: jti,
+ ExpiresAt: datatype.DateTime(exp),
+ }).Error
+ if errors.Is(err, gorm.ErrDuplicatedKey) {
+ return fosite.ErrJTIKnown
+ }
+ return err
+}
+
+// Satisfies fositeoauth2.CoreStorage
+
+func (s *Store) CreateAuthorizeCodeSession(ctx context.Context, code string, request fosite.Requester) error {
+ return s.upsertSession(ctx, sessionKindAuthorizeCode, code, request, "", true, fosite.AuthorizeCode)
+}
+
+func (s *Store) GetAuthorizeCodeSession(ctx context.Context, code string, _ fosite.Session) (fosite.Requester, error) {
+ request, active, err := s.getRequesterSession(ctx, sessionKindAuthorizeCode, code)
+ if err != nil {
+ return nil, err
+ }
+ if !active {
+ return request, fosite.ErrInvalidatedAuthorizeCode
+ }
+ return request, nil
+}
+
+func (s *Store) InvalidateAuthorizeCodeSession(ctx context.Context, code string) error {
+ return s.deactivateSession(ctx, sessionKindAuthorizeCode, code)
+}
+
+func (s *Store) CreateAccessTokenSession(ctx context.Context, signature string, request fosite.Requester) error {
+ return s.upsertSession(ctx, sessionKindAccessToken, signature, request, "", true, fosite.AccessToken)
+}
+
+func (s *Store) GetAccessTokenSession(ctx context.Context, signature string, _ fosite.Session) (fosite.Requester, error) {
+ request, _, err := s.getRequesterSession(ctx, sessionKindAccessToken, signature)
+ return request, err
+}
+
+func (s *Store) DeleteAccessTokenSession(ctx context.Context, signature string) error {
+ return s.deleteSession(ctx, sessionKindAccessToken, signature)
+}
+
+func (s *Store) CreateRefreshTokenSession(ctx context.Context, signature string, accessSignature string, request fosite.Requester) error {
+ return s.upsertSession(ctx, sessionKindRefreshToken, signature, request, accessSignature, true, fosite.RefreshToken)
+}
+
+func (s *Store) GetRefreshTokenSession(ctx context.Context, signature string, _ fosite.Session) (fosite.Requester, error) {
+ request, active, err := s.getRequesterSession(ctx, sessionKindRefreshToken, signature)
+ if err != nil {
+ return nil, err
+ }
+ if !active {
+ return request, fosite.ErrInactiveToken
+ }
+ return request, nil
+}
+
+func (s *Store) DeleteRefreshTokenSession(ctx context.Context, signature string) error {
+ return s.deleteSession(ctx, sessionKindRefreshToken, signature)
+}
+
+func (s *Store) RotateRefreshToken(ctx context.Context, requestID string, refreshTokenSignature string) error {
+ if err := s.deactivateSession(ctx, sessionKindRefreshToken, refreshTokenSignature); err != nil {
+ return err
+ }
+ return s.RevokeAccessToken(ctx, requestID)
+}
+
+// Satisfies fositeoauth2.TokenRevocationStorage
+
+func (s *Store) RevokeRefreshToken(ctx context.Context, requestID string) error {
+ return s.dbFor(ctx).
+ Model(&OAuth2Session{}).
+ Where("kind = ? AND request_id = ?", sessionKindRefreshToken, requestID).
+ Update("active", false).
+ Error
+}
+
+func (s *Store) RevokeAccessToken(ctx context.Context, requestID string) error {
+ return s.dbFor(ctx).
+ Where("kind = ? AND request_id = ?", sessionKindAccessToken, requestID).
+ Delete(&OAuth2Session{}).
+ Error
+}
+
+func (s *Store) RevokeSessionsByIDTokenHint(ctx context.Context, userID, clientID, idTokenJTI string) error {
+ _, jtiMatches, err := s.findUserClientRequestIDs(ctx, userID, clientID, idTokenJTI)
+ if err != nil {
+ return err
+ }
+
+ return s.revokeRequestIDs(ctx, jtiMatches)
+}
+
+func RevokeUserClientSessions(ctx context.Context, db *gorm.DB, userID, clientID string) error {
+ s := NewStore(db)
+ requestIDs, _, err := s.findUserClientRequestIDs(ctx, userID, clientID, "")
+ if err != nil {
+ return err
+ }
+ return s.revokeRequestIDs(ctx, requestIDs)
+}
+
+func (s *Store) findUserClientRequestIDs(ctx context.Context, userID, clientID, idTokenJTI string) (candidates []string, jtiMatches []string, err error) {
+ var sessions []OAuth2Session
+ err = s.dbFor(ctx).
+ Where("kind = ? AND active = ?", sessionKindRefreshToken, true).
+ Find(&sessions).
+ Error
+ if err != nil {
+ return nil, nil, err
+ }
+
+ candidateRequestIDs := map[string]struct{}{}
+ matchingRequestIDs := map[string]struct{}{}
+ for _, session := range sessions {
+ requester, err := s.decodeRequester(ctx, session.RequestData)
+ if err != nil {
+ return nil, nil, err
+ }
+ requestSession := requester.GetSession()
+ if requestSession == nil || requester.GetClient().GetID() != clientID || requestSession.GetSubject() != userID {
+ continue
+ }
+
+ // Add all sessions that match the user and client
+ candidateRequestIDs[session.RequestID] = struct{}{}
+ // Only add sessions that also match the ID token hint JTI
+ if storedSession, ok := requestSession.(*Session); ok && idTokenJTI != "" && storedSession.IDTokenClaims().JTI == idTokenJTI {
+ matchingRequestIDs[session.RequestID] = struct{}{}
+ }
+ }
+
+ return mapKeys(candidateRequestIDs), mapKeys(matchingRequestIDs), nil
+}
+
+func (s *Store) revokeRequestIDs(ctx context.Context, requestIDs []string) error {
+ if len(requestIDs) == 0 {
+ return nil
+ }
+
+ if err := s.dbFor(ctx).
+ Model(&OAuth2Session{}).
+ Where("kind = ? AND request_id IN ?", sessionKindRefreshToken, requestIDs).
+ Update("active", false).
+ Error; err != nil {
+ return err
+ }
+
+ return s.dbFor(ctx).
+ Where("kind = ? AND request_id IN ?", sessionKindAccessToken, requestIDs).
+ Delete(&OAuth2Session{}).
+ Error
+}
+
+func mapKeys(m map[string]struct{}) []string {
+ keys := make([]string, 0, len(m))
+ for key := range m {
+ keys = append(keys, key)
+ }
+ return keys
+}
+
+// Satisfies pkce.PKCERequestStorage
+
+func (s *Store) CreatePKCERequestSession(ctx context.Context, signature string, requester fosite.Requester) error {
+ // PKCE sessions share the authorize code lifespan so abandoned ones expire and get cleaned up
+ return s.upsertSession(ctx, sessionKindPKCE, signature, requester, "", true, fosite.AuthorizeCode)
+}
+
+func (s *Store) GetPKCERequestSession(ctx context.Context, signature string, _ fosite.Session) (fosite.Requester, error) {
+ request, _, err := s.getRequesterSession(ctx, sessionKindPKCE, signature)
+ return request, err
+}
+
+func (s *Store) DeletePKCERequestSession(ctx context.Context, signature string) error {
+ return s.deleteSession(ctx, sessionKindPKCE, signature)
+}
+
+// Satisfies openid.OpenIDConnectRequestStorage
+
+func (s *Store) CreateOpenIDConnectSession(ctx context.Context, authorizeCode string, requester fosite.Requester) error {
+ return s.upsertSession(ctx, sessionKindOpenID, authorizeCode, requester, "", true, fosite.AuthorizeCode)
+}
+
+func (s *Store) GetOpenIDConnectSession(ctx context.Context, authorizeCode string, _ fosite.Requester) (fosite.Requester, error) {
+ request, _, err := s.getRequesterSession(ctx, sessionKindOpenID, authorizeCode)
+ if errors.Is(err, fosite.ErrNotFound) {
+ return nil, openid.ErrNoSessionFound
+ }
+ return request, err
+}
+
+func (s *Store) DeleteOpenIDConnectSession(ctx context.Context, authorizeCode string) error {
+ return s.deleteSession(ctx, sessionKindOpenID, authorizeCode)
+}
+
+// Satisfies fosite.PARStorage
+
+func (s *Store) CreatePARSession(ctx context.Context, requestURI string, request fosite.AuthorizeRequester) error {
+ return s.upsertAuthorizeSession(ctx, sessionKindPAR, requestURI, request, true, fosite.PushedAuthorizeRequestContext)
+}
+
+func (s *Store) GetPARSession(ctx context.Context, requestURI string) (fosite.AuthorizeRequester, error) {
+ session, err := s.getSession(ctx, sessionKindPAR, requestURI)
+ if err != nil {
+ return nil, err
+ }
+ if !session.Active || session.ExpiresAt == nil || session.ExpiresAt.ToTime().Before(time.Now()) {
+ return nil, fosite.ErrNotFound
+ }
+
+ result := s.dbFor(ctx).
+ Model(&OAuth2Session{}).
+ Where("kind = ? AND key = ? AND active = ?", sessionKindPAR, requestURI, true).
+ Update("active", false)
+ if result.Error != nil {
+ return nil, result.Error
+ }
+ if result.RowsAffected == 0 {
+ return nil, fosite.ErrNotFound
+ }
+
+ return s.decodeAuthorizeRequester(ctx, session.RequestData)
+}
+
+func (s *Store) DeletePARSession(ctx context.Context, requestURI string) error {
+ return s.deleteSession(ctx, sessionKindPAR, requestURI)
+}
+
+// Satisfies rfc8628.RFC8628CoreStorage
+
+func (s *Store) CreateDeviceAuthSession(ctx context.Context, deviceCodeSignature, userCodeSignature string, request fosite.DeviceRequester) error {
+ requestData, err := s.encodeDeviceRequester(request)
+ if err != nil {
+ return err
+ }
+
+ if _, err := s.getSession(ctx, sessionKindUserCode, userCodeSignature); err == nil {
+ return fosite.ErrExistingUserCodeSignature
+ } else if !errors.Is(err, fosite.ErrNotFound) {
+ return err
+ }
+
+ expDeviceCode := expiresAt(request.GetSession(), fosite.DeviceCode)
+ expUserCode := expiresAt(request.GetSession(), fosite.UserCode)
+ if err := s.storeSession(ctx, sessionKindDeviceCode, deviceCodeSignature, request.GetID(), "", true, requestData, expDeviceCode); err != nil {
+ return err
+ }
+ return s.storeSession(ctx, sessionKindUserCode, userCodeSignature, request.GetID(), "", true, requestData, expUserCode)
+}
+
+func (s *Store) GetDeviceCodeSession(ctx context.Context, signature string, _ fosite.Session) (fosite.DeviceRequester, error) {
+ request, active, err := s.getDeviceRequesterSession(ctx, sessionKindDeviceCode, signature)
+ if err != nil {
+ return nil, err
+ }
+ if !active {
+ return request, fosite.ErrInvalidatedDeviceCode
+ }
+ return request, nil
+}
+
+func (s *Store) InvalidateDeviceCodeSession(ctx context.Context, signature string) error {
+ session, err := s.getSession(ctx, sessionKindDeviceCode, signature)
+ if err != nil {
+ return err
+ }
+
+ // Only flip rows that are still active so two concurrent token requests for the same
+ // device code cannot both pass the single-use check and each mint a token set.
+ result := s.dbFor(ctx).
+ Model(&OAuth2Session{}).
+ Where("kind IN ? AND request_id = ? AND active = ?", []string{sessionKindDeviceCode, sessionKindUserCode}, session.RequestID, true).
+ Update("active", false)
+ if result.Error != nil {
+ return result.Error
+ }
+ if result.RowsAffected == 0 {
+ return fosite.ErrNotFound
+ }
+ return nil
+}
+
+func (s *Store) GetDeviceCodeSessionByUserCodeSignature(ctx context.Context, signature string) (fosite.DeviceRequester, error) {
+ request, active, err := s.getDeviceRequesterSession(ctx, sessionKindUserCode, signature)
+ if err != nil {
+ return nil, err
+ }
+ if !active {
+ return nil, fosite.ErrNotFound
+ }
+ return request, nil
+}
+
+func (s *Store) AcceptDeviceCodeSessionByUserCodeSignature(ctx context.Context, signature string, request fosite.DeviceRequester) (string, error) {
+ userCodeSession, err := s.getSession(ctx, sessionKindUserCode, signature)
+ if err != nil {
+ return "", err
+ }
+ request.SetUserCodeState(fosite.UserCodeAccepted)
+
+ requestData, err := s.encodeDeviceRequester(request)
+ if err != nil {
+ return "", err
+ }
+
+ deviceCodeSession, err := s.getSessionByRequestID(ctx, sessionKindDeviceCode, userCodeSession.RequestID)
+ if err != nil {
+ return "", err
+ }
+
+ err = s.dbFor(ctx).
+ Model(&OAuth2Session{}).
+ Where("kind IN ? AND request_id = ?", []string{sessionKindDeviceCode, sessionKindUserCode}, userCodeSession.RequestID).
+ Updates(map[string]any{
+ "request_data": requestData,
+ }).
+ Error
+ if err != nil {
+ return "", err
+ }
+
+ return deviceCodeSession.Key, nil
+}
+
+// Satisfies fositestorage.Transactional
+
+func (s *Store) BeginTX(ctx context.Context) (context.Context, error) {
+ tx := s.db.WithContext(ctx).Begin()
+ if tx.Error != nil {
+ return ctx, tx.Error
+ }
+ return contextWithTx(ctx, tx), nil
+}
+
+func (s *Store) Commit(ctx context.Context) error {
+ tx, ok := ctx.Value(txContextKey{}).(*gorm.DB)
+ if !ok {
+ return nil
+ }
+ return tx.Commit().Error
+}
+
+func (s *Store) Rollback(ctx context.Context) error {
+ tx, ok := ctx.Value(txContextKey{}).(*gorm.DB)
+ if !ok {
+ return nil
+ }
+ return tx.Rollback().Error
+}
+
+func (s *Store) upsertSession(ctx context.Context, kind string, key string, requester fosite.Requester, accessTokenSignature string, active bool, expiresAtKey fosite.TokenType) error {
+ requestData, err := s.encodeRequester(requester)
+ if err != nil {
+ return err
+ }
+
+ return s.storeSession(ctx, kind, key, requester.GetID(), accessTokenSignature, active, requestData, expiresAt(requester.GetSession(), expiresAtKey))
+}
+
+func (s *Store) upsertAuthorizeSession(ctx context.Context, kind string, key string, requester fosite.AuthorizeRequester, active bool, expiresAtKey fosite.TokenType) error {
+ requestData, err := s.encodeAuthorizeRequester(requester)
+ if err != nil {
+ return err
+ }
+
+ return s.storeSession(ctx, kind, key, requester.GetID(), "", active, requestData, expiresAt(requester.GetSession(), expiresAtKey))
+}
+
+func (s *Store) storeSession(ctx context.Context, kind string, key string, requestID string, accessTokenSignature string, active bool, requestData string, exp *datatype.DateTime) error {
+ session := OAuth2Session{
+ Kind: kind,
+ Key: key,
+ RequestID: requestID,
+ AccessTokenSignature: accessTokenSignature,
+ Active: active,
+ RequestData: requestData,
+ ExpiresAt: exp,
+ }
+
+ return s.dbFor(ctx).
+ Clauses(clause.OnConflict{
+ Columns: []clause.Column{{Name: "kind"}, {Name: "key"}},
+ DoUpdates: clause.AssignmentColumns([]string{
+ "request_id",
+ "access_token_signature",
+ "active",
+ "request_data",
+ "expires_at",
+ }),
+ }).
+ Create(&session).
+ Error
+}
+
+func (s *Store) getRequesterSession(ctx context.Context, kind string, key string) (fosite.Requester, bool, error) {
+ session, err := s.getSession(ctx, kind, key)
+ if err != nil {
+ return nil, false, err
+ }
+
+ requester, err := s.decodeRequester(ctx, session.RequestData)
+ if err != nil {
+ return nil, false, err
+ }
+
+ return requester, session.Active, nil
+}
+
+func (s *Store) getDeviceRequesterSession(ctx context.Context, kind string, key string) (fosite.DeviceRequester, bool, error) {
+ session, err := s.getSession(ctx, kind, key)
+ if err != nil {
+ return nil, false, err
+ }
+
+ requester, err := s.decodeDeviceRequester(ctx, session.RequestData)
+ if err != nil {
+ return nil, false, err
+ }
+
+ return requester, session.Active, nil
+}
+
+func (s *Store) getSession(ctx context.Context, kind string, key string) (session OAuth2Session, err error) {
+ err = s.dbFor(ctx).
+ Where("kind = ? AND key = ?", kind, key).
+ First(&session).
+ Error
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ return session, fosite.ErrNotFound
+ }
+ if err != nil {
+ return session, err
+ }
+
+ return session, nil
+}
+
+func (s *Store) getSessionByRequestID(ctx context.Context, kind string, requestID string) (session OAuth2Session, err error) {
+ err = s.dbFor(ctx).
+ Where("kind = ? AND request_id = ?", kind, requestID).
+ First(&session).
+ Error
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ return session, fosite.ErrNotFound
+ }
+ if err != nil {
+ return session, err
+ }
+
+ return session, nil
+}
+
+func (s *Store) deleteSession(ctx context.Context, kind string, key string) error {
+ return s.dbFor(ctx).
+ Where("kind = ? AND key = ?", kind, key).
+ Delete(&OAuth2Session{}).
+ Error
+}
+
+func (s *Store) deactivateSession(ctx context.Context, kind string, key string) error {
+ result := s.dbFor(ctx).
+ Model(&OAuth2Session{}).
+ Where("kind = ? AND key = ? AND active = ?", kind, key, true).
+ Update("active", false)
+ if result.Error != nil {
+ return result.Error
+ }
+ if result.RowsAffected == 0 {
+ return fosite.ErrNotFound
+ }
+ return nil
+}
+
+func (s *Store) encodeRequester(requester fosite.Requester) (string, error) {
+ stored, err := s.storedRequesterFromRequester(requester)
+ if err != nil {
+ return "", err
+ }
+ data, err := json.Marshal(stored)
+ if err != nil {
+ return "", err
+ }
+ return string(data), nil
+}
+
+func (s *Store) encodeAuthorizeRequester(requester fosite.AuthorizeRequester) (string, error) {
+ stored, err := s.storedRequesterFromRequester(requester)
+ if err != nil {
+ return "", err
+ }
+
+ stored.Authorize = true
+ stored.ResponseTypes = cloneArguments(requester.GetResponseTypes())
+ if redirectURI := requester.GetRedirectURI(); redirectURI != nil {
+ stored.RedirectURI = redirectURI.String()
+ }
+ stored.State = requester.GetState()
+ stored.ResponseMode = requester.GetResponseMode()
+ stored.DefaultResponseMode = requester.GetDefaultResponseMode()
+
+ if ar, ok := requester.(*fosite.AuthorizeRequest); ok {
+ stored.HandledResponseTypes = cloneArguments(ar.HandledResponseTypes)
+ }
+
+ data, err := json.Marshal(stored)
+ if err != nil {
+ return "", err
+ }
+ return string(data), nil
+}
+
+func (s *Store) encodeDeviceRequester(requester fosite.DeviceRequester) (string, error) {
+ stored, err := s.storedRequesterFromRequester(requester)
+ if err != nil {
+ return "", err
+ }
+
+ stored.Device = true
+ stored.UserCodeState = requester.GetUserCodeState()
+
+ data, err := json.Marshal(stored)
+ if err != nil {
+ return "", err
+ }
+ return string(data), nil
+}
+
+func (s *Store) storedRequesterFromRequester(requester fosite.Requester) (storedRequester, error) {
+ if requester == nil {
+ return storedRequester{}, fosite.ErrServerError.WithHint("requester must not be nil")
+ }
+
+ return storedRequester{
+ ID: requester.GetID(),
+ RequestedAt: requester.GetRequestedAt(),
+ ClientID: requester.GetClient().GetID(),
+ RequestedScope: cloneArguments(requester.GetRequestedScopes()),
+ GrantedScope: cloneArguments(requester.GetGrantedScopes()),
+ Form: sanitizeStoredForm(requester.GetRequestForm()),
+ Session: cloneSession(requester.GetSession()),
+ RequestedAudience: cloneArguments(requester.GetRequestedAudience()),
+ GrantedAudience: cloneArguments(requester.GetGrantedAudience()),
+ }, nil
+}
+
+func (s *Store) decodeRequester(ctx context.Context, data string) (fosite.Requester, error) {
+ var stored storedRequester
+ if err := json.Unmarshal([]byte(data), &stored); err != nil {
+ return nil, err
+ }
+ if stored.Authorize {
+ return s.requesterFromStoredAuthorize(ctx, stored)
+ }
+ return s.requesterFromStored(ctx, stored)
+}
+
+func (s *Store) decodeAuthorizeRequester(ctx context.Context, data string) (fosite.AuthorizeRequester, error) {
+ var stored storedRequester
+ if err := json.Unmarshal([]byte(data), &stored); err != nil {
+ return nil, err
+ }
+ stored.Authorize = true
+ return s.requesterFromStoredAuthorize(ctx, stored)
+}
+
+func (s *Store) decodeDeviceRequester(ctx context.Context, data string) (fosite.DeviceRequester, error) {
+ var stored storedRequester
+ if err := json.Unmarshal([]byte(data), &stored); err != nil {
+ return nil, err
+ }
+ stored.Device = true
+ return s.requesterFromStoredDevice(ctx, stored)
+}
+
+func (s *Store) requesterFromStored(ctx context.Context, stored storedRequester) (fosite.Requester, error) {
+ client, err := s.GetClient(ctx, stored.ClientID)
+ if err != nil {
+ return nil, err
+ }
+
+ request := fosite.NewRequest()
+ request.ID = stored.ID
+ request.RequestedAt = stored.RequestedAt
+ request.Client = client
+ request.RequestedScope = cloneArguments(stored.RequestedScope)
+ request.GrantedScope = cloneArguments(stored.GrantedScope)
+ request.Form = cloneValues(stored.Form)
+ request.Session = stored.Session
+ request.RequestedAudience = cloneArguments(stored.RequestedAudience)
+ request.GrantedAudience = cloneArguments(stored.GrantedAudience)
+ return request, nil
+}
+
+func (s *Store) requesterFromStoredDevice(ctx context.Context, stored storedRequester) (fosite.DeviceRequester, error) {
+ requester, err := s.requesterFromStored(ctx, stored)
+ if err != nil {
+ return nil, err
+ }
+
+ base := requester.(*fosite.Request)
+ request := fosite.NewDeviceRequest()
+ request.Request = *base
+ request.UserCodeState = stored.UserCodeState
+ return request, nil
+}
+
+func (s *Store) requesterFromStoredAuthorize(ctx context.Context, stored storedRequester) (fosite.AuthorizeRequester, error) {
+ requester, err := s.requesterFromStored(ctx, stored)
+ if err != nil {
+ return nil, err
+ }
+
+ base := requester.(*fosite.Request)
+ request := fosite.NewAuthorizeRequest()
+ request.Request = *base
+ request.ResponseTypes = cloneArguments(stored.ResponseTypes)
+ request.State = stored.State
+ request.HandledResponseTypes = cloneArguments(stored.HandledResponseTypes)
+ request.ResponseMode = stored.ResponseMode
+ request.DefaultResponseMode = stored.DefaultResponseMode
+
+ if stored.RedirectURI != "" {
+ redirectURI, err := url.Parse(stored.RedirectURI)
+ if err != nil {
+ return nil, err
+ }
+ request.RedirectURI = redirectURI
+ }
+
+ return request, nil
+}
+
+func cloneSession(session fosite.Session) *Session {
+ if session == nil {
+ return nil
+ }
+
+ if s, ok := session.(*Session); ok {
+ cloned := s.Clone()
+ if typed, ok := cloned.(*Session); ok {
+ return typed
+ }
+ }
+
+ cloned := NewEmptySession()
+ cloned.Subject = session.GetSubject()
+ for _, tokenType := range []fosite.TokenType{
+ fosite.AccessToken,
+ fosite.RefreshToken,
+ fosite.AuthorizeCode,
+ fosite.IDToken,
+ fosite.PushedAuthorizeRequestContext,
+ fosite.DeviceCode,
+ fosite.UserCode,
+ } {
+ if exp := session.GetExpiresAt(tokenType); !exp.IsZero() {
+ cloned.SetExpiresAt(tokenType, exp)
+ }
+ }
+ return cloned
+}
+
+func cloneArguments(arguments fosite.Arguments) fosite.Arguments {
+ if len(arguments) == 0 {
+ return fosite.Arguments{}
+ }
+ cloned := make(fosite.Arguments, len(arguments))
+ copy(cloned, arguments)
+ return cloned
+}
+
+func sanitizeStoredForm(values url.Values) url.Values {
+ cloned := cloneValues(values)
+ cloned.Del("client_secret")
+ cloned.Del("client_assertion")
+ return cloned
+}
+
+func cloneValues(values url.Values) url.Values {
+ if len(values) == 0 {
+ return url.Values{}
+ }
+ cloned := make(url.Values, len(values))
+ for key, value := range values {
+ cloned[key] = append([]string(nil), value...)
+ }
+ return cloned
+}
+
+func expiresAt(session fosite.Session, tokenType fosite.TokenType) *datatype.DateTime {
+ if session == nil || tokenType == "" {
+ return nil
+ }
+ exp := session.GetExpiresAt(tokenType)
+ if exp.IsZero() {
+ return nil
+ }
+ return new(datatype.DateTime(exp))
+}
+
+func (s *Store) dbFor(ctx context.Context) *gorm.DB {
+ return dbFromContext(ctx, s.db)
+}
diff --git a/backend/internal/oidc/store_test.go b/backend/internal/oidc/store_test.go
new file mode 100644
index 00000000..143db987
--- /dev/null
+++ b/backend/internal/oidc/store_test.go
@@ -0,0 +1,219 @@
+package oidc
+
+import (
+ "net/url"
+ "testing"
+ "time"
+
+ "github.com/ory/fosite"
+ fositejwt "github.com/ory/fosite/token/jwt"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+
+ "github.com/pocket-id/pocket-id/backend/internal/model"
+ testutils "github.com/pocket-id/pocket-id/backend/internal/utils/testing"
+)
+
+// TestStoreGetPARSessionIsSingleUse verifies that a pushed-authorization request_uri can be
+// consumed only once (RFC 9126 §4). The consumption (read + invalidate) must be atomic so
+// two concurrent /authorize requests cannot both resolve the same request_uri; the store
+// enforces this with a conditional UPDATE, so the second GetPARSession returns ErrNotFound.
+func TestStoreGetPARSessionIsSingleUse(t *testing.T) {
+ db := testutils.NewDatabaseForTest(t)
+ store := NewStore(db)
+
+ const (
+ clientID = "par-client"
+ requestURI = "urn:ietf:params:oauth:request_uri:single-use-test"
+ )
+ require.NoError(t, db.Create(&model.OidcClient{Base: model.Base{ID: clientID}, Name: "PAR Client"}).Error)
+
+ session := NewEmptySession()
+ session.SetExpiresAt(fosite.PushedAuthorizeRequestContext, time.Now().UTC().Add(time.Minute))
+ redirectURI, err := url.Parse("https://rp.example.com/callback")
+ require.NoError(t, err)
+
+ request := &fosite.AuthorizeRequest{
+ Request: fosite.Request{
+ ID: "par-request",
+ RequestedAt: time.Now().UTC(),
+ Client: Client{OidcClient: model.OidcClient{Base: model.Base{ID: clientID}}},
+ RequestedScope: fosite.Arguments{"openid"},
+ Form: url.Values{},
+ Session: session,
+ },
+ RedirectURI: redirectURI,
+ ResponseTypes: fosite.Arguments{"code"},
+ State: "state-value",
+ }
+ require.NoError(t, store.CreatePARSession(t.Context(), requestURI, request))
+
+ // First consumption resolves the stored request.
+ first, err := store.GetPARSession(t.Context(), requestURI)
+ require.NoError(t, err)
+ require.NotNil(t, first)
+ require.Equal(t, clientID, first.GetClient().GetID())
+
+ // A second consumption is rejected: the request_uri is single-use.
+ _, err = store.GetPARSession(t.Context(), requestURI)
+ require.ErrorIs(t, err, fosite.ErrNotFound)
+}
+
+// TestStoreInvalidateAuthorizeCodeSessionIsAtomic verifies that an authorization code can
+// be invalidated only once (RFC 6749 §4.1.2). Invalidation is a conditional UPDATE guarded
+// on active = true, so a second concurrent token request that already read the code as
+// active fails closed (ErrNotFound) instead of minting a second token set from one code.
+func TestStoreInvalidateAuthorizeCodeSessionIsAtomic(t *testing.T) {
+ db := testutils.NewDatabaseForTest(t)
+ store := NewStore(db)
+
+ const (
+ clientID = "code-client"
+ code = "auth-code-single-use"
+ )
+ require.NoError(t, db.Create(&model.OidcClient{Base: model.Base{ID: clientID}, Name: "Code Client"}).Error)
+
+ session := NewEmptySession()
+ session.SetExpiresAt(fosite.AuthorizeCode, time.Now().UTC().Add(time.Minute))
+ request := &fosite.Request{
+ ID: "code-request",
+ RequestedAt: time.Now().UTC(),
+ Client: Client{OidcClient: model.OidcClient{Base: model.Base{ID: clientID}}},
+ RequestedScope: fosite.Arguments{"openid"},
+ Form: url.Values{},
+ Session: session,
+ }
+ require.NoError(t, store.CreateAuthorizeCodeSession(t.Context(), code, request))
+
+ // First invalidation wins.
+ require.NoError(t, store.InvalidateAuthorizeCodeSession(t.Context(), code))
+
+ // A second invalidation of the now-inactive code fails closed: a racing token request
+ // cannot proceed to issue a second set of tokens from the same code.
+ require.ErrorIs(t, store.InvalidateAuthorizeCodeSession(t.Context(), code), fosite.ErrNotFound)
+
+ // Reads of the consumed code report it as invalidated so fosite triggers reuse handling.
+ _, err := store.GetAuthorizeCodeSession(t.Context(), code, nil)
+ require.ErrorIs(t, err, fosite.ErrInvalidatedAuthorizeCode)
+}
+
+func TestStoreRevokeSessionsByIDTokenHintRevokesMatchingFositeSessions(t *testing.T) {
+ db := testutils.NewDatabaseForTest(t)
+ store := NewStore(db)
+
+ const (
+ userID = "test-user-123"
+ clientID = "test-client-456"
+ otherClientID = "other-client-789"
+ idTokenJTI = "matching-id-token-jti"
+ otherIDTokenJTI = "other-id-token-jti"
+ )
+
+ require.NoError(t, db.Create(&model.OidcClient{
+ Base: model.Base{ID: clientID},
+ Name: "Test Client",
+ }).Error)
+ require.NoError(t, db.Create(&model.OidcClient{
+ Base: model.Base{ID: otherClientID},
+ Name: "Other Client",
+ }).Error)
+
+ require.NoError(t, store.CreateRefreshTokenSession(t.Context(), "matching-refresh", "matching-access", newTestRequester("matching-request", clientID, userID, idTokenJTI)))
+ require.NoError(t, store.CreateAccessTokenSession(t.Context(), "matching-access", newTestRequester("matching-request", clientID, userID, idTokenJTI)))
+ require.NoError(t, store.CreateRefreshTokenSession(t.Context(), "same-client-different-session", "same-client-different-access", newTestRequester("same-client-request", clientID, userID, otherIDTokenJTI)))
+ require.NoError(t, store.CreateAccessTokenSession(t.Context(), "same-client-different-access", newTestRequester("same-client-request", clientID, userID, otherIDTokenJTI)))
+ require.NoError(t, store.CreateRefreshTokenSession(t.Context(), "other-client-same-jti", "other-client-access", newTestRequester("other-client-request", otherClientID, userID, idTokenJTI)))
+ require.NoError(t, store.CreateAccessTokenSession(t.Context(), "other-client-access", newTestRequester("other-client-request", otherClientID, userID, idTokenJTI)))
+
+ require.NoError(t, store.RevokeSessionsByIDTokenHint(t.Context(), userID, clientID, idTokenJTI))
+
+ var sessions []OAuth2Session
+ require.NoError(t, db.Order("key").Find(&sessions).Error)
+
+ activeRefreshByKey := map[string]bool{}
+ accessKeys := map[string]bool{}
+ for _, session := range sessions {
+ switch session.Kind {
+ case sessionKindRefreshToken:
+ activeRefreshByKey[session.Key] = session.Active
+ case sessionKindAccessToken:
+ accessKeys[session.Key] = true
+ }
+ }
+
+ assert.False(t, activeRefreshByKey["matching-refresh"])
+ assert.True(t, activeRefreshByKey["same-client-different-session"])
+ assert.True(t, activeRefreshByKey["other-client-same-jti"])
+ assert.False(t, accessKeys["matching-access"])
+ assert.True(t, accessKeys["same-client-different-access"])
+ assert.True(t, accessKeys["other-client-access"])
+}
+
+func TestStoreRevokeSessionsByIDTokenHintSkipsSessionsWithoutMatchingJTI(t *testing.T) {
+ db := testutils.NewDatabaseForTest(t)
+ store := NewStore(db)
+
+ const (
+ userID = "test-user-123"
+ clientID = "test-client-456"
+ otherClientID = "other-client-789"
+ )
+
+ require.NoError(t, db.Create(&model.OidcClient{
+ Base: model.Base{ID: clientID},
+ Name: "Test Client",
+ }).Error)
+ require.NoError(t, db.Create(&model.OidcClient{
+ Base: model.Base{ID: otherClientID},
+ Name: "Other Client",
+ }).Error)
+
+ require.NoError(t, store.CreateRefreshTokenSession(t.Context(), "same-client-refresh", "same-client-access", newTestRequester("same-client-request", clientID, userID, "")))
+ require.NoError(t, store.CreateAccessTokenSession(t.Context(), "same-client-access", newTestRequester("same-client-request", clientID, userID, "")))
+ require.NoError(t, store.CreateRefreshTokenSession(t.Context(), "other-client-refresh", "other-client-access", newTestRequester("other-client-request", otherClientID, userID, "")))
+ require.NoError(t, store.CreateAccessTokenSession(t.Context(), "other-client-access", newTestRequester("other-client-request", otherClientID, userID, "")))
+
+ require.NoError(t, store.RevokeSessionsByIDTokenHint(t.Context(), userID, clientID, "missing-from-stored-session"))
+
+ var sessions []OAuth2Session
+ require.NoError(t, db.Order("key").Find(&sessions).Error)
+
+ activeRefreshByKey := map[string]bool{}
+ accessKeys := map[string]bool{}
+ for _, session := range sessions {
+ switch session.Kind {
+ case sessionKindRefreshToken:
+ activeRefreshByKey[session.Key] = session.Active
+ case sessionKindAccessToken:
+ accessKeys[session.Key] = true
+ }
+ }
+
+ assert.True(t, activeRefreshByKey["same-client-refresh"])
+ assert.True(t, activeRefreshByKey["other-client-refresh"])
+ assert.True(t, accessKeys["same-client-access"])
+ assert.True(t, accessKeys["other-client-access"])
+}
+
+func newTestRequester(requestID, clientID, subject, idTokenJTI string) fosite.Requester {
+ session := NewEmptySession()
+ session.Subject = subject
+ session.Claims = &fositejwt.IDTokenClaims{
+ JTI: idTokenJTI,
+ RequestedAt: time.Now().UTC(),
+ Extra: map[string]any{},
+ }
+
+ return &fosite.Request{
+ ID: requestID,
+ RequestedAt: time.Now().UTC(),
+ Client: Client{
+ OidcClient: model.OidcClient{
+ Base: model.Base{ID: clientID},
+ },
+ },
+ GrantedScope: fosite.Arguments{"openid"},
+ Form: map[string][]string{},
+ Session: session,
+ }
+}
diff --git a/backend/internal/oidc/token_handler.go b/backend/internal/oidc/token_handler.go
new file mode 100644
index 00000000..9fddd82e
--- /dev/null
+++ b/backend/internal/oidc/token_handler.go
@@ -0,0 +1,77 @@
+package oidc
+
+import (
+ "log/slog"
+
+ "github.com/gin-gonic/gin"
+ "github.com/ory/fosite"
+)
+
+type tokenHandler struct {
+ provider fosite.OAuth2Provider
+ claimsService *ClaimsService
+}
+
+func newTokenHandler(provider fosite.OAuth2Provider, claimsService *ClaimsService) *tokenHandler {
+ return &tokenHandler{
+ provider: provider,
+ claimsService: claimsService,
+ }
+}
+
+func (h *tokenHandler) token(c *gin.Context) {
+ ctx := c.Request.Context()
+
+ // For grants that continue an existing session (authorization code, refresh token),
+ // fosite restores the stored session over this empty one.
+ session := NewEmptySession()
+
+ accessRequest, err := h.provider.NewAccessRequest(ctx, c.Request, session)
+ if err != nil {
+ slog.ErrorContext(ctx, "Failed to create access request", "error", err)
+ h.provider.WriteAccessError(ctx, c.Writer, accessRequest, err)
+ return
+ }
+
+ requestSession, ok := accessRequest.GetSession().(*Session)
+ if !ok {
+ slog.ErrorContext(ctx, "Failed to handle token request: session must be *oidc.Session")
+ h.provider.WriteAccessError(ctx, c.Writer, accessRequest, fosite.ErrServerError)
+ return
+ }
+
+ if client, ok := accessRequest.GetClient().(Client); ok {
+ // Re-validate the resource owner on every user-bound grant.
+ if err := h.claimsService.ValidateUserAccess(ctx, requestSession.Subject, client); err != nil {
+ slog.WarnContext(ctx, "Rejected token request: user no longer allowed to access client", "error", err.Error())
+ h.provider.WriteAccessError(ctx, c.Writer, accessRequest, err)
+ return
+ }
+
+ // Bind every issued JWT access token to the requesting client so it always carries an aud claim.
+ accessRequest.GrantAudience(client.GetID())
+ }
+
+ if err := h.claimsService.applyIDTokenClaims(ctx, requestSession, accessRequest.GetGrantedScopes()); err != nil {
+ slog.ErrorContext(ctx, "Failed to apply ID token claims", "error", err)
+ h.provider.WriteAccessError(ctx, c.Writer, accessRequest, err)
+ return
+ }
+
+ // The client credentials grant has no resource owner, so no subject is ever set. Assign a
+ // stable synthetic subject so the issued JWT access token still carries a subclaim.
+ if requestSession.Subject == "" {
+ if client, ok := accessRequest.GetClient().(Client); ok && accessRequest.GetGrantTypes().Has(string(fosite.GrantTypeClientCredentials)) {
+ requestSession.Subject = "client-" + client.GetID()
+ }
+ }
+
+ response, err := h.provider.NewAccessResponse(ctx, accessRequest)
+ if err != nil {
+ slog.ErrorContext(ctx, "Failed to create access response", "error", err)
+ h.provider.WriteAccessError(ctx, c.Writer, accessRequest, err)
+ return
+ }
+
+ h.provider.WriteAccessResponse(ctx, c.Writer, accessRequest, response)
+}
diff --git a/backend/internal/oidc/token_handler_test.go b/backend/internal/oidc/token_handler_test.go
new file mode 100644
index 00000000..9355e0b1
--- /dev/null
+++ b/backend/internal/oidc/token_handler_test.go
@@ -0,0 +1,243 @@
+package oidc
+
+import (
+ "crypto/rand"
+ "crypto/rsa"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/gin-gonic/gin"
+ "github.com/ory/fosite"
+ "github.com/ory/fosite/compose"
+ fositejwt "github.com/ory/fosite/token/jwt"
+ "github.com/stretchr/testify/require"
+ "golang.org/x/crypto/bcrypt"
+ "gorm.io/gorm"
+
+ "github.com/pocket-id/pocket-id/backend/internal/model"
+ testutils "github.com/pocket-id/pocket-id/backend/internal/utils/testing"
+)
+
+// TestTokenHandlerClientCredentialsGrant guards two regressions in one flow:
+// - the resource-owner re-validation must be skipped for client_credentials (which has
+// no user / empty subject), otherwise the grant is rejected with invalid_grant; and
+// - every issued JWT access token must carry an `aud` claim bound to the client
+// (RFC 9068 §2.2), which fosite only emits when the granted audience is non-empty.
+//
+// The provider-level tests bypass tokenHandler.token by calling NewAccessResponse directly,
+// so this drives the real HTTP handler with confidential-client Basic auth.
+func TestTokenHandlerClientCredentialsGrant(t *testing.T) {
+ gin.SetMode(gin.TestMode)
+
+ const (
+ baseURL = "https://issuer.example.com"
+ secret = "test-secret"
+ clientID = "cc-client"
+ clientPlain = "cc-secret-value"
+ )
+
+ db := testutils.NewDatabaseForTest(t)
+ key, err := rsa.GenerateKey(rand.Reader, 2048)
+ require.NoError(t, err)
+
+ hashed, err := bcrypt.GenerateFromPassword([]byte(clientPlain), bcrypt.DefaultCost)
+ require.NoError(t, err)
+ require.NoError(t, db.Create(&model.OidcClient{
+ Base: model.Base{ID: clientID},
+ Name: "Client Credentials Client",
+ Secret: string(hashed),
+ IsPublic: false,
+ }).Error)
+
+ provider, err := newProvider(NewStore(db), nil, testTokenSigner{key: key}, Config{
+ BaseURL: baseURL,
+ TokenBaseURL: baseURL,
+ Secret: secret,
+ })
+ require.NoError(t, err)
+ handler := newTokenHandler(provider, newClaimsService(db, nil, baseURL, nil))
+
+ form := url.Values{"grant_type": {"client_credentials"}}
+ req := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/api/oidc/token", strings.NewReader(form.Encode()))
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ req.SetBasicAuth(clientID, clientPlain)
+
+ rec := httptest.NewRecorder()
+ c, _ := gin.CreateTestContext(rec)
+ c.Request = req
+ handler.token(c)
+
+ var body map[string]any
+ require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &body))
+ require.NotEmpty(t, body["access_token"], "client_credentials must issue a token, got error: %v", body["error"])
+
+ claims := decodeJWTPart(t, body["access_token"].(string), 1)
+ require.Contains(t, jwtAudience(claims), clientID, "access token must be audience-bound to the client")
+}
+
+// jwtAudience normalizes the `aud` claim (string or []string) into a slice.
+func jwtAudience(claims map[string]any) []string {
+ switch aud := claims["aud"].(type) {
+ case string:
+ return []string{aud}
+ case []any:
+ out := make([]string, 0, len(aud))
+ for _, a := range aud {
+ if s, ok := a.(string); ok {
+ out = append(out, s)
+ }
+ }
+ return out
+ default:
+ return nil
+ }
+}
+
+// TestTokenHandlerRefreshGrantRevalidatesUser is the regression guard for the most
+// security-sensitive part of the fosite migration: fosite's refresh-token grant replays
+// the stored session without reloading the user, so the token handler must re-check the
+// resource owner on every refresh. Without that re-check, a user who is disabled or
+// removed from a group-restricted client after the initial login keeps minting fresh
+// access/ID tokens (and rotating the refresh token) until the 30-day refresh token
+// expires, defeating offboarding and incident response. This drives the real refresh grant
+// end to end through the HTTP handler.
+func TestTokenHandlerRefreshGrantRevalidatesUser(t *testing.T) {
+ gin.SetMode(gin.TestMode)
+
+ const (
+ baseURL = "https://issuer.example.com"
+ secret = "test-secret"
+ )
+
+ key, err := rsa.GenerateKey(rand.Reader, 2048)
+ require.NoError(t, err)
+ signer := testTokenSigner{key: key}
+
+ // mintRefreshToken stores an active refresh-token session for the user/client pair and
+ // returns the opaque token. It mirrors how the e2e test service seeds refresh tokens:
+ // the HMAC signature is derived from the same global secret the provider uses, so the
+ // real refresh grant resolves it.
+ mintRefreshToken := func(t *testing.T, db *gorm.DB, clientID, userID string) string {
+ t.Helper()
+ globalSecret, err := DeriveGlobalSecret(secret)
+ require.NoError(t, err)
+ strategy := compose.NewOAuth2HMACStrategy(&fosite.Config{
+ GlobalSecret: globalSecret,
+ RefreshTokenLifespan: 30 * 24 * time.Hour,
+ })
+ token, signature, err := strategy.GenerateRefreshToken(t.Context(), nil)
+ require.NoError(t, err)
+
+ now := time.Now().UTC()
+ session := NewEmptySession()
+ session.Subject = userID
+ session.Claims = &fositejwt.IDTokenClaims{
+ Subject: userID,
+ RequestedAt: now,
+ AuthTime: now,
+ Extra: map[string]any{},
+ }
+ session.SetExpiresAt(fosite.RefreshToken, now.Add(30*24*time.Hour))
+ session.SetExpiresAt(fosite.AccessToken, now.Add(time.Hour))
+
+ request := fosite.NewRequest()
+ request.ID = "refresh-req-" + userID
+ request.RequestedAt = now
+ request.Client = Client{OidcClient: model.OidcClient{Base: model.Base{ID: clientID}, IsPublic: true}}
+ request.RequestedScope = fosite.Arguments{"openid"}
+ request.GrantedScope = fosite.Arguments{"openid"}
+ request.RequestedAudience = fosite.Arguments{clientID}
+ request.GrantedAudience = fosite.Arguments{clientID}
+ request.Session = session
+
+ require.NoError(t, NewStore(db).CreateRefreshTokenSession(t.Context(), signature, "", request))
+ return token
+ }
+
+ doRefresh := func(t *testing.T, db *gorm.DB, clientID, refreshToken string) map[string]any {
+ t.Helper()
+ provider, err := newProvider(NewStore(db), nil, signer, Config{
+ BaseURL: baseURL,
+ TokenBaseURL: baseURL,
+ Secret: secret,
+ })
+ require.NoError(t, err)
+ handler := newTokenHandler(provider, newClaimsService(db, nil, baseURL, nil))
+
+ form := url.Values{
+ "grant_type": {"refresh_token"},
+ "refresh_token": {refreshToken},
+ "client_id": {clientID},
+ }
+ req := httptest.NewRequestWithContext(t.Context(), http.MethodPost, "/api/oidc/token", strings.NewReader(form.Encode()))
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+
+ rec := httptest.NewRecorder()
+ c, _ := gin.CreateTestContext(rec)
+ c.Request = req
+ handler.token(c)
+
+ var body map[string]any
+ require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &body))
+ return body
+ }
+
+ createClient := func(t *testing.T, db *gorm.DB, client model.OidcClient) {
+ t.Helper()
+ require.NoError(t, db.Create(&client).Error)
+ }
+
+ t.Run("enabled user receives rotated tokens", func(t *testing.T) {
+ db := testutils.NewDatabaseForTest(t)
+ const clientID, userID = "client-ok", "user-ok"
+ createClient(t, db, model.OidcClient{Base: model.Base{ID: clientID}, Name: "Client", IsPublic: true})
+ require.NoError(t, db.Create(&model.User{Base: model.Base{ID: userID}, Username: "tim"}).Error)
+
+ token := mintRefreshToken(t, db, clientID, userID)
+ body := doRefresh(t, db, clientID, token)
+
+ require.NotEmpty(t, body["access_token"], "expected a new access token, got error: %v", body["error"])
+ require.NotEmpty(t, body["refresh_token"])
+ require.NotEqual(t, token, body["refresh_token"], "refresh token must be rotated")
+ })
+
+ t.Run("disabled user is rejected on refresh", func(t *testing.T) {
+ db := testutils.NewDatabaseForTest(t)
+ const clientID, userID = "client-disabled", "user-disabled"
+ createClient(t, db, model.OidcClient{Base: model.Base{ID: clientID}, Name: "Client", IsPublic: true})
+ require.NoError(t, db.Create(&model.User{Base: model.Base{ID: userID}, Username: "tim", Disabled: true}).Error)
+
+ token := mintRefreshToken(t, db, clientID, userID)
+ body := doRefresh(t, db, clientID, token)
+
+ require.Empty(t, body["access_token"])
+ require.Equal(t, "invalid_grant", body["error"])
+ })
+
+ t.Run("user removed from a group-restricted client is rejected on refresh", func(t *testing.T) {
+ db := testutils.NewDatabaseForTest(t)
+ const clientID, userID = "client-restricted", "user-outsider"
+ group := model.UserGroup{Base: model.Base{ID: "allowed-group"}, Name: "allowed", FriendlyName: "Allowed"}
+ require.NoError(t, db.Create(&group).Error)
+ createClient(t, db, model.OidcClient{
+ Base: model.Base{ID: clientID},
+ Name: "Restricted",
+ IsPublic: true,
+ IsGroupRestricted: true,
+ AllowedUserGroups: []model.UserGroup{group},
+ })
+ // User is not a member of the allowed group.
+ require.NoError(t, db.Create(&model.User{Base: model.Base{ID: userID}, Username: "outsider"}).Error)
+
+ token := mintRefreshToken(t, db, clientID, userID)
+ body := doRefresh(t, db, clientID, token)
+
+ require.Empty(t, body["access_token"])
+ require.Equal(t, "access_denied", body["error"])
+ })
+}
diff --git a/backend/internal/oidc/tx.go b/backend/internal/oidc/tx.go
new file mode 100644
index 00000000..92aa5c70
--- /dev/null
+++ b/backend/internal/oidc/tx.go
@@ -0,0 +1,50 @@
+package oidc
+
+import (
+ "context"
+ "errors"
+ "log/slog"
+
+ "gorm.io/gorm"
+)
+
+// Transactions are carried in the context, every database access goes through dbFromContext.
+// Although storing transactions in the context is not preferred, fosite handlers don't allow passing a transaction
+// so we have to do it this way.
+
+type txContextKey struct{}
+
+func contextWithTx(ctx context.Context, tx *gorm.DB) context.Context {
+ return context.WithValue(ctx, txContextKey{}, tx)
+}
+
+func dbFromContext(ctx context.Context, fallback *gorm.DB) *gorm.DB {
+ if tx, ok := ctx.Value(txContextKey{}).(*gorm.DB); ok {
+ return tx.WithContext(ctx)
+ }
+ return fallback.WithContext(ctx)
+}
+
+// withTx runs fn inside a transaction, committing on nil error. Nested calls join the
+// outer transaction.
+func withTx(ctx context.Context, db *gorm.DB, fn func(ctx context.Context) error) error {
+ if _, ok := ctx.Value(txContextKey{}).(*gorm.DB); ok {
+ return fn(ctx)
+ }
+
+ tx := db.WithContext(ctx).Begin()
+ if tx.Error != nil {
+ return tx.Error
+ }
+ defer func() {
+ err := tx.Rollback().Error
+ if err != nil && !errors.Is(err, gorm.ErrInvalidTransaction) {
+ slog.ErrorContext(ctx, "Failed to rollback transaction", "error", err)
+ }
+ }()
+
+ if err := fn(contextWithTx(ctx, tx)); err != nil {
+ return err
+ }
+ return tx.Commit().Error
+}
diff --git a/backend/internal/oidc/userinfo_handler.go b/backend/internal/oidc/userinfo_handler.go
new file mode 100644
index 00000000..a490be9c
--- /dev/null
+++ b/backend/internal/oidc/userinfo_handler.go
@@ -0,0 +1,66 @@
+package oidc
+
+import (
+ "fmt"
+ "net/http"
+
+ "github.com/gin-gonic/gin"
+ "github.com/ory/fosite"
+)
+
+type userInfoHandler struct {
+ provider fosite.OAuth2Provider
+ claimsService *ClaimsService
+}
+
+func newUserInfoHandler(provider fosite.OAuth2Provider, claimsService *ClaimsService) *userInfoHandler {
+ return &userInfoHandler{
+ provider: provider,
+ claimsService: claimsService,
+ }
+}
+
+// userInfo godoc
+// @Summary Get user information
+// @Description Get user information based on the access token
+// @Tags OIDC
+// @Accept json
+// @Produce json
+// @Success 200 {object} object "User claims based on requested scopes"
+// @Security OAuth2AccessToken
+// @Router /api/oidc/userinfo [get]
+func (h *userInfoHandler) userInfo(c *gin.Context) {
+ ctx := c.Request.Context()
+ tokenType, accessRequest, err := h.provider.IntrospectToken(ctx, fosite.AccessTokenFromRequest(c.Request), fosite.AccessToken, NewEmptySession())
+ if err != nil {
+ writeUserInfoError(c, err)
+ return
+ }
+ if tokenType != fosite.AccessToken {
+ writeUserInfoError(c, fosite.ErrRequestUnauthorized.WithDescription("Only access tokens are allowed in the authorization header."))
+ return
+ }
+
+ session, ok := accessRequest.GetSession().(*Session)
+ if !ok || session.GetSubject() == "" {
+ writeUserInfoError(c, fosite.ErrRequestUnauthorized.WithDescription("The access token is invalid"))
+ return
+ }
+
+ claims, err := h.claimsService.GetUserClaims(ctx, session.GetSubject(), accessRequest.GetGrantedScopes())
+ if err != nil {
+ _ = c.Error(err)
+ return
+ }
+
+ c.JSON(http.StatusOK, claims)
+}
+
+func writeUserInfoError(c *gin.Context, err error) {
+ rfcErr := fosite.ErrorToRFC6749Error(err)
+ if rfcErr.StatusCode() == http.StatusUnauthorized {
+ c.Header("WWW-Authenticate", fmt.Sprintf(`Bearer error="%s", error_description="%s"`, rfcErr.ErrorField, rfcErr.GetDescription()))
+ }
+
+ c.JSON(rfcErr.StatusCode(), rfcErr)
+}
diff --git a/backend/internal/oidc/userinfo_handler_test.go b/backend/internal/oidc/userinfo_handler_test.go
new file mode 100644
index 00000000..5335773f
--- /dev/null
+++ b/backend/internal/oidc/userinfo_handler_test.go
@@ -0,0 +1,128 @@
+package oidc
+
+import (
+ "crypto/rand"
+ "crypto/rsa"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+ "time"
+
+ "github.com/gin-gonic/gin"
+ "github.com/ory/fosite"
+ "github.com/stretchr/testify/require"
+
+ "github.com/pocket-id/pocket-id/backend/internal/model"
+ testutils "github.com/pocket-id/pocket-id/backend/internal/utils/testing"
+)
+
+// TestUserInfoHandler covers the userinfo endpoint, which returns user PII based purely on
+// a presented access token. It must reject a missing/garbage token and — importantly — a
+// token that carries no resource owner (e.g. a client_credentials token), so machine
+// tokens cannot be exchanged for a user's profile. A valid user access token returns the
+// claims for exactly the scopes it was granted.
+func TestUserInfoHandler(t *testing.T) {
+ gin.SetMode(gin.TestMode)
+
+ const (
+ baseURL = "https://issuer.example.com"
+ userID = "user-1"
+ clientID = "client-1"
+ )
+
+ db := testutils.NewDatabaseForTest(t)
+ require.NoError(t, db.Create(&model.OidcClient{Base: model.Base{ID: clientID}, Name: "Test Client"}).Error)
+ require.NoError(t, db.Create(&model.User{
+ Base: model.Base{ID: userID},
+ Username: "tim",
+ FirstName: "Tim",
+ LastName: "Cook",
+ DisplayName: "Tim Cook",
+ Email: stringPointer("tim@example.com"),
+ EmailVerified: true,
+ }).Error)
+
+ key, err := rsa.GenerateKey(rand.Reader, 2048)
+ require.NoError(t, err)
+
+ provider, err := newProvider(NewStore(db), nil, testTokenSigner{key: key}, Config{
+ BaseURL: baseURL,
+ TokenBaseURL: baseURL,
+ Secret: "test-secret",
+ })
+ require.NoError(t, err)
+
+ handler := newUserInfoHandler(provider, newClaimsService(db, nil, baseURL, nil))
+
+ issueAccessToken := func(t *testing.T, requestID, subject string, scopes ...string) string {
+ t.Helper()
+ session := NewEmptySession()
+ session.Subject = subject
+ session.SetExpiresAt(fosite.AccessToken, time.Now().UTC().Add(time.Hour))
+
+ request := fosite.NewAccessRequest(session)
+ request.ID = requestID
+ request.Client = Client{OidcClient: model.OidcClient{Base: model.Base{ID: clientID}}}
+ request.GrantTypes = fosite.Arguments{string(fosite.GrantTypeClientCredentials)}
+ request.RequestedScope = fosite.Arguments(scopes)
+ request.GrantedScope = fosite.Arguments(scopes)
+ request.RequestedAudience = fosite.Arguments{clientID}
+ request.GrantedAudience = fosite.Arguments{clientID}
+
+ response, err := provider.NewAccessResponse(t.Context(), request)
+ require.NoError(t, err)
+ return response.GetAccessToken()
+ }
+
+ call := func(t *testing.T, token string) (*httptest.ResponseRecorder, *gin.Context) {
+ t.Helper()
+ req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/api/oidc/userinfo", nil)
+ if token != "" {
+ req.Header.Set("Authorization", "Bearer "+token)
+ }
+ rec := httptest.NewRecorder()
+ c, _ := gin.CreateTestContext(rec)
+ c.Request = req
+ handler.userInfo(c)
+ return rec, c
+ }
+
+ t.Run("valid user access token returns the granted claims", func(t *testing.T) {
+ token := issueAccessToken(t, "req-valid", userID, "openid", "email")
+ rec, c := call(t, token)
+
+ require.Empty(t, c.Errors)
+ require.Equal(t, http.StatusOK, rec.Code)
+
+ var claims map[string]any
+ require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &claims))
+ require.Equal(t, userID, claims["sub"])
+ require.Equal(t, "tim@example.com", claims["email"])
+ // profile was not granted, so profile claims must be absent
+ require.NotContains(t, claims, "given_name")
+ })
+
+ t.Run("missing access token is rejected", func(t *testing.T) {
+ rec, c := call(t, "")
+ require.Empty(t, c.Errors)
+ require.Equal(t, http.StatusUnauthorized, rec.Code)
+ require.Contains(t, rec.Header().Get("WWW-Authenticate"), `Bearer error="request_unauthorized"`)
+ })
+
+ t.Run("garbage access token is rejected", func(t *testing.T) {
+ rec, c := call(t, "garbage.token.value")
+ require.Empty(t, c.Errors)
+ require.Equal(t, http.StatusUnauthorized, rec.Code)
+ require.Contains(t, rec.Header().Get("WWW-Authenticate"), `Bearer error=`)
+ })
+
+ t.Run("token without a resource owner is rejected", func(t *testing.T) {
+ // client_credentials-style token: valid, but no subject -> must not return PII.
+ token := issueAccessToken(t, "req-no-subject", "", "openid")
+ rec, c := call(t, token)
+ require.Empty(t, c.Errors)
+ require.Equal(t, http.StatusUnauthorized, rec.Code)
+ require.Contains(t, rec.Header().Get("WWW-Authenticate"), `Bearer error="request_unauthorized"`)
+ })
+}
diff --git a/backend/internal/service/e2etest_service.go b/backend/internal/service/e2etest_service.go
index ffbba370..3af37e8a 100644
--- a/backend/internal/service/e2etest_service.go
+++ b/backend/internal/service/e2etest_service.go
@@ -14,14 +14,19 @@ import (
"time"
"github.com/go-webauthn/webauthn/protocol"
+ "github.com/google/uuid"
"github.com/lestrrat-go/jwx/v3/jwa"
"github.com/lestrrat-go/jwx/v3/jwk"
"github.com/lestrrat-go/jwx/v3/jwt"
+ "github.com/ory/fosite"
+ "github.com/ory/fosite/compose"
+ fositejwt "github.com/ory/fosite/token/jwt"
"gorm.io/gorm"
"github.com/pocket-id/pocket-id/backend/internal/common"
"github.com/pocket-id/pocket-id/backend/internal/model"
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
+ "github.com/pocket-id/pocket-id/backend/internal/oidc"
"github.com/pocket-id/pocket-id/backend/internal/storage"
"github.com/pocket-id/pocket-id/backend/internal/utils"
jwkutils "github.com/pocket-id/pocket-id/backend/internal/utils/jwk"
@@ -38,6 +43,13 @@ type TestService struct {
externalIdPKey jwk.Key
}
+const (
+ e2eRefreshTokenUserID = "f4b89dc2-62fb-46bf-9f5f-c34f4eafe93e"
+ e2eRefreshTokenClientID = "3654a746-35d4-4321-ac61-0bdcff2b4055"
+ e2eRefreshTokenValidFixtureToken = "ou87UDg249r1StBLYkMEqy9TXDbV5HmGuDpMcZDo"
+ e2eRefreshTokenExpiredFixtureToken = "X4vqwtRyCUaq51UafHea4Fsg8Km6CAns6vp3tuX4"
+)
+
func NewTestService(db *gorm.DB, appConfigService *AppConfigService, jwtService *JwtService, ldapService *LdapService, appLockService *AppLockService, fileStorage storage.FileStorage) (*TestService, error) {
s := &TestService{
db: db,
@@ -173,8 +185,8 @@ func (s *TestService) SeedDatabase(baseURL string) error {
Name: "Nextcloud",
LaunchURL: new("https://nextcloud.local"),
Secret: "$2a$10$9dypwot8nGuCjT6wQWWpJOckZfRprhe2EkwpKizxS/fpVHrOLEJHC", // w2mUeZISmEvIDMEDvpY0PnxQIpj1m3zY
- CallbackURLs: model.UrlList{"http://nextcloud/auth/callback"},
- LogoutCallbackURLs: model.UrlList{"http://nextcloud/auth/logout/callback"},
+ CallbackURLs: model.UrlList{"http://nextcloud.localhost/auth/callback"},
+ LogoutCallbackURLs: model.UrlList{"http://nextcloud.localhost/auth/logout/callback"},
ImageType: new("png"),
CreatedByID: new(users[0].ID),
},
@@ -184,7 +196,7 @@ func (s *TestService) SeedDatabase(baseURL string) error {
},
Name: "Immich",
Secret: "$2a$10$Ak.FP8riD1ssy2AGGbG.gOpnp/rBpymd74j0nxNMtW0GG1Lb4gzxe", // PYjrE9u4v9GVqXKi52eur0eb2Ci4kc0x
- CallbackURLs: model.UrlList{"http://immich/auth/callback"},
+ CallbackURLs: model.UrlList{"http://immich.localhost/auth/callback"},
CreatedByID: new(users[1].ID),
IsGroupRestricted: true,
AllowedUserGroups: []model.UserGroup{
@@ -197,8 +209,8 @@ func (s *TestService) SeedDatabase(baseURL string) error {
},
Name: "Tailscale",
Secret: "$2a$10$xcRReBsvkI1XI6FG8xu/pOgzeF00bH5Wy4d/NThwcdi3ZBpVq/B9a", // n4VfQeXlTzA6yKpWbR9uJcMdSx2qH0Lo
- CallbackURLs: model.UrlList{"http://tailscale/auth/callback"},
- LogoutCallbackURLs: model.UrlList{"http://tailscale/auth/logout/callback"},
+ CallbackURLs: model.UrlList{"http://tailscale.localhost/auth/callback"},
+ LogoutCallbackURLs: model.UrlList{"http://tailscale.localhost/auth/logout/callback"},
IsGroupRestricted: true,
CreatedByID: new(users[0].ID),
},
@@ -208,7 +220,7 @@ func (s *TestService) SeedDatabase(baseURL string) error {
},
Name: "Federated",
Secret: "$2a$10$Ak.FP8riD1ssy2AGGbG.gOpnp/rBpymd74j0nxNMtW0GG1Lb4gzxe", // PYjrE9u4v9GVqXKi52eur0eb2Ci4kc0x
- CallbackURLs: model.UrlList{"http://federated/auth/callback"},
+ CallbackURLs: model.UrlList{"http://federated.localhost/auth/callback"},
CreatedByID: new(users[1].ID),
AllowedUserGroups: []model.UserGroup{},
Credentials: model.OidcClientCredentials{
@@ -228,7 +240,7 @@ func (s *TestService) SeedDatabase(baseURL string) error {
},
Name: "SCIM Client",
Secret: "$2a$10$h4wfa8gI7zavDAxwzSq1sOwYU4e8DwK1XZ8ZweNnY5KzlJ3Iz.qdK", // nQbiuMRG7FpdK2EnDd5MBivWQeKFXohn
- CallbackURLs: model.UrlList{"http://scimclient/auth/callback"},
+ CallbackURLs: model.UrlList{"http://scimclient.localhost/auth/callback"},
CreatedByID: new(users[0].ID),
IsGroupRestricted: true,
AllowedUserGroups: []model.UserGroup{
@@ -242,7 +254,7 @@ func (s *TestService) SeedDatabase(baseURL string) error {
},
Name: "PAR Test Client",
Secret: "$2a$10$9dypwot8nGuCjT6wQWWpJOckZfRprhe2EkwpKizxS/fpVHrOLEJHC", // w2mUeZISmEvIDMEDvpY0PnxQIpj1m3zY
- CallbackURLs: model.UrlList{"http://par-client/auth/callback"},
+ CallbackURLs: model.UrlList{"http://par-client.localhost/auth/callback"},
CreatedByID: new(users[0].ID),
},
}
@@ -252,45 +264,6 @@ func (s *TestService) SeedDatabase(baseURL string) error {
}
}
- authCodes := []model.OidcAuthorizationCode{
- {
- Code: "auth-code",
- Scope: "openid profile",
- Nonce: "nonce",
- AuthenticationMethod: AuthenticationMethodPhishingResistant,
- ExpiresAt: datatype.DateTime(time.Now().Add(1 * time.Hour)),
- UserID: users[0].ID,
- ClientID: oidcClients[0].ID,
- },
- {
- Code: "federated",
- Scope: "openid profile",
- Nonce: "nonce",
- AuthenticationMethod: AuthenticationMethodPhishingResistant,
- ExpiresAt: datatype.DateTime(time.Now().Add(1 * time.Hour)),
- UserID: users[1].ID,
- ClientID: oidcClients[3].ID,
- },
- }
- for _, authCode := range authCodes {
- if err := tx.Create(&authCode).Error; err != nil {
- return err
- }
- }
-
- refreshToken := model.OidcRefreshToken{
- Token: utils.CreateSha256Hash("ou87UDg249r1StBLYkMEqy9TXDbV5HmGuDpMcZDo"),
- IdTokenJti: new("dd75f6f6-ce0a-44b7-a645-7de390ccd2fa"),
- AuthenticationMethod: AuthenticationMethodPhishingResistant,
- ExpiresAt: datatype.DateTime(time.Now().Add(24 * time.Hour)),
- Scope: "openid profile email",
- UserID: users[0].ID,
- ClientID: oidcClients[0].ID,
- }
- if err := tx.Create(&refreshToken).Error; err != nil {
- return err
- }
-
accessToken := model.OneTimeAccessToken{
Token: "one-time-token",
ExpiresAt: datatype.DateTime(time.Now().Add(1 * time.Hour)),
@@ -302,25 +275,25 @@ func (s *TestService) SeedDatabase(baseURL string) error {
userAuthorizedClients := []model.UserAuthorizedOidcClient{
{
- Scope: "openid profile email",
+ Scope: datatype.StringList{"openid", "profile", "email"},
UserID: users[0].ID,
ClientID: oidcClients[0].ID,
LastUsedAt: datatype.DateTime(time.Date(2025, 8, 1, 13, 0, 0, 0, time.UTC)),
},
{
- Scope: "openid profile email",
+ Scope: datatype.StringList{"openid", "profile", "email"},
UserID: users[0].ID,
ClientID: oidcClients[2].ID,
LastUsedAt: datatype.DateTime(time.Date(2025, 8, 10, 14, 0, 0, 0, time.UTC)),
},
{
- Scope: "openid profile email",
+ Scope: datatype.StringList{"openid", "profile", "email"},
UserID: users[1].ID,
ClientID: oidcClients[3].ID,
LastUsedAt: datatype.DateTime(time.Date(2025, 8, 12, 12, 0, 0, 0, time.UTC)),
},
{
- Scope: "openid profile email",
+ Scope: datatype.StringList{"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)),
@@ -646,8 +619,173 @@ func (s *TestService) SetLdapTestConfig(ctx context.Context) error {
return nil
}
-func (s *TestService) SignRefreshToken(userID, clientID, refreshToken string) (string, error) {
- return s.jwtService.GenerateOAuthRefreshToken(userID, clientID, refreshToken)
+func (s *TestService) SignRefreshToken(ctx context.Context, userID, clientID, fixtureRefreshToken string) (string, error) {
+ globalSecret, err := oidc.DeriveGlobalSecret(string(common.EnvConfig.EncryptionKey))
+ if err != nil {
+ return "", err
+ }
+ strategy := compose.NewOAuth2HMACStrategy(&fosite.Config{
+ GlobalSecret: globalSecret,
+ RefreshTokenLifespan: RefreshTokenDuration,
+ })
+
+ token, signature, err := strategy.GenerateRefreshToken(ctx, nil)
+ if err != nil {
+ return "", err
+ }
+
+ // The e2e API always returns a newly generated fosite token. The legacy fixture
+ // token only selects whether to seed the matching stored session as valid/expired.
+ session, ok := seededRefreshTokenSession(userID, clientID, fixtureRefreshToken)
+ if !ok {
+ return token, nil
+ }
+ session.Signature = signature
+
+ if err := s.seedFositeTokenSession(ctx, session); err != nil {
+ return "", err
+ }
+
+ return token, nil
+}
+
+func seededRefreshTokenSession(userID string, clientID string, fixtureRefreshToken string) (fositeTokenSession, bool) {
+ expired, ok := seededRefreshTokenFixture(userID, clientID, fixtureRefreshToken)
+ if !ok {
+ return fositeTokenSession{}, false
+ }
+
+ expiresAt := time.Now().UTC().Add(24 * time.Hour)
+ if expired {
+ expiresAt = time.Now().UTC().Add(-24 * time.Hour)
+ }
+
+ return fositeTokenSession{
+ Kind: "refresh_token",
+ RequestID: "e2e-refresh-" + utils.CreateSha256Hash(fixtureRefreshToken),
+ UserID: userID,
+ ClientID: clientID,
+ AuthenticationMethod: AuthenticationMethodPhishingResistant,
+ Scopes: []string{"openid", "profile", "email"},
+ TokenType: fosite.RefreshToken,
+ ExpiresAt: expiresAt,
+ }, true
+}
+
+func seededRefreshTokenFixture(userID string, clientID string, fixtureRefreshToken string) (expired bool, ok bool) {
+ if userID != e2eRefreshTokenUserID || clientID != e2eRefreshTokenClientID {
+ return false, false
+ }
+
+ switch fixtureRefreshToken {
+ case e2eRefreshTokenValidFixtureToken:
+ return false, true
+ case e2eRefreshTokenExpiredFixtureToken:
+ return true, true
+ default:
+ return false, false
+ }
+}
+
+func (s *TestService) SignAccessToken(ctx context.Context, userID, clientID string, expired bool) (string, error) {
+ globalSecret, err := oidc.DeriveGlobalSecret(string(common.EnvConfig.EncryptionKey))
+ if err != nil {
+ return "", err
+ }
+ fositeConfig := &fosite.Config{
+ GlobalSecret: globalSecret,
+ AccessTokenLifespan: AccessTokenDuration,
+ AccessTokenIssuer: common.EnvConfig.AppURL,
+ }
+ coreStrategy := compose.NewOAuth2HMACStrategy(fositeConfig)
+ keyGetter := func(context.Context) (interface{}, error) {
+ return oidc.SigningKeyFromSigner(s.jwtService)
+ }
+ strategy := compose.NewOAuth2JWTStrategy(keyGetter, coreStrategy, fositeConfig)
+
+ expiresAt := time.Now().UTC().Add(AccessTokenDuration)
+ if expired {
+ expiresAt = time.Now().UTC().Add(-time.Minute)
+ }
+
+ session := fositeTokenSession{
+ Kind: "access_token",
+ RequestID: "e2e-access-" + uuid.NewString(),
+ UserID: userID,
+ ClientID: clientID,
+ AuthenticationMethod: AuthenticationMethodPhishingResistant,
+ Scopes: []string{"openid", "profile", "email"},
+ TokenType: fosite.AccessToken,
+ ExpiresAt: expiresAt,
+ }
+
+ request := s.newFositeTokenRequest(session)
+ token, signature, err := strategy.GenerateAccessToken(ctx, request)
+ if err != nil {
+ return "", err
+ }
+
+ session.Signature = signature
+ err = s.seedFositeTokenSession(ctx, session)
+ if err != nil {
+ return "", err
+ }
+
+ return token, nil
+}
+
+type fositeTokenSession struct {
+ Kind string
+ Signature string
+ RequestID string
+ UserID string
+ ClientID string
+ AuthenticationMethod string
+ Scopes []string
+ TokenType fosite.TokenType
+ ExpiresAt time.Time
+}
+
+func (s *TestService) seedFositeTokenSession(ctx context.Context, session fositeTokenSession) error {
+ request := s.newFositeTokenRequest(session)
+
+ store := oidc.NewStore(s.db)
+ switch session.Kind {
+ case "access_token":
+ return store.CreateAccessTokenSession(ctx, session.Signature, request)
+ case "refresh_token":
+ return store.CreateRefreshTokenSession(ctx, session.Signature, "", request)
+ default:
+ return fmt.Errorf("unsupported token session kind %q", session.Kind)
+ }
+}
+
+func (s *TestService) newFositeTokenRequest(session fositeTokenSession) *fosite.Request {
+ requestedAt := time.Now().UTC()
+
+ oidcSession := &oidc.Session{
+ Subject: session.UserID,
+ AuthenticationMethod: session.AuthenticationMethod,
+ Claims: &fositejwt.IDTokenClaims{
+ RequestedAt: requestedAt,
+ AuthTime: requestedAt,
+ Subject: session.UserID,
+ Issuer: common.EnvConfig.AppURL,
+ },
+ }
+ oidcSession.SetExpiresAt(session.TokenType, session.ExpiresAt)
+
+ request := fosite.NewRequest()
+ request.ID = session.RequestID
+ request.RequestedAt = requestedAt
+ request.Client = oidc.Client{OidcClient: model.OidcClient{Base: model.Base{ID: session.ClientID}}}
+ request.RequestedScope = session.Scopes
+ request.GrantedScope = session.Scopes
+ request.RequestedAudience = fosite.Arguments{session.ClientID}
+ request.GrantedAudience = fosite.Arguments{session.ClientID}
+ request.Session = oidcSession
+
+ return request
}
// GetExternalIdPJWKS returns the JWKS for the "external IdP".
@@ -672,6 +810,7 @@ func (s *TestService) SignExternalIdPToken(iss, sub, aud string) (string, error)
Subject(sub).
Expiration(now.Add(time.Hour)).
IssuedAt(now).
+ JwtID(uuid.NewString()).
Issuer(iss).
Audience([]string{aud}).
Build()
diff --git a/backend/internal/service/jwt_service.go b/backend/internal/service/jwt_service.go
index 63e0c339..5eafd310 100644
--- a/backend/internal/service/jwt_service.go
+++ b/backend/internal/service/jwt_service.go
@@ -29,30 +29,15 @@ const (
// TokenTypeClaim is the claim used to identify the type of token
TokenTypeClaim = "type"
- // RefreshTokenClaim is the claim used for the refresh token's value
- RefreshTokenClaim = "rt"
-
- // AuthenticationMethodsClaim is the claim used to identify how the user authenticated
- AuthenticationMethodsClaim = "amr"
-
// AuthenticationMethodPhishingResistant identifies phishing-resistant authentication, such as passkeys
AuthenticationMethodPhishingResistant = "phr"
// AuthenticationMethodOneTimePassword identifies one-time password/code authentication
AuthenticationMethodOneTimePassword = "otp"
- // OAuthAccessTokenJWTType identifies a JWT as an OAuth access token
- OAuthAccessTokenJWTType = "oauth-access-token" //nolint:gosec
-
- // OAuthRefreshTokenJWTType identifies a JWT as an OAuth refresh token
- OAuthRefreshTokenJWTType = "refresh-token"
-
// AccessTokenJWTType identifies a JWT as an access token used by Pocket ID
AccessTokenJWTType = "access-token"
- // IDTokenJWTType identifies a JWT as an ID token used by Pocket ID
- IDTokenJWTType = "id-token"
-
// Acceptable clock skew for verifying tokens
clockSkew = time.Minute
)
@@ -227,7 +212,7 @@ func (s *JwtService) GenerateAccessToken(user model.User, authenticationMethod s
err = SetAuthenticationMethods(token, authenticationMethod)
if err != nil {
- return "", fmt.Errorf("failed to set '%s' claim in token: %w", AuthenticationMethodsClaim, err)
+ return "", fmt.Errorf("failed to set '%s' claim in token: %w", common.AuthenticationMethodsClaim, err)
}
alg, _ := s.privateKey.Algorithm()
@@ -257,256 +242,6 @@ func (s *JwtService) VerifyAccessToken(tokenString string) (jwt.Token, error) {
return token, nil
}
-// BuildIDToken creates an ID token with all claims
-func (s *JwtService) BuildIDToken(userClaims map[string]any, clientID string, nonce string, authenticationMethod string) (jwt.Token, string, error) {
- now := time.Now()
- jti := uuid.New().String()
- token, err := jwt.NewBuilder().
- Expiration(now.Add(1 * time.Hour)).
- IssuedAt(now).
- Issuer(s.envConfig.AppURL).
- JwtID(jti).
- Build()
- if err != nil {
- return nil, "", fmt.Errorf("failed to build token: %w", err)
- }
-
- err = SetAudienceString(token, clientID)
- if err != nil {
- return nil, "", fmt.Errorf("failed to set 'aud' claim in token: %w", err)
- }
-
- err = SetTokenType(token, IDTokenJWTType)
- if err != nil {
- return nil, "", fmt.Errorf("failed to set 'type' claim in token: %w", err)
- }
-
- err = SetAuthenticationMethods(token, authenticationMethod)
- if err != nil {
- return nil, "", fmt.Errorf("failed to set '%s' claim in token: %w", AuthenticationMethodsClaim, err)
- }
-
- for k, v := range userClaims {
- err = token.Set(k, v)
- if err != nil {
- return nil, "", fmt.Errorf("failed to set claim '%s': %w", k, err)
- }
- }
-
- if nonce != "" {
- err = token.Set("nonce", nonce)
- if err != nil {
- return nil, "", fmt.Errorf("failed to set claim 'nonce': %w", err)
- }
- }
-
- return token, jti, nil
-}
-
-// GenerateIDToken creates and signs an ID token
-func (s *JwtService) GenerateIDToken(userClaims map[string]any, clientID string, nonce string, authenticationMethod string) (signedToken, jti string, err error) {
- token, jti, err := s.BuildIDToken(userClaims, clientID, nonce, authenticationMethod)
- if err != nil {
- return "", "", err
- }
-
- alg, _ := s.privateKey.Algorithm()
- signed, err := jwt.Sign(token, jwt.WithKey(alg, s.privateKey))
- if err != nil {
- return "", "", fmt.Errorf("failed to sign token: %w", err)
- }
-
- return string(signed), jti, nil
-}
-
-func (s *JwtService) VerifyIdToken(tokenString string, acceptExpiredTokens bool) (jwt.Token, error) {
- alg, _ := s.privateKey.Algorithm()
-
- opts := make([]jwt.ParseOption, 0)
-
- // These options are always present
- opts = append(opts,
- jwt.WithValidate(true),
- jwt.WithKey(alg, s.privateKey),
- jwt.WithAcceptableSkew(clockSkew),
- jwt.WithIssuer(s.envConfig.AppURL),
- jwt.WithValidator(TokenTypeValidator(IDTokenJWTType)),
- )
-
- // By default, jwt.Parse includes 3 default validators for "nbf", "iat", and "exp"
- // In case we want to accept expired tokens (during logout), we need to set the validators explicitly without validating "exp"
- if acceptExpiredTokens {
- // This is equivalent to the default validators except it doesn't validate "exp"
- opts = append(opts,
- jwt.WithResetValidators(true),
- jwt.WithValidator(jwt.IsIssuedAtValid()),
- jwt.WithValidator(jwt.IsNbfValid()),
- )
- }
-
- token, err := jwt.ParseString(tokenString, opts...)
- if err != nil {
- return nil, fmt.Errorf("failed to parse token: %w", err)
- }
-
- return token, nil
-}
-
-// BuildOAuthAccessToken creates an OAuth access token with all claims
-func (s *JwtService) BuildOAuthAccessToken(user model.User, clientID string, authenticationMethod string) (jwt.Token, error) {
- now := time.Now()
- token, err := jwt.NewBuilder().
- Subject(user.ID).
- Expiration(now.Add(1 * time.Hour)).
- IssuedAt(now).
- Issuer(s.envConfig.AppURL).
- JwtID(uuid.New().String()).
- Build()
- if err != nil {
- return nil, fmt.Errorf("failed to build token: %w", err)
- }
-
- err = SetAudienceString(token, clientID)
- if err != nil {
- return nil, fmt.Errorf("failed to set 'aud' claim in token: %w", err)
- }
-
- err = SetTokenType(token, OAuthAccessTokenJWTType)
- if err != nil {
- return nil, fmt.Errorf("failed to set 'type' claim in token: %w", err)
- }
-
- err = SetAuthenticationMethods(token, authenticationMethod)
- if err != nil {
- return nil, fmt.Errorf("failed to set '%s' claim in token: %w", AuthenticationMethodsClaim, err)
- }
-
- return token, nil
-}
-
-// GenerateOAuthAccessToken creates and signs an OAuth access token
-func (s *JwtService) GenerateOAuthAccessToken(user model.User, clientID string, authenticationMethod string) (string, error) {
- token, err := s.BuildOAuthAccessToken(user, clientID, authenticationMethod)
- if err != nil {
- return "", err
- }
-
- alg, _ := s.privateKey.Algorithm()
- signed, err := jwt.Sign(token, jwt.WithKey(alg, s.privateKey))
- if err != nil {
- return "", fmt.Errorf("failed to sign token: %w", err)
- }
-
- return string(signed), nil
-}
-
-func (s *JwtService) VerifyOAuthAccessToken(tokenString string) (jwt.Token, error) {
- alg, _ := s.privateKey.Algorithm()
- token, err := jwt.ParseString(
- tokenString,
- jwt.WithValidate(true),
- jwt.WithKey(alg, s.privateKey),
- jwt.WithAcceptableSkew(clockSkew),
- jwt.WithIssuer(s.envConfig.AppURL),
- jwt.WithValidator(TokenTypeValidator(OAuthAccessTokenJWTType)),
- )
- if err != nil {
- return nil, fmt.Errorf("failed to parse token: %w", err)
- }
-
- return token, nil
-}
-
-func (s *JwtService) GenerateOAuthRefreshToken(userID string, clientID string, refreshToken string) (string, error) {
- now := time.Now()
- token, err := jwt.NewBuilder().
- Subject(userID).
- Expiration(now.Add(RefreshTokenDuration)).
- IssuedAt(now).
- Issuer(s.envConfig.AppURL).
- Build()
- if err != nil {
- return "", fmt.Errorf("failed to build token: %w", err)
- }
-
- err = token.Set(RefreshTokenClaim, refreshToken)
- if err != nil {
- return "", fmt.Errorf("failed to set 'rt' claim in token: %w", err)
- }
-
- err = SetAudienceString(token, clientID)
- if err != nil {
- return "", fmt.Errorf("failed to set 'aud' claim in token: %w", err)
- }
-
- err = SetTokenType(token, OAuthRefreshTokenJWTType)
- if err != nil {
- return "", fmt.Errorf("failed to set 'type' claim in token: %w", err)
- }
-
- alg, _ := s.privateKey.Algorithm()
- signed, err := jwt.Sign(token, jwt.WithKey(alg, s.privateKey))
- if err != nil {
- return "", fmt.Errorf("failed to sign token: %w", err)
- }
-
- return string(signed), nil
-}
-
-func (s *JwtService) VerifyOAuthRefreshToken(tokenString string) (userID, clientID, rt string, err error) {
- alg, _ := s.privateKey.Algorithm()
- token, err := jwt.ParseString(
- tokenString,
- jwt.WithValidate(true),
- jwt.WithKey(alg, s.privateKey),
- jwt.WithAcceptableSkew(clockSkew),
- jwt.WithIssuer(s.envConfig.AppURL),
- jwt.WithValidator(TokenTypeValidator(OAuthRefreshTokenJWTType)),
- )
- if err != nil {
- return "", "", "", fmt.Errorf("failed to parse token: %w", err)
- }
-
- err = token.Get(RefreshTokenClaim, &rt)
- if err != nil {
- return "", "", "", fmt.Errorf("failed to get '%s' claim from token: %w", RefreshTokenClaim, err)
- }
-
- audiences, ok := token.Audience()
- if !ok || len(audiences) != 1 || audiences[0] == "" {
- return "", "", "", errors.New("failed to get 'aud' claim from token")
- }
- clientID = audiences[0]
-
- userID, ok = token.Subject()
- if !ok {
- return "", "", "", errors.New("failed to get 'sub' claim from token")
- }
-
- return userID, clientID, rt, nil
-}
-
-// GetTokenType returns the type of the JWT token issued by Pocket ID, but **does not validate it**.
-func (s *JwtService) GetTokenType(tokenString string) (string, jwt.Token, error) {
- // Disable validation and verification to parse the token without checking it
- token, err := jwt.ParseString(
- tokenString,
- jwt.WithValidate(false),
- jwt.WithVerify(false),
- )
- if err != nil {
- return "", nil, fmt.Errorf("failed to parse token: %w", err)
- }
-
- var tokenType string
- err = token.Get(TokenTypeClaim, &tokenType)
- if err != nil {
- return "", nil, fmt.Errorf("failed to get token type claim: %w", err)
- }
-
- return tokenType, token, nil
-}
-
// GetPublicJWK returns the JSON Web Key (JWK) for the public key.
func (s *JwtService) GetPublicJWK() (jwk.Key, error) {
if s.privateKey == nil {
@@ -547,6 +282,14 @@ func (s *JwtService) GetKeyAlg() (jwa.KeyAlgorithm, error) {
return alg, nil
}
+// GetKeyID returns the key ID (kid) of the signing key, if one is set.
+func (s *JwtService) GetKeyID() (string, bool) {
+ if s.privateKey == nil {
+ return "", false
+ }
+ return s.privateKey.KeyID()
+}
+
// GetIsAdmin returns the value of the "isAdmin" claim in the token
func GetIsAdmin(token jwt.Token) (bool, error) {
if !token.Has(IsAdminClaim) {
@@ -562,13 +305,13 @@ func GetIsAdmin(token jwt.Token) (bool, error) {
// GetAuthenticationMethod returns the first authentication method in the "amr" claim in the token
func GetAuthenticationMethod(token jwt.Token) (string, error) {
- if !token.Has(AuthenticationMethodsClaim) {
+ if !token.Has(common.AuthenticationMethodsClaim) {
return "", nil
}
var rawAuthenticationMethods []any
- err := token.Get(AuthenticationMethodsClaim, &rawAuthenticationMethods)
+ err := token.Get(common.AuthenticationMethodsClaim, &rawAuthenticationMethods)
if err != nil {
- return "", fmt.Errorf("failed to get '%s' claim from token: %w", AuthenticationMethodsClaim, err)
+ return "", fmt.Errorf("failed to get '%s' claim from token: %w", common.AuthenticationMethodsClaim, err)
}
if len(rawAuthenticationMethods) == 0 {
@@ -576,7 +319,7 @@ func GetAuthenticationMethod(token jwt.Token) (string, error) {
}
authenticationMethod, ok := rawAuthenticationMethods[0].(string)
if !ok {
- return "", fmt.Errorf("invalid '%s' claim in token: expected array of strings", AuthenticationMethodsClaim)
+ return "", fmt.Errorf("invalid '%s' claim in token: expected array of strings", common.AuthenticationMethodsClaim)
}
return authenticationMethod, nil
}
@@ -603,7 +346,7 @@ func SetAuthenticationMethods(token jwt.Token, authenticationMethod string) erro
if authenticationMethod == "" {
return nil
}
- return token.Set(AuthenticationMethodsClaim, []string{authenticationMethod})
+ return token.Set(common.AuthenticationMethodsClaim, []string{authenticationMethod})
}
// SetAudienceString sets the "aud" claim with a value that is a string, and not an array
@@ -626,3 +369,9 @@ func TokenTypeValidator(expectedTokenType string) jwt.ValidatorFunc {
return nil
}
}
+
+func (s *JwtService) GetPrivateKey() any {
+ var privateKey any
+ _ = jwk.Export(s.privateKey, &privateKey)
+ return privateKey
+}
diff --git a/backend/internal/service/jwt_service_test.go b/backend/internal/service/jwt_service_test.go
index d049ee6a..75408bfc 100644
--- a/backend/internal/service/jwt_service_test.go
+++ b/backend/internal/service/jwt_service_test.go
@@ -515,515 +515,6 @@ func TestGenerateVerifyAccessToken(t *testing.T) {
})
}
-func TestGenerateVerifyIdToken(t *testing.T) {
- mockConfig := NewTestAppConfigService(&model.AppConfig{
- SessionDuration: model.AppConfigVariable{Value: "60"}, // 60 minutes
- })
-
- t.Run("generates and verifies ID token with standard claims", func(t *testing.T) {
- service, _, _ := setupJwtService(t, mockConfig)
-
- userClaims := map[string]any{
- "sub": "user123",
- "name": "Test User",
- "email": "user@example.com",
- }
- const clientID = "test-client-123"
-
- tokenString, jti, err := service.GenerateIDToken(userClaims, clientID, "", "")
- require.NoError(t, err, "Failed to generate ID token")
- assert.NotEmpty(t, tokenString, "Token should not be empty")
- assert.Regexp(t, uuidRegexPattern, jti, "Returned JWT ID is not a UUID")
-
- claims, err := service.VerifyIdToken(tokenString, false)
- require.NoError(t, err, "Failed to verify generated ID token")
-
- subject, ok := claims.Subject()
- _ = assert.True(t, ok, "User ID not found in token") &&
- assert.Equal(t, "user123", subject, "Token subject should match user ID")
- audience, ok := claims.Audience()
- _ = assert.True(t, ok, "Audience not found in token") &&
- assert.Equal(t, []string{clientID}, audience, "Audience should contain the client ID")
- issuer, ok := claims.Issuer()
- _ = assert.True(t, ok, "Issuer not found in token") &&
- assert.Equal(t, service.envConfig.AppURL, issuer, "Issuer should match app URL")
- jwtID, ok := claims.JwtID()
- _ = assert.True(t, ok, "JWT ID not found in token") &&
- assert.Regexp(t, uuidRegexPattern, jwtID, "JWT ID is not a UUID")
- assert.Equal(t, jti, jwtID, "Returned JWT ID should match token claim")
-
- expectedExp := time.Now().Add(1 * time.Hour)
- expiration, ok := claims.Expiration()
- assert.True(t, ok, "Expiration not found in token")
- timeDiff := expectedExp.Sub(expiration).Minutes()
- assert.InDelta(t, 0, timeDiff, 1.0, "Token should expire in approximately 1 hour")
- })
-
- t.Run("can accept expired tokens if told so", func(t *testing.T) {
- service, _, _ := setupJwtService(t, mockConfig)
-
- userClaims := map[string]any{
- "sub": "user123",
- "name": "Test User",
- "email": "user@example.com",
- }
- const clientID = "test-client-123"
-
- token, err := jwt.NewBuilder().
- Subject(userClaims["sub"].(string)).
- Issuer(service.envConfig.AppURL).
- Audience([]string{clientID}).
- IssuedAt(time.Now().Add(-2 * time.Hour)).
- Expiration(time.Now().Add(-1 * time.Hour)).
- Build()
- require.NoError(t, err, "Failed to build token")
-
- err = SetTokenType(token, IDTokenJWTType)
- require.NoError(t, err, "Failed to set token type")
-
- for k, v := range userClaims {
- if k != "sub" {
- err = token.Set(k, v)
- require.NoError(t, err, "Failed to set claim")
- }
- }
-
- signed, err := jwt.Sign(token, jwt.WithKey(jwa.RS256(), service.privateKey))
- require.NoError(t, err, "Failed to sign token")
- tokenString := string(signed)
-
- _, err = service.VerifyIdToken(tokenString, false)
- require.Error(t, err, "Verification should fail with expired token when not allowing expired tokens")
- assert.Contains(t, err.Error(), "\"exp\" not satisfied", "Error message should indicate token verification failure")
-
- claims, err := service.VerifyIdToken(tokenString, true)
- require.NoError(t, err, "Verification should succeed with expired token when allowing expired tokens")
-
- subject, ok := claims.Subject()
- _ = assert.True(t, ok, "User ID not found in token") &&
- assert.Equal(t, userClaims["sub"], subject, "Token subject should match user ID")
- issuer, ok := claims.Issuer()
- _ = assert.True(t, ok, "Issuer not found in token") &&
- assert.Equal(t, service.envConfig.AppURL, issuer, "Issuer should match app URL")
- })
-
- t.Run("generates and verifies ID token with nonce", func(t *testing.T) {
- service, _, _ := setupJwtService(t, mockConfig)
-
- userClaims := map[string]any{
- "sub": "user456",
- "name": "Another User",
- }
- const clientID = "test-client-456"
- nonce := "random-nonce-value"
-
- tokenString, _, err := service.GenerateIDToken(userClaims, clientID, nonce, "")
- require.NoError(t, err, "Failed to generate ID token with nonce")
-
- publicKey, err := service.GetPublicJWK()
- require.NoError(t, err, "Failed to get public key")
- token, err := jwt.Parse([]byte(tokenString), jwt.WithKey(jwa.RS256(), publicKey))
- require.NoError(t, err, "Failed to parse token")
-
- var tokenNonce string
- err = token.Get("nonce", &tokenNonce)
- require.NoError(t, err, "Failed to get claims")
-
- assert.Equal(t, nonce, tokenNonce, "Token should contain the correct nonce")
- })
-
- t.Run("fails verification with incorrect issuer", func(t *testing.T) {
- service, _, _ := setupJwtService(t, mockConfig)
-
- userClaims := map[string]any{
- "sub": "user789",
- }
- tokenString, _, err := service.GenerateIDToken(userClaims, "client-789", "", "")
- require.NoError(t, err, "Failed to generate ID token")
-
- service.envConfig.AppURL = "https://wrong-issuer.com"
-
- _, err = service.VerifyIdToken(tokenString, false)
- require.Error(t, err, "Verification should fail with incorrect issuer")
- assert.Contains(t, err.Error(), "\"iss\" not satisfied", "Error message should indicate token verification failure")
- })
-
- t.Run("works with Ed25519 keys", func(t *testing.T) {
- db, envConfig := newTestDbAndEnv(t)
- origKeyID := createEdDSAKeyJWK(t, db, envConfig, mockConfig)
- service := initJwtService(t, db, mockConfig, envConfig)
-
- loadedKeyID, ok := service.privateKey.KeyID()
- require.True(t, ok)
- assert.Equal(t, origKeyID, loadedKeyID, "Loaded key should have the same ID as the original")
-
- userClaims := map[string]any{
- "sub": "eddsauser456",
- "name": "EdDSA User",
- "email": "eddsauser@example.com",
- }
- const clientID = "eddsa-client-123"
-
- tokenString, _, err := service.GenerateIDToken(userClaims, clientID, "", "")
- require.NoError(t, err, "Failed to generate ID token with key")
- assert.NotEmpty(t, tokenString, "Token should not be empty")
-
- claims, err := service.VerifyIdToken(tokenString, false)
- require.NoError(t, err, "Failed to verify generated ID token with key")
-
- subject, ok := claims.Subject()
- _ = assert.True(t, ok, "User ID not found in token") &&
- assert.Equal(t, "eddsauser456", subject, "Token subject should match user ID")
- issuer, ok := claims.Issuer()
- _ = assert.True(t, ok, "Issuer not found in token") &&
- assert.Equal(t, service.envConfig.AppURL, issuer, "Issuer should match app URL")
-
- publicKey, err := service.GetPublicJWK()
- require.NoError(t, err)
- assert.Equal(t, jwa.OKP().String(), publicKey.KeyType().String(), "Key type should be OKP")
- alg, ok := publicKey.Algorithm()
- require.True(t, ok)
- assert.Equal(t, jwa.EdDSA().String(), alg.String(), "Algorithm should be EdDSA")
- })
-
- t.Run("works with P-256 keys", func(t *testing.T) {
- db, envConfig := newTestDbAndEnv(t)
- origKeyID := createECDSAKeyJWK(t, db, envConfig, mockConfig)
- service := initJwtService(t, db, mockConfig, envConfig)
-
- loadedKeyID, ok := service.privateKey.KeyID()
- require.True(t, ok)
- assert.Equal(t, origKeyID, loadedKeyID, "Loaded key should have the same ID as the original")
-
- userClaims := map[string]any{
- "sub": "ecdsauser456",
- "email": "ecdsauser@example.com",
- }
- const clientID = "ecdsa-client-123"
-
- tokenString, _, err := service.GenerateIDToken(userClaims, clientID, "", "")
- require.NoError(t, err, "Failed to generate ID token with key")
- assert.NotEmpty(t, tokenString, "Token should not be empty")
-
- claims, err := service.VerifyIdToken(tokenString, false)
- require.NoError(t, err, "Failed to verify generated ID token with key")
-
- subject, ok := claims.Subject()
- _ = assert.True(t, ok, "User ID not found in token") &&
- assert.Equal(t, "ecdsauser456", subject, "Token subject should match user ID")
- issuer, ok := claims.Issuer()
- _ = assert.True(t, ok, "Issuer not found in token") &&
- assert.Equal(t, service.envConfig.AppURL, issuer, "Issuer should match app URL")
-
- publicKey, err := service.GetPublicJWK()
- require.NoError(t, err)
- assert.Equal(t, jwa.EC().String(), publicKey.KeyType().String(), "Key type should be EC")
- alg, ok := publicKey.Algorithm()
- require.True(t, ok)
- assert.Equal(t, jwa.ES256().String(), alg.String(), "Algorithm should be ES256")
- })
-
- t.Run("works with RSA-4096 keys", func(t *testing.T) {
- db, envConfig := newTestDbAndEnv(t)
- origKeyID := createRSA4096KeyJWK(t, db, envConfig, mockConfig)
- service := initJwtService(t, db, mockConfig, envConfig)
-
- loadedKeyID, ok := service.privateKey.KeyID()
- require.True(t, ok)
- assert.Equal(t, origKeyID, loadedKeyID, "Loaded key should have the same ID as the original")
-
- userClaims := map[string]any{
- "sub": "rsauser456",
- "name": "RSA User",
- "email": "rsauser@example.com",
- }
- const clientID = "rsa-client-123"
-
- tokenString, _, err := service.GenerateIDToken(userClaims, clientID, "", "")
- require.NoError(t, err, "Failed to generate ID token with key")
- assert.NotEmpty(t, tokenString, "Token should not be empty")
-
- claims, err := service.VerifyIdToken(tokenString, false)
- require.NoError(t, err, "Failed to verify generated ID token with key")
-
- subject, ok := claims.Subject()
- _ = assert.True(t, ok, "User ID not found in token") &&
- assert.Equal(t, "rsauser456", subject, "Token subject should match user ID")
- issuer, ok := claims.Issuer()
- _ = assert.True(t, ok, "Issuer not found in token") &&
- assert.Equal(t, service.envConfig.AppURL, issuer, "Issuer should match app URL")
- })
-}
-
-func TestGenerateVerifyOAuthAccessToken(t *testing.T) {
- mockConfig := NewTestAppConfigService(&model.AppConfig{
- SessionDuration: model.AppConfigVariable{Value: "60"}, // 60 minutes
- })
-
- t.Run("generates and verifies OAuth access token with standard claims", func(t *testing.T) {
- service, _, _ := setupJwtService(t, mockConfig)
-
- user := model.User{
- Base: model.Base{ID: "user123"},
- Email: new("user@example.com"),
- }
- const clientID = "test-client-123"
-
- tokenString, err := service.GenerateOAuthAccessToken(user, clientID, "")
- require.NoError(t, err, "Failed to generate OAuth access token")
- assert.NotEmpty(t, tokenString, "Token should not be empty")
-
- claims, err := service.VerifyOAuthAccessToken(tokenString)
- require.NoError(t, err, "Failed to verify generated OAuth access token")
-
- subject, ok := claims.Subject()
- _ = assert.True(t, ok, "User ID not found in token") &&
- assert.Equal(t, user.ID, subject, "Token subject should match user ID")
- audience, ok := claims.Audience()
- _ = assert.True(t, ok, "Audience not found in token") &&
- assert.Equal(t, []string{clientID}, audience, "Audience should contain the client ID")
- issuer, ok := claims.Issuer()
- _ = assert.True(t, ok, "Issuer not found in token") &&
- assert.Equal(t, service.envConfig.AppURL, issuer, "Issuer should match app URL")
- jwtID, ok := claims.JwtID()
- _ = assert.True(t, ok, "JWT ID not found in token") &&
- assert.Regexp(t, uuidRegexPattern, jwtID, "JWT ID is not a UUID")
-
- expectedExp := time.Now().Add(1 * time.Hour)
- expiration, ok := claims.Expiration()
- assert.True(t, ok, "Expiration not found in token")
- timeDiff := expectedExp.Sub(expiration).Minutes()
- assert.InDelta(t, 0, timeDiff, 1.0, "Token should expire in approximately 1 hour")
- })
-
- t.Run("sets authentication method references claim when provided", func(t *testing.T) {
- service, _, _ := setupJwtService(t, mockConfig)
-
- user := model.User{
- Base: model.Base{ID: "oauth-amr-user"},
- }
- const clientID = "test-client-amr"
-
- tokenString, err := service.GenerateOAuthAccessToken(user, clientID, AuthenticationMethodPhishingResistant)
- require.NoError(t, err, "Failed to generate OAuth access token")
-
- claims, err := service.VerifyOAuthAccessToken(tokenString)
- require.NoError(t, err, "Failed to verify generated OAuth access token")
-
- authenticationMethod, err := GetAuthenticationMethod(claims)
- _ = assert.NoError(t, err, "Failed to get amr claim") &&
- assert.Equal(t, AuthenticationMethodPhishingResistant, authenticationMethod, "amr should match")
- })
-
- t.Run("fails verification for expired token", func(t *testing.T) {
- service, _, _ := setupJwtService(t, mockConfig)
-
- user := model.User{Base: model.Base{ID: "user456"}}
- const clientID = "test-client-456"
-
- token, err := jwt.NewBuilder().
- Subject(user.ID).
- Expiration(time.Now().Add(-1 * time.Hour)).
- IssuedAt(time.Now().Add(-2 * time.Hour)).
- Audience([]string{clientID}).
- Issuer(service.envConfig.AppURL).
- Build()
- require.NoError(t, err, "Failed to build token")
-
- err = SetTokenType(token, OAuthAccessTokenJWTType)
- require.NoError(t, err, "Failed to set token type")
-
- signed, err := jwt.Sign(token, jwt.WithKey(jwa.RS256(), service.privateKey))
- require.NoError(t, err, "Failed to sign token")
-
- _, err = service.VerifyOAuthAccessToken(string(signed))
- require.Error(t, err, "Verification should fail with expired token")
- assert.Contains(t, err.Error(), "\"exp\" not satisfied", "Error message should indicate token verification failure")
- })
-
- t.Run("fails verification with invalid signature", func(t *testing.T) {
- service1, _, _ := setupJwtService(t, mockConfig)
- service2, _, _ := setupJwtService(t, mockConfig)
-
- user := model.User{Base: model.Base{ID: "user789"}}
- const clientID = "test-client-789"
-
- tokenString, err := service1.GenerateOAuthAccessToken(user, clientID, "")
- require.NoError(t, err, "Failed to generate OAuth access token")
-
- _, err = service2.VerifyOAuthAccessToken(tokenString)
- require.Error(t, err, "Verification should fail with invalid signature")
- assert.Contains(t, err.Error(), "verification error", "Error message should indicate token verification failure")
- })
-
- t.Run("works with Ed25519 keys", func(t *testing.T) {
- db, envConfig := newTestDbAndEnv(t)
- origKeyID := createEdDSAKeyJWK(t, db, envConfig, mockConfig)
- service := initJwtService(t, db, mockConfig, envConfig)
-
- loadedKeyID, ok := service.privateKey.KeyID()
- require.True(t, ok)
- assert.Equal(t, origKeyID, loadedKeyID, "Loaded key should have the same ID as the original")
-
- user := model.User{
- Base: model.Base{ID: "eddsauser789"},
- Email: new("eddsaoauth@example.com"),
- }
- const clientID = "eddsa-oauth-client"
-
- tokenString, err := service.GenerateOAuthAccessToken(user, clientID, "")
- require.NoError(t, err, "Failed to generate OAuth access token with key")
- assert.NotEmpty(t, tokenString, "Token should not be empty")
-
- claims, err := service.VerifyOAuthAccessToken(tokenString)
- require.NoError(t, err, "Failed to verify generated OAuth access token with key")
-
- subject, ok := claims.Subject()
- _ = assert.True(t, ok, "User ID not found in token") &&
- assert.Equal(t, user.ID, subject, "Token subject should match user ID")
- audience, ok := claims.Audience()
- _ = assert.True(t, ok, "Audience not found in token") &&
- assert.Equal(t, []string{clientID}, audience, "Audience should contain the client ID")
-
- publicKey, err := service.GetPublicJWK()
- require.NoError(t, err)
- assert.Equal(t, jwa.OKP().String(), publicKey.KeyType().String(), "Key type should be OKP")
- alg, ok := publicKey.Algorithm()
- require.True(t, ok)
- assert.Equal(t, jwa.EdDSA().String(), alg.String(), "Algorithm should be EdDSA")
- })
-
- t.Run("works with ECDSA keys", func(t *testing.T) {
- db, envConfig := newTestDbAndEnv(t)
- origKeyID := createECDSAKeyJWK(t, db, envConfig, mockConfig)
- service := initJwtService(t, db, mockConfig, envConfig)
-
- loadedKeyID, ok := service.privateKey.KeyID()
- require.True(t, ok)
- assert.Equal(t, origKeyID, loadedKeyID, "Loaded key should have the same ID as the original")
-
- user := model.User{
- Base: model.Base{ID: "ecdsauser789"},
- Email: new("ecdsaoauth@example.com"),
- }
- const clientID = "ecdsa-oauth-client"
-
- tokenString, err := service.GenerateOAuthAccessToken(user, clientID, "")
- require.NoError(t, err, "Failed to generate OAuth access token with key")
- assert.NotEmpty(t, tokenString, "Token should not be empty")
-
- claims, err := service.VerifyOAuthAccessToken(tokenString)
- require.NoError(t, err, "Failed to verify generated OAuth access token with key")
-
- subject, ok := claims.Subject()
- _ = assert.True(t, ok, "User ID not found in token") &&
- assert.Equal(t, user.ID, subject, "Token subject should match user ID")
- audience, ok := claims.Audience()
- _ = assert.True(t, ok, "Audience not found in token") &&
- assert.Equal(t, []string{clientID}, audience, "Audience should contain the client ID")
-
- publicKey, err := service.GetPublicJWK()
- require.NoError(t, err)
- assert.Equal(t, jwa.EC().String(), publicKey.KeyType().String(), "Key type should be EC")
- alg, ok := publicKey.Algorithm()
- require.True(t, ok)
- assert.Equal(t, jwa.ES256().String(), alg.String(), "Algorithm should be ES256")
- })
-
- t.Run("works with RSA keys", func(t *testing.T) {
- db, envConfig := newTestDbAndEnv(t)
- origKeyID := createRSA4096KeyJWK(t, db, envConfig, mockConfig)
- service := initJwtService(t, db, mockConfig, envConfig)
-
- loadedKeyID, ok := service.privateKey.KeyID()
- require.True(t, ok)
- assert.Equal(t, origKeyID, loadedKeyID, "Loaded key should have the same ID as the original")
-
- user := model.User{
- Base: model.Base{ID: "rsauser789"},
- Email: new("rsaoauth@example.com"),
- }
- const clientID = "rsa-oauth-client"
-
- tokenString, err := service.GenerateOAuthAccessToken(user, clientID, "")
- require.NoError(t, err, "Failed to generate OAuth access token with key")
- assert.NotEmpty(t, tokenString, "Token should not be empty")
-
- claims, err := service.VerifyOAuthAccessToken(tokenString)
- require.NoError(t, err, "Failed to verify generated OAuth access token with key")
-
- subject, ok := claims.Subject()
- _ = assert.True(t, ok, "User ID not found in token") &&
- assert.Equal(t, user.ID, subject, "Token subject should match user ID")
- audience, ok := claims.Audience()
- _ = assert.True(t, ok, "Audience not found in token") &&
- assert.Equal(t, []string{clientID}, audience, "Audience should contain the client ID")
-
- publicKey, err := service.GetPublicJWK()
- require.NoError(t, err)
- assert.Equal(t, jwa.RSA().String(), publicKey.KeyType().String(), "Key type should be RSA")
- alg, ok := publicKey.Algorithm()
- require.True(t, ok)
- assert.Equal(t, jwa.RS256().String(), alg.String(), "Algorithm should be RS256")
- })
-}
-
-func TestGenerateVerifyOAuthRefreshToken(t *testing.T) {
- mockConfig := NewTestAppConfigService(&model.AppConfig{})
-
- t.Run("generates and verifies refresh token", func(t *testing.T) {
- service, _, _ := setupJwtService(t, mockConfig)
-
- const (
- userID = "user123"
- clientID = "client123"
- refreshToken = "rt-123"
- )
-
- tokenString, err := service.GenerateOAuthRefreshToken(userID, clientID, refreshToken)
- require.NoError(t, err, "Failed to generate refresh token")
- assert.NotEmpty(t, tokenString, "Token should not be empty")
-
- resUser, resClient, resRT, err := service.VerifyOAuthRefreshToken(tokenString)
- require.NoError(t, err, "Failed to verify generated token")
- assert.Equal(t, userID, resUser, "Should return correct user ID")
- assert.Equal(t, clientID, resClient, "Should return correct client ID")
- assert.Equal(t, refreshToken, resRT, "Should return correct refresh token")
- })
-
- t.Run("fails verification for expired token", func(t *testing.T) {
- service, _, _ := setupJwtService(t, mockConfig)
-
- token, err := jwt.NewBuilder().
- Subject("user789").
- Expiration(time.Now().Add(-1 * time.Hour)).
- IssuedAt(time.Now().Add(-2 * time.Hour)).
- Audience([]string{"client123"}).
- Issuer(service.envConfig.AppURL).
- Build()
- require.NoError(t, err, "Failed to build token")
-
- signed, err := jwt.Sign(token, jwt.WithKey(jwa.RS256(), service.privateKey))
- require.NoError(t, err, "Failed to sign token")
-
- _, _, _, err = service.VerifyOAuthRefreshToken(string(signed))
- require.Error(t, err, "Verification should fail with expired token")
- assert.Contains(t, err.Error(), "\"exp\" not satisfied", "Error message should indicate token verification failure")
- })
-
- t.Run("fails verification with invalid signature", func(t *testing.T) {
- service1, _, _ := setupJwtService(t, mockConfig)
- service2, _, _ := setupJwtService(t, mockConfig)
-
- tokenString, err := service1.GenerateOAuthRefreshToken("user789", "client123", "my-rt-123")
- require.NoError(t, err, "Failed to generate refresh token")
-
- _, _, _, err = service2.VerifyOAuthRefreshToken(tokenString)
- require.Error(t, err, "Verification should fail with invalid signature")
- assert.Contains(t, err.Error(), "verification error", "Error message should indicate token verification failure")
- })
-}
-
func TestTokenTypeValidator(t *testing.T) {
// Create a context for the validator function
ctx := context.Background()
@@ -1045,16 +536,16 @@ func TestTokenTypeValidator(t *testing.T) {
t.Run("fails when token type doesn't match expected type", func(t *testing.T) {
// Create a token with a different type
token := jwt.New()
- err := token.Set(TokenTypeClaim, OAuthAccessTokenJWTType)
+ err := token.Set(TokenTypeClaim, "other-token")
require.NoError(t, err, "Failed to set token type claim")
// Create a validator function for a different expected type
- validator := TokenTypeValidator(IDTokenJWTType)
+ validator := TokenTypeValidator(AccessTokenJWTType)
// Validate the token
err = validator(ctx, token)
require.Error(t, err, "Validator should reject token with non-matching type")
- assert.Contains(t, err.Error(), "invalid token type: expected id-token, got oauth-access-token")
+ assert.Contains(t, err.Error(), "invalid token type: expected access-token, got other-token")
})
t.Run("fails when token type claim is missing", func(t *testing.T) {
@@ -1071,57 +562,6 @@ func TestTokenTypeValidator(t *testing.T) {
})
}
-func TestGetTokenType(t *testing.T) {
- mockConfig := NewTestAppConfigService(&model.AppConfig{})
- service, _, _ := setupJwtService(t, mockConfig)
-
- buildTokenForType := func(t *testing.T, typ string, setClaimsFn func(b *jwt.Builder)) string {
- t.Helper()
-
- b := jwt.NewBuilder()
- b.Subject("user123")
- if setClaimsFn != nil {
- setClaimsFn(b)
- }
-
- token, err := b.Build()
- require.NoError(t, err, "Failed to build token")
-
- err = SetTokenType(token, typ)
- require.NoError(t, err, "Failed to set token type")
-
- alg, _ := service.privateKey.Algorithm()
- signed, err := jwt.Sign(token, jwt.WithKey(alg, service.privateKey))
- require.NoError(t, err, "Failed to sign token")
-
- return string(signed)
- }
-
- t.Run("correctly identifies access tokens", func(t *testing.T) {
- tokenString := buildTokenForType(t, AccessTokenJWTType, nil)
-
- tokenType, _, err := service.GetTokenType(tokenString)
- require.NoError(t, err, "GetTokenType should not return an error")
- assert.Equal(t, AccessTokenJWTType, tokenType, "Should identify access token type")
- })
-
- t.Run("correctly identifies ID tokens", func(t *testing.T) {
- tokenString := buildTokenForType(t, IDTokenJWTType, nil)
-
- tokenType, _, err := service.GetTokenType(tokenString)
- require.NoError(t, err, "GetTokenType should not return an error")
- assert.Equal(t, IDTokenJWTType, tokenType, "Should identify ID token type")
- })
-
- t.Run("fails when token type claim is missing", func(t *testing.T) {
- tokenString := buildTokenForType(t, "", nil)
-
- _, _, err := service.GetTokenType(tokenString)
- require.Error(t, err, "GetTokenType should return an error for tokens without type claim")
- assert.Contains(t, err.Error(), "failed to get token type claim", "Error message should indicate missing token type claim")
- })
-}
-
func importKey(t *testing.T, db *gorm.DB, envConfig *common.EnvConfigSchema, appConfig *AppConfigService, privateKeyRaw any) string {
t.Helper()
diff --git a/backend/internal/service/oidc_service.go b/backend/internal/service/oidc_service.go
index ef6f0054..e8fd2d61 100644
--- a/backend/internal/service/oidc_service.go
+++ b/backend/internal/service/oidc_service.go
@@ -2,28 +2,16 @@ package service
import (
"context"
- "crypto/sha256"
- "crypto/subtle"
- "crypto/tls"
- "encoding/base64"
- "encoding/json"
"errors"
"fmt"
"io"
- "log/slog"
"mime/multipart"
"net/http"
"net/url"
"path"
- "slices"
"strings"
"time"
- "github.com/lestrrat-go/httprc/v3"
- "github.com/lestrrat-go/httprc/v3/errsink"
- "github.com/lestrrat-go/jwx/v3/jwk"
- "github.com/lestrrat-go/jwx/v3/jws"
- "github.com/lestrrat-go/jwx/v3/jwt"
"golang.org/x/crypto/bcrypt"
"gorm.io/gorm"
"gorm.io/gorm/clause"
@@ -32,6 +20,7 @@ import (
"github.com/pocket-id/pocket-id/backend/internal/dto"
"github.com/pocket-id/pocket-id/backend/internal/model"
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
+ "github.com/pocket-id/pocket-id/backend/internal/oidc"
"github.com/pocket-id/pocket-id/backend/internal/storage"
"github.com/pocket-id/pocket-id/backend/internal/utils"
imageutil "github.com/pocket-id/pocket-id/backend/internal/utils/image"
@@ -43,832 +32,51 @@ const (
GrantTypeDeviceCode = "urn:ietf:params:oauth:grant-type:device_code"
GrantTypeClientCredentials = "client_credentials"
- ClientAssertionTypeJWTBearer = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer" //nolint:gosec
-
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 {
- db *gorm.DB
- jwtService *JwtService
- appConfigService *AppConfigService
- auditLogService *AuditLogService
- customClaimService *CustomClaimService
- webAuthnService *WebAuthnService
- scimService *ScimService
+ db *gorm.DB
+ jwtService *JwtService
+ appConfigService *AppConfigService
+ previewBuilder oidcClientPreviewBuilder
+ scimService *ScimService
httpClient *http.Client
- jwkCache *jwk.Cache
fileStorage storage.FileStorage
}
+type oidcClientPreviewBuilder interface {
+ BuildClientPreview(ctx context.Context, client model.OidcClient, userID string, scopes []string, authenticationMethod string) (*oidc.ClientPreview, error)
+}
+
func NewOidcService(
- ctx context.Context,
db *gorm.DB,
jwtService *JwtService,
appConfigService *AppConfigService,
- auditLogService *AuditLogService,
- customClaimService *CustomClaimService,
- webAuthnService *WebAuthnService,
+ previewBuilder oidcClientPreviewBuilder,
scimService *ScimService,
httpClient *http.Client,
fileStorage storage.FileStorage,
) (s *OidcService, err error) {
s = &OidcService{
- db: db,
- jwtService: jwtService,
- appConfigService: appConfigService,
- auditLogService: auditLogService,
- customClaimService: customClaimService,
- webAuthnService: webAuthnService,
- scimService: scimService,
- httpClient: httpClient,
- fileStorage: fileStorage,
- }
-
- // Note: we don't pass the HTTP Client with OTel instrumented to this because requests are always made in background and not tied to a specific trace
- s.jwkCache, err = s.getJWKCache(ctx)
- if err != nil {
- return nil, err
+ db: db,
+ jwtService: jwtService,
+ appConfigService: appConfigService,
+ previewBuilder: previewBuilder,
+ scimService: scimService,
+ httpClient: httpClient,
+ fileStorage: fileStorage,
}
return s, nil
}
-func (s *OidcService) getJWKCache(ctx context.Context) (*jwk.Cache, error) {
- // We need to create a custom HTTP client to set a timeout.
- client := s.httpClient
- if client == nil {
- client = &http.Client{
- Timeout: 20 * time.Second,
- }
-
- defaultTransport, ok := http.DefaultTransport.(*http.Transport)
- if !ok {
- // Indicates a development-time error
- panic("Default transport is not of type *http.Transport")
- }
- transport := defaultTransport.Clone()
- transport.TLSClientConfig.MinVersion = tls.VersionTLS12
- client.Transport = transport
- }
-
- // Create the JWKS cache
- return jwk.NewCache(ctx,
- httprc.NewClient(
- httprc.WithErrorSink(errsink.NewSlog(slog.Default())),
- httprc.WithHTTPClient(client),
- ),
- )
-}
-
-func (s *OidcService) Authorize(ctx context.Context, input dto.AuthorizeOidcClientRequestDto, userID string, authenticationMethod string, ipAddress, userAgent string) (string, string, error) {
- tx := s.db.Begin()
- defer tx.Rollback()
-
- var client model.OidcClient
- err := tx.
- WithContext(ctx).
- Preload("AllowedUserGroups").
- First(&client, "id = ?", input.ClientID).
- Error
- if err != nil {
- 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{}
- }
-
- // Validate the callback URL before any prompt checks, so that prompt-related
- // error responses never contain an unvalidated redirect target
- callbackURL, err := s.getCallbackURL(&client, input.CallbackURL, tx, ctx)
- if err != nil {
- return "", "", err
- }
-
- // Parse prompt parameter (space-delimited list per OIDC spec)
- promptValues := parsePromptParameter(input.Prompt)
- 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.NewOidcInvalidRequestError("prompt type 'none' cannot be combined with others")
- }
-
- // 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 {
- if input.ReauthenticationToken == "" {
- return "", "", &common.ReauthenticationRequiredError{}
- }
-
- err = s.webAuthnService.ConsumeReauthenticationToken(ctx, tx, input.ReauthenticationToken, userID)
- if err != nil {
- return "", "", err
- }
- }
-
- // Check if the user group is allowed to authorize the client
- var user model.User
- err = tx.
- WithContext(ctx).
- Preload("UserGroups").
- First(&user, "id = ?", userID).
- Error
- if err != nil {
- return "", "", err
- }
-
- if !IsUserGroupAllowedToAuthorize(user, client) {
- return "", "", &common.OidcAccessDeniedError{}
- }
-
- // Handle prompt=none - if consent would be required, we can't show UI
- if hasPromptNone {
- hasAlreadyAuthorized, err := s.hasAuthorizedClientInternal(ctx, input.ClientID, userID, input.Scope, tx)
- if err != nil {
- return "", "", err
- }
- if !hasAlreadyAuthorized {
- return "", callbackURL, &common.OidcConsentRequiredError{}
- }
- }
-
- hasAlreadyAuthorizedClient, err := s.createAuthorizedClientInternal(ctx, userID, input.ClientID, input.Scope, tx)
- if err != nil {
- return "", "", err
- }
-
- // Create the authorization code
- code, err := s.createAuthorizationCode(ctx, input.ClientID, userID, input.Scope, authenticationMethod, input.Nonce, input.CodeChallenge, input.CodeChallengeMethod, tx)
- if err != nil {
- return "", "", err
- }
-
- // Log the authorization event
- if hasAlreadyAuthorizedClient {
- 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,
- )
- }
-
- err = tx.Commit().Error
- if err != nil {
- return "", "", fmt.Errorf("failed to commit transaction: %w", err)
- }
-
- 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.
- WithContext(ctx).
- First(&userAuthorizedOidcClient, "client_id = ? AND user_id = ?", clientID, userID).
- Error
- if err != nil {
- if errors.Is(err, gorm.ErrRecordNotFound) {
- return false, nil
- }
- return false, err
- }
-
- if userAuthorizedOidcClient.Scope != scope {
- return false, nil
- }
-
- return true, nil
-}
-
-// IsUserGroupAllowedToAuthorize checks if the user group of the user is allowed to authorize the client
-func IsUserGroupAllowedToAuthorize(user model.User, client model.OidcClient) bool {
- if !client.IsGroupRestricted {
- return true
- }
-
- isAllowedToAuthorize := false
- for _, userGroup := range client.AllowedUserGroups {
- for _, userGroupUser := range user.UserGroups {
- if userGroup.ID == userGroupUser.ID {
- isAllowedToAuthorize = true
- break
- }
- }
- }
-
- return isAllowedToAuthorize
-}
-
-type CreatedTokens struct {
- IdToken string
- AccessToken string
- RefreshToken string
- ExpiresIn time.Duration
-}
-
-func (s *OidcService) CreateTokens(ctx context.Context, input dto.OidcCreateTokensDto) (CreatedTokens, error) {
- switch input.GrantType {
- case GrantTypeAuthorizationCode:
- return s.createTokenFromAuthorizationCode(ctx, input)
- case GrantTypeRefreshToken:
- return s.createTokenFromRefreshToken(ctx, input)
- case GrantTypeDeviceCode:
- return s.createTokenFromDeviceCode(ctx, input)
- case GrantTypeClientCredentials:
- return s.createTokenFromClientCredentials(ctx, input)
- default:
- return CreatedTokens{}, &common.OidcGrantTypeNotSupportedError{}
- }
-}
-
-func (s *OidcService) createTokenFromDeviceCode(ctx context.Context, input dto.OidcCreateTokensDto) (CreatedTokens, error) {
- tx := s.db.Begin()
- defer func() {
- tx.Rollback()
- }()
-
- _, err := s.verifyClientCredentialsInternal(ctx, tx, clientAuthCredentialsFromCreateTokensDto(&input), true)
- if err != nil {
- return CreatedTokens{}, err
- }
-
- // Get the device authorization from database with explicit query conditions
- var deviceAuth model.OidcDeviceCode
- err = tx.
- WithContext(ctx).
- Preload("User").
- Where("device_code = ? AND client_id = ?", input.DeviceCode, input.ClientID).
- First(&deviceAuth).
- Error
- if err != nil {
- if errors.Is(err, gorm.ErrRecordNotFound) {
- return CreatedTokens{}, &common.OidcInvalidDeviceCodeError{}
- }
- return CreatedTokens{}, err
- }
-
- // Check if device code has expired
- if time.Now().After(deviceAuth.ExpiresAt.ToTime()) {
- return CreatedTokens{}, &common.OidcDeviceCodeExpiredError{}
- }
-
- // Check if device code has been authorized
- if !deviceAuth.IsAuthorized || deviceAuth.UserID == nil {
- return CreatedTokens{}, &common.OidcAuthorizationPendingError{}
- }
-
- // Get user claims for the ID token - ensure UserID is not nil
- if deviceAuth.UserID == nil {
- return CreatedTokens{}, &common.OidcAuthorizationPendingError{}
- }
-
- userClaims, err := s.getUserClaimsForClientInternal(ctx, *deviceAuth.UserID, input.ClientID, tx)
- if err != nil {
- return CreatedTokens{}, err
- }
-
- // Explicitly use the input clientID for the audience claim to ensure consistency
- idToken, idTokenJti, err := s.jwtService.GenerateIDToken(userClaims, input.ClientID, deviceAuth.Nonce, deviceAuth.AuthenticationMethod)
- if err != nil {
- return CreatedTokens{}, err
- }
-
- refreshToken, err := s.createRefreshToken(ctx, input.ClientID, *deviceAuth.UserID, deviceAuth.Scope, deviceAuth.AuthenticationMethod, idTokenJti, tx)
- if err != nil {
- return CreatedTokens{}, err
- }
-
- accessToken, err := s.jwtService.GenerateOAuthAccessToken(deviceAuth.User, input.ClientID, deviceAuth.AuthenticationMethod)
- if err != nil {
- return CreatedTokens{}, err
- }
-
- // Delete the used device code
- err = tx.WithContext(ctx).Delete(&deviceAuth).Error
- if err != nil {
- return CreatedTokens{}, err
- }
-
- err = tx.Commit().Error
- if err != nil {
- return CreatedTokens{}, err
- }
-
- return CreatedTokens{
- IdToken: idToken,
- AccessToken: accessToken,
- RefreshToken: refreshToken,
- ExpiresIn: AccessTokenDuration,
- }, nil
-}
-
-func (s *OidcService) createTokenFromClientCredentials(ctx context.Context, input dto.OidcCreateTokensDto) (CreatedTokens, error) {
- client, err := s.verifyClientCredentialsInternal(ctx, s.db, clientAuthCredentialsFromCreateTokensDto(&input), false)
- if err != nil {
- return CreatedTokens{}, err
- }
-
- // GenerateOAuthAccessToken uses user.ID as a "sub" claim. Prefix is used to take those security considerations
- // into account: https://datatracker.ietf.org/doc/html/rfc9068#name-security-considerations
- dummyUser := model.User{
- Base: model.Base{ID: "client-" + client.ID},
- }
-
- audClaim := client.ID
- if input.Resource != "" {
- audClaim = input.Resource
- }
-
- accessToken, err := s.jwtService.GenerateOAuthAccessToken(dummyUser, audClaim, "")
- if err != nil {
- return CreatedTokens{}, err
- }
-
- return CreatedTokens{
- AccessToken: accessToken,
- ExpiresIn: AccessTokenDuration,
- }, nil
-}
-
-func (s *OidcService) createTokenFromAuthorizationCode(ctx context.Context, input dto.OidcCreateTokensDto) (CreatedTokens, error) {
- tx := s.db.Begin()
- defer func() {
- tx.Rollback()
- }()
-
- client, err := s.verifyClientCredentialsInternal(ctx, tx, clientAuthCredentialsFromCreateTokensDto(&input), true)
- if err != nil {
- return CreatedTokens{}, err
- }
-
- var authorizationCodeMetaData model.OidcAuthorizationCode
- err = tx.
- WithContext(ctx).
- Preload("User").
- First(&authorizationCodeMetaData, "code = ?", input.Code).
- Error
- if err != nil {
- return CreatedTokens{}, &common.OidcInvalidAuthorizationCodeError{}
- }
-
- // If the client is public or PKCE is enabled, the code verifier must match the code challenge
- if client.IsPublic || client.PkceEnabled {
- if !validateCodeVerifier(input.CodeVerifier, *authorizationCodeMetaData.CodeChallenge, *authorizationCodeMetaData.CodeChallengeMethodSha256) {
- return CreatedTokens{}, &common.OidcInvalidCodeVerifierError{}
- }
- }
-
- if authorizationCodeMetaData.ClientID != input.ClientID || authorizationCodeMetaData.ExpiresAt.ToTime().Before(time.Now()) {
- return CreatedTokens{}, &common.OidcInvalidAuthorizationCodeError{}
- }
-
- userClaims, err := s.getUserClaimsForClientInternal(ctx, authorizationCodeMetaData.UserID, input.ClientID, tx)
- if err != nil {
- return CreatedTokens{}, err
- }
-
- authenticationMethod := authorizationCodeMetaData.AuthenticationMethod
-
- idToken, idTokenJti, err := s.jwtService.GenerateIDToken(userClaims, input.ClientID, authorizationCodeMetaData.Nonce, authenticationMethod)
- if err != nil {
- return CreatedTokens{}, err
- }
-
- // Generate a refresh token
- refreshToken, err := s.createRefreshToken(ctx, input.ClientID, authorizationCodeMetaData.UserID, authorizationCodeMetaData.Scope, authenticationMethod, idTokenJti, tx)
- if err != nil {
- return CreatedTokens{}, err
- }
-
- accessToken, err := s.jwtService.GenerateOAuthAccessToken(authorizationCodeMetaData.User, input.ClientID, authenticationMethod)
- if err != nil {
- return CreatedTokens{}, err
- }
-
- err = tx.
- WithContext(ctx).
- Delete(&authorizationCodeMetaData).
- Error
- if err != nil {
- return CreatedTokens{}, err
- }
-
- err = tx.Commit().Error
- if err != nil {
- return CreatedTokens{}, err
- }
-
- return CreatedTokens{
- IdToken: idToken,
- AccessToken: accessToken,
- RefreshToken: refreshToken,
- ExpiresIn: AccessTokenDuration,
- }, nil
-}
-
-func (s *OidcService) createTokenFromRefreshToken(ctx context.Context, input dto.OidcCreateTokensDto) (CreatedTokens, error) {
- if input.RefreshToken == "" {
- return CreatedTokens{}, &common.OidcMissingRefreshTokenError{}
- }
-
- // Validate the signed refresh token and extract the actual token (which is a claim in the signed one)
- userID, clientID, rt, err := s.jwtService.VerifyOAuthRefreshToken(input.RefreshToken)
- if err != nil {
- return CreatedTokens{}, &common.OidcInvalidRefreshTokenError{}
- }
-
- tx := s.db.Begin()
- defer func() {
- tx.Rollback()
- }()
-
- client, err := s.verifyClientCredentialsInternal(ctx, tx, clientAuthCredentialsFromCreateTokensDto(&input), true)
- if err != nil {
- return CreatedTokens{}, err
- }
-
- // The ID of the client that made the call must match the client ID in the token
- if client.ID != clientID {
- return CreatedTokens{}, &common.OidcInvalidRefreshTokenError{}
- }
-
- // Verify refresh token
- var storedRefreshToken model.OidcRefreshToken
- err = tx.
- WithContext(ctx).
- Preload("User.UserGroups").
- Where(
- "token = ? AND expires_at > ? AND user_id = ? AND client_id = ?",
- utils.CreateSha256Hash(rt),
- datatype.DateTime(time.Now()),
- userID,
- input.ClientID,
- ).
- First(&storedRefreshToken).
- Error
- if errors.Is(err, gorm.ErrRecordNotFound) {
- return CreatedTokens{}, &common.OidcInvalidRefreshTokenError{}
- } else if err != nil {
- return CreatedTokens{}, err
- }
-
- // Verify that the refresh token belongs to the provided client
- if storedRefreshToken.ClientID != input.ClientID {
- return CreatedTokens{}, &common.OidcInvalidRefreshTokenError{}
- }
-
- if storedRefreshToken.User.Disabled {
- return CreatedTokens{}, &common.OidcInvalidRefreshTokenError{}
- }
-
- var authorizedClient model.UserAuthorizedOidcClient
- err = tx.
- WithContext(ctx).
- Where("user_id = ? AND client_id = ?", storedRefreshToken.UserID, input.ClientID).
- First(&authorizedClient).
- Error
- if errors.Is(err, gorm.ErrRecordNotFound) {
- err = tx.WithContext(ctx).Delete(&storedRefreshToken).Error
- if err != nil {
- return CreatedTokens{}, err
- }
-
- err = tx.Commit().Error
- if err != nil {
- return CreatedTokens{}, err
- }
-
- return CreatedTokens{}, &common.OidcInvalidRefreshTokenError{}
- } else if err != nil {
- return CreatedTokens{}, err
- }
-
- if client.IsGroupRestricted {
- err = tx.WithContext(ctx).Model(client).Association("AllowedUserGroups").Find(&client.AllowedUserGroups)
- if err != nil {
- return CreatedTokens{}, err
- }
- }
-
- if !IsUserGroupAllowedToAuthorize(storedRefreshToken.User, *client) {
- return CreatedTokens{}, &common.OidcAccessDeniedError{}
- }
-
- // Generate a new access token
- authenticationMethods := storedRefreshToken.AuthenticationMethod
- accessToken, err := s.jwtService.GenerateOAuthAccessToken(storedRefreshToken.User, input.ClientID, authenticationMethods)
- if err != nil {
- return CreatedTokens{}, err
- }
-
- // Load the profile, which we need for the ID token
- userClaims, err := s.getUserClaims(ctx, &storedRefreshToken.User, storedRefreshToken.Scopes(), tx)
- if err != nil {
- return CreatedTokens{}, err
- }
-
- // Generate a new ID token
- // There's no nonce here because we don't have one with the refresh token, but that's not required
- idToken, idTokenJti, err := s.jwtService.GenerateIDToken(userClaims, input.ClientID, "", authenticationMethods)
- if err != nil {
- return CreatedTokens{}, err
- }
-
- // Generate a new refresh token and invalidate the old one
- newRefreshToken, err := s.createRefreshToken(ctx, input.ClientID, storedRefreshToken.UserID, storedRefreshToken.Scope, authenticationMethods, idTokenJti, tx)
- if err != nil {
- return CreatedTokens{}, err
- }
-
- // Delete the used refresh token
- err = tx.
- WithContext(ctx).
- Delete(&storedRefreshToken).
- Error
- if err != nil {
- return CreatedTokens{}, err
- }
-
- err = tx.Commit().Error
- if err != nil {
- return CreatedTokens{}, err
- }
-
- return CreatedTokens{
- AccessToken: accessToken,
- RefreshToken: newRefreshToken,
- IdToken: idToken,
- ExpiresIn: AccessTokenDuration,
- }, nil
-}
-
-func (s *OidcService) IntrospectToken(ctx context.Context, creds ClientAuthCredentials, tokenString string) (introspectDto dto.OidcIntrospectionResponseDto, err error) {
- client, err := s.verifyClientCredentialsInternal(ctx, s.db, creds, false)
- if err != nil {
- return introspectDto, err
- }
-
- // Get the type of the token and the client ID
- tokenType, token, err := s.jwtService.GetTokenType(tokenString)
- if err != nil {
- // We just treat the token as invalid
- introspectDto.Active = false
- return introspectDto, nil //nolint:nilerr
- }
-
- // Get the audience from the token
- tokenAudiences, _ := token.Audience()
- if len(tokenAudiences) != 1 || tokenAudiences[0] == "" {
- introspectDto.Active = false
- return introspectDto, nil
- }
-
- // Audience must match the client ID
- if client.ID != tokenAudiences[0] {
- return introspectDto, &common.OidcMissingClientCredentialsError{}
- }
-
- // Introspect the token
- switch tokenType {
- case OAuthAccessTokenJWTType:
- return s.introspectAccessToken(client.ID, tokenString)
- case OAuthRefreshTokenJWTType:
- return s.introspectRefreshToken(ctx, client.ID, tokenString)
- default:
- // We just treat the token as invalid
- introspectDto.Active = false
- return introspectDto, nil
- }
-}
-
-func (s *OidcService) introspectAccessToken(clientID string, tokenString string) (introspectDto dto.OidcIntrospectionResponseDto, err error) {
- token, err := s.jwtService.VerifyOAuthAccessToken(tokenString)
- if err != nil {
- // Every failure we get means the token is invalid. Nothing more to do with the error.
- introspectDto.Active = false
- return introspectDto, nil //nolint:nilerr
- }
-
- // The ID of the client that made the request must match the client ID in the token
- audience, ok := token.Audience()
- if !ok || len(audience) != 1 || audience[0] == "" {
- introspectDto.Active = false
- return introspectDto, nil
- }
- if audience[0] != clientID {
- return introspectDto, &common.OidcMissingClientCredentialsError{}
- }
-
- introspectDto.Active = true
- introspectDto.TokenType = "access_token"
- introspectDto.Audience = audience
- if token.Has("scope") {
- var (
- asString string
- asStrings []string
- )
- if err := token.Get("scope", &asString); err == nil {
- introspectDto.Scope = asString
- } else if err := token.Get("scope", &asStrings); err == nil {
- introspectDto.Scope = strings.Join(asStrings, " ")
- }
- }
- if expiration, ok := token.Expiration(); ok {
- introspectDto.Expiration = expiration.Unix()
- }
- if issuedAt, ok := token.IssuedAt(); ok {
- introspectDto.IssuedAt = issuedAt.Unix()
- }
- if notBefore, ok := token.NotBefore(); ok {
- introspectDto.NotBefore = notBefore.Unix()
- }
- if subject, ok := token.Subject(); ok {
- introspectDto.Subject = subject
- }
- if issuer, ok := token.Issuer(); ok {
- introspectDto.Issuer = issuer
- }
- if identifier, ok := token.JwtID(); ok {
- introspectDto.Identifier = identifier
- }
-
- return introspectDto, nil
-}
-
-func (s *OidcService) introspectRefreshToken(ctx context.Context, clientID string, refreshToken string) (introspectDto dto.OidcIntrospectionResponseDto, err error) {
- // Validate the signed refresh token and extract the actual token (which is a claim in the signed one)
- tokenUserID, tokenClientID, tokenRT, err := s.jwtService.VerifyOAuthRefreshToken(refreshToken)
- if err != nil {
- return introspectDto, fmt.Errorf("invalid refresh token: %w", err)
- }
-
- // The ID of the client that made the call must match the client ID in the token
- if tokenClientID != clientID {
- return introspectDto, errors.New("invalid refresh token: client ID does not match")
- }
-
- var storedRefreshToken model.OidcRefreshToken
- err = s.db.
- WithContext(ctx).
- Preload("User").
- Where(
- "token = ? AND expires_at > ? AND user_id = ? AND client_id = ?",
- utils.CreateSha256Hash(tokenRT),
- datatype.DateTime(time.Now()),
- tokenUserID,
- tokenClientID,
- ).
- First(&storedRefreshToken).
- Error
- if errors.Is(err, gorm.ErrRecordNotFound) {
- introspectDto.Active = false
- return introspectDto, nil
- } else if err != nil {
- return introspectDto, err
- }
-
- introspectDto.Active = true
- introspectDto.TokenType = "refresh_token"
- return introspectDto, nil
-}
-
func (s *OidcService) GetClient(ctx context.Context, clientID string) (model.OidcClient, error) {
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
- }
-
- return resolveConfiguredCallbackURL(&client, inputCallbackURL)
-}
-
-// ResolveAuthorizeCallbackURL resolves the callback URL for a browser authorization
-// request without authorizing the user or consuming PAR state.
-func (s *OidcService) ResolveAuthorizeCallbackURL(ctx context.Context, clientID, inputCallbackURL, requestURI string) (string, error) {
- client, err := s.GetClient(ctx, clientID)
- if err != nil {
- return "", err
- }
-
- if client.RequiresPushedAuthorizationRequests && requestURI == "" {
- return "", &common.OidcPARRequiredError{}
- }
-
- if requestURI != "" {
- par, err := s.GetPushedAuthorizationRequest(ctx, clientID, requestURI)
- if err != nil {
- return "", err
- }
- inputCallbackURL = par.Parameters.RedirectURI
- }
-
- return resolveConfiguredCallbackURL(&client, inputCallbackURL)
-}
-
-func resolveConfiguredCallbackURL(client *model.OidcClient, inputCallbackURL string) (string, error) {
- if inputCallbackURL == "" {
- if len(client.CallbackURLs) > 0 {
- return client.CallbackURLs[0], nil
- }
- return "", &common.OidcMissingCallbackURLError{}
- }
-
- if 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.
@@ -1011,8 +219,7 @@ 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.RequiresPushedAuthorizationRequests = input.RequiresPushedAuthorizationRequests
client.LaunchURL = input.LaunchURL
client.IsGroupRestricted = input.IsGroupRestricted
@@ -1020,10 +227,11 @@ func updateOIDCClientModelFromDto(client *model.OidcClient, input *dto.OidcClien
client.Credentials.FederatedIdentities = make([]model.OidcClientFederatedIdentity, len(input.Credentials.FederatedIdentities))
for i, fi := range input.Credentials.FederatedIdentities {
client.Credentials.FederatedIdentities[i] = model.OidcClientFederatedIdentity{
- Issuer: fi.Issuer,
- Audience: fi.Audience,
- Subject: fi.Subject,
- JWKS: fi.JWKS,
+ Issuer: fi.Issuer,
+ Audience: fi.Audience,
+ Subject: fi.Subject,
+ JWKS: fi.JWKS,
+ ReplayProtection: fi.ReplayProtection,
}
}
@@ -1288,476 +496,6 @@ func (s *OidcService) UpdateAllowedUserGroups(ctx context.Context, id string, in
return client, nil
}
-// ValidateEndSession returns the logout callback URL for the client if all the validations pass
-func (s *OidcService) ValidateEndSession(ctx context.Context, input dto.OidcLogoutDto, userID string) (callbackURL string, err error) {
- // If no ID token hint is provided, return an error
- if input.IdTokenHint == "" {
- return "", &common.TokenInvalidError{}
- }
-
- // If the ID token hint is provided, verify the ID token
- // Here we also accept expired ID tokens, which are fine per spec
- token, err := s.jwtService.VerifyIdToken(input.IdTokenHint, true)
- if err != nil {
- return "", &common.TokenInvalidError{}
- }
-
- // If the client ID is provided check if the client ID in the ID token matches the client ID in the request
- clientID, ok := token.Audience()
- if !ok || len(clientID) == 0 {
- return "", &common.TokenInvalidError{}
- }
- if input.ClientId != "" && clientID[0] != input.ClientId {
- return "", &common.OidcClientIdNotMatchingError{}
- }
-
- subject, ok := token.Subject()
- if !ok || subject != userID {
- return "", &common.TokenInvalidError{}
- }
-
- idTokenJti, ok := token.JwtID()
- if !ok {
- return "", &common.TokenInvalidError{}
- }
-
- tx := s.db.Begin()
- defer tx.Rollback()
-
- // Check if the user has authorized the client before
- var userAuthorizedOIDCClient model.UserAuthorizedOidcClient
- err = tx.
- WithContext(ctx).
- Preload("Client").
- First(&userAuthorizedOIDCClient, "client_id = ? AND user_id = ?", clientID[0], userID).
- Error
- if err != nil {
- return "", &common.OidcMissingAuthorizationError{}
- }
-
- // If the client has a callback URL, validate it
- if len(userAuthorizedOIDCClient.Client.LogoutCallbackURLs) > 0 {
- callbackURL, err = s.getLogoutCallbackURL(&userAuthorizedOIDCClient.Client, input.PostLogoutRedirectUri)
- if err != nil {
- return "", err
- }
- }
-
- err = tx.
- WithContext(ctx).
- Where("user_id = ? AND client_id = ? AND id_token_jti = ?", userID, clientID[0], idTokenJti).
- Delete(&model.OidcRefreshToken{}).
- Error
- if err != nil {
- return "", err
- }
-
- if err := tx.Commit().Error; err != nil {
- return "", fmt.Errorf("failed to commit transaction: %w", err)
- }
-
- return callbackURL, nil
-}
-
-func (s *OidcService) createAuthorizationCode(ctx context.Context, clientID string, userID string, scope string, authenticationMethod string, nonce string, codeChallenge string, codeChallengeMethod string, tx *gorm.DB) (string, error) {
- randomString, err := utils.GenerateRandomAlphanumericString(32)
- if err != nil {
- return "", err
- }
-
- codeChallengeMethodSha256, err := codeChallengeMethodIsSha256(codeChallengeMethod)
- if err != nil {
- return "", err
- }
-
- oidcAuthorizationCode := model.OidcAuthorizationCode{
- ExpiresAt: datatype.DateTime(time.Now().Add(15 * time.Minute)),
- Code: randomString,
- ClientID: clientID,
- UserID: userID,
- Scope: scope,
- AuthenticationMethod: authenticationMethod,
- Nonce: nonce,
- CodeChallenge: &codeChallenge,
- CodeChallengeMethodSha256: &codeChallengeMethodSha256,
- }
-
- err = tx.
- WithContext(ctx).
- Create(&oidcAuthorizationCode).
- Error
- if err != nil {
- return "", err
- }
-
- return randomString, nil
-}
-
-func codeChallengeMethodIsSha256(codeChallengeMethod string) (bool, error) {
- switch strings.ToUpper(codeChallengeMethod) {
- case "":
- return false, nil
- case "PLAIN":
- return false, nil
- case "S256":
- return true, nil
- default:
- return false, common.NewOidcInvalidRequestError("code challenge method not supported")
- }
-}
-
-// 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
- }
-
- if !codeChallengeMethodSha256 {
- return subtle.ConstantTimeCompare([]byte(codeVerifier), []byte(codeChallenge)) == 1
- }
-
- // Base64 URL decode the challenge
- // If it's not valid base64url, fail the operation
- codeChallengeBytes, err := base64.RawURLEncoding.DecodeString(codeChallenge)
- if err != nil {
- return false
- }
-
- // Compute SHA-256 hash of the codeVerifier
- h := sha256.New()
- h.Write([]byte(codeVerifier))
- codeVerifierHash := h.Sum(nil)
-
- return subtle.ConstantTimeCompare(codeVerifierHash, codeChallengeBytes) == 1
-}
-
-func (s *OidcService) getCallbackURL(client *model.OidcClient, inputCallbackURL string, tx *gorm.DB, ctx context.Context) (callbackURL string, err error) {
- // If no input callback URL provided, use the first configured URL
- if inputCallbackURL == "" {
- if len(client.CallbackURLs) > 0 {
- return client.CallbackURLs[0], nil
- }
- // If no URLs are configured and no input URL, this is an error
- return "", &common.OidcMissingCallbackURLError{}
- }
-
- // If URLs are already configured, validate against them
- if len(client.CallbackURLs) > 0 {
- matched, err := utils.GetCallbackURLFromList(client.CallbackURLs, inputCallbackURL)
- if err != nil {
- return "", err
- } else if matched == "" {
- return "", &common.OidcInvalidCallbackURLError{}
- }
-
- return matched, nil
- }
-
- // If no URLs are configured, trust and store the first URL (TOFU)
- err = s.addCallbackURLToClient(ctx, client, inputCallbackURL, tx)
- if err != nil {
- return "", err
- }
- return inputCallbackURL, nil
-}
-
-func (s *OidcService) getLogoutCallbackURL(client *model.OidcClient, inputLogoutCallbackURL string) (callbackURL string, err error) {
- if inputLogoutCallbackURL == "" {
- return client.LogoutCallbackURLs[0], nil
- }
-
- matched, err := utils.GetCallbackURLFromList(client.LogoutCallbackURLs, inputLogoutCallbackURL)
- if err != nil {
- return "", err
- } else if matched == "" {
- return "", &common.OidcInvalidCallbackURLError{}
- }
-
- return matched, nil
-}
-
-func (s *OidcService) addCallbackURLToClient(ctx context.Context, client *model.OidcClient, callbackURL string, tx *gorm.DB) error {
- // Add the new callback URL to the existing list
- client.CallbackURLs = append(client.CallbackURLs, callbackURL)
-
- err := tx.WithContext(ctx).Save(client).Error
- if err != nil {
- return err
- }
-
- return nil
-}
-
-func (s *OidcService) CreateDeviceAuthorization(ctx context.Context, input dto.OidcDeviceAuthorizationRequestDto) (*dto.OidcDeviceAuthorizationResponseDto, error) {
- client, err := s.verifyClientCredentialsInternal(ctx, s.db, ClientAuthCredentials{
- ClientID: input.ClientID,
- ClientSecret: input.ClientSecret,
- ClientAssertionType: input.ClientAssertionType,
- ClientAssertion: input.ClientAssertion,
- }, true)
- if err != nil {
- return nil, err
- }
-
- // Generate codes
- deviceCode, err := utils.GenerateRandomAlphanumericString(32)
- if err != nil {
- return nil, err
- }
- userCode, err := utils.GenerateRandomAlphanumericString(8)
- if err != nil {
- return nil, err
- }
-
- // Create device authorization
- deviceAuth := &model.OidcDeviceCode{
- DeviceCode: deviceCode,
- UserCode: userCode,
- Scope: input.Scope,
- ExpiresAt: datatype.DateTime(time.Now().Add(DeviceCodeDuration)),
- IsAuthorized: false,
- ClientID: client.ID,
- Nonce: input.Nonce,
- }
-
- if err := s.db.Create(deviceAuth).Error; err != nil {
- return nil, err
- }
-
- return &dto.OidcDeviceAuthorizationResponseDto{
- DeviceCode: deviceCode,
- UserCode: userCode,
- VerificationURI: common.EnvConfig.AppURL + "/device",
- VerificationURIComplete: common.EnvConfig.AppURL + "/device?code=" + userCode,
- ExpiresIn: int(DeviceCodeDuration.Seconds()),
- Interval: 5,
- }, nil
-}
-
-func (s *OidcService) VerifyDeviceCode(ctx context.Context, userCode string, userID string, authenticationMethod string, ipAddress string, userAgent string) error {
- tx := s.db.Begin()
- defer func() {
- tx.Rollback()
- }()
-
- var deviceAuth model.OidcDeviceCode
- err := tx.
- WithContext(ctx).
- Preload("Client.AllowedUserGroups").
- First(&deviceAuth, "user_code = ?", userCode).
- Error
- if err != nil {
- return fmt.Errorf("error finding device code: %w", err)
- }
-
- if time.Now().After(deviceAuth.ExpiresAt.ToTime()) {
- return &common.OidcDeviceCodeExpiredError{}
- }
-
- // Check if the user group is allowed to authorize the client
- var user model.User
- err = tx.
- WithContext(ctx).
- Preload("UserGroups").
- First(&user, "id = ?", userID).
- Error
- if err != nil {
- return fmt.Errorf("error finding user groups: %w", err)
- }
-
- if !IsUserGroupAllowedToAuthorize(user, deviceAuth.Client) {
- return &common.OidcAccessDeniedError{}
- }
-
- err = tx.
- WithContext(ctx).
- Preload("Client").
- First(&deviceAuth, "user_code = ?", userCode).
- Error
- if err != nil {
- return fmt.Errorf("error finding device code: %w", err)
- }
-
- if time.Now().After(deviceAuth.ExpiresAt.ToTime()) {
- return &common.OidcDeviceCodeExpiredError{}
- }
-
- deviceAuth.UserID = &userID
- deviceAuth.AuthenticationMethod = authenticationMethod
- deviceAuth.IsAuthorized = true
-
- err = tx.
- WithContext(ctx).
- Save(&deviceAuth).
- Error
- if err != nil {
- return fmt.Errorf("error saving device auth: %w", err)
- }
-
- hasAlreadyAuthorizedClient, err := s.createAuthorizedClientInternal(ctx, userID, deviceAuth.ClientID, deviceAuth.Scope, tx)
- if err != nil {
- return err
- }
-
- auditLogData := model.AuditLogData{"clientName": deviceAuth.Client.Name}
- if hasAlreadyAuthorizedClient {
- s.auditLogService.Create(ctx, model.AuditLogEventDeviceCodeAuthorization, ipAddress, userAgent, userID, auditLogData, tx)
- } else {
- s.auditLogService.Create(ctx, model.AuditLogEventNewDeviceCodeAuthorization, ipAddress, userAgent, userID, auditLogData, tx)
- }
-
- return tx.Commit().Error
-}
-
-func (s *OidcService) GetDeviceCodeInfo(ctx context.Context, userCode string, userID string) (*dto.DeviceCodeInfoDto, error) {
- var deviceAuth model.OidcDeviceCode
- err := s.db.
- WithContext(ctx).
- Preload("Client").
- First(&deviceAuth, "user_code = ?", userCode).
- Error
- if err != nil {
- if errors.Is(err, gorm.ErrRecordNotFound) {
- return nil, &common.OidcInvalidDeviceCodeError{}
- }
- return nil, err
- }
-
- if time.Now().After(deviceAuth.ExpiresAt.ToTime()) {
- return nil, &common.OidcDeviceCodeExpiredError{}
- }
-
- // Check if the user has already authorized this client with this scope
- hasAuthorizedClient := false
- if userID != "" {
- var err error
- hasAuthorizedClient, err = s.HasAuthorizedClient(ctx, deviceAuth.ClientID, userID, deviceAuth.Scope)
- if err != nil {
- return nil, err
- }
- }
-
- return &dto.DeviceCodeInfoDto{
- Client: dto.OidcClientMetaDataDto{
- ID: deviceAuth.Client.ID,
- Name: deviceAuth.Client.Name,
- HasLogo: deviceAuth.Client.HasLogo(),
- HasDarkLogo: deviceAuth.Client.HasDarkLogo(),
- },
- Scope: deviceAuth.Scope,
- AuthorizationRequired: !hasAuthorizedClient,
- }, nil
-}
-
func (s *OidcService) GetAllowedGroupsCountOfClient(ctx context.Context, id string) (int64, error) {
// We only perform select queries here, so we can rollback in all cases
tx := s.db.Begin()
@@ -1809,12 +547,7 @@ func (s *OidcService) RevokeAuthorizedClient(ctx context.Context, userID string,
return err
}
- err = tx.
- WithContext(ctx).
- Where("user_id = ? AND client_id = ?", userID, clientID).
- Delete(&model.OidcRefreshToken{}).
- Error
- if err != nil {
+ if err = oidc.RevokeUserClientSessions(ctx, tx, userID, clientID); err != nil {
return err
}
@@ -1905,256 +638,14 @@ func (s *OidcService) ListAccessibleOidcClients(ctx context.Context, userID stri
return dtos, response, err
}
-func (s *OidcService) createRefreshToken(ctx context.Context, clientID string, userID string, scope string, authenticationMethod string, idTokenJti string, tx *gorm.DB) (string, error) {
- refreshToken, err := utils.GenerateRandomAlphanumericString(40)
- if err != nil {
- return "", err
- }
-
- // Compute the hash of the refresh token to store in the DB
- // Refresh tokens are pretty long already, so a "simple" SHA-256 hash is enough
- refreshTokenHash := utils.CreateSha256Hash(refreshToken)
-
- m := model.OidcRefreshToken{
- ExpiresAt: datatype.DateTime(time.Now().Add(RefreshTokenDuration)),
- Token: refreshTokenHash,
- IdTokenJti: &idTokenJti,
- ClientID: clientID,
- UserID: userID,
- Scope: scope,
- AuthenticationMethod: authenticationMethod,
- }
-
- err = tx.
- WithContext(ctx).
- Create(&m).
- Error
- if err != nil {
- return "", err
- }
-
- // Sign the refresh token
- signed, err := s.jwtService.GenerateOAuthRefreshToken(userID, clientID, refreshToken)
- if err != nil {
- return "", fmt.Errorf("failed to sign refresh token: %w", err)
- }
-
- return signed, nil
-}
-
-func (s *OidcService) createAuthorizedClientInternal(ctx context.Context, userID string, clientID string, scope string, tx *gorm.DB) (hasAlreadyAuthorizedClient bool, err error) {
-
- // Check if the user has already authorized the client with the given scope
- hasAlreadyAuthorizedClient, err = s.hasAuthorizedClientInternal(ctx, clientID, userID, scope, tx)
- if err != nil {
- return false, err
- }
-
- if hasAlreadyAuthorizedClient {
- err = tx.
- WithContext(ctx).
- Model(&model.UserAuthorizedOidcClient{}).
- Where("user_id = ? AND client_id = ?", userID, clientID).
- Update("last_used_at", datatype.DateTime(time.Now())).
- Error
-
- if err != nil {
- return hasAlreadyAuthorizedClient, err
- }
-
- return hasAlreadyAuthorizedClient, nil
- }
-
- userAuthorizedClient := model.UserAuthorizedOidcClient{
- UserID: userID,
- ClientID: clientID,
- Scope: scope,
- LastUsedAt: datatype.DateTime(time.Now()),
- }
-
- err = tx.WithContext(ctx).
- Clauses(clause.OnConflict{
- Columns: []clause.Column{{Name: "user_id"}, {Name: "client_id"}},
- DoUpdates: clause.AssignmentColumns([]string{"scope"}),
- }).
- Create(&userAuthorizedClient).
- Error
-
- return hasAlreadyAuthorizedClient, err
-}
-
-type ClientAuthCredentials struct {
- ClientID string
- ClientSecret string
- ClientAssertion string
- ClientAssertionType string
-}
-
-func clientAuthCredentialsFromCreateTokensDto(d *dto.OidcCreateTokensDto) ClientAuthCredentials {
- return ClientAuthCredentials{
- ClientID: d.ClientID,
- ClientSecret: d.ClientSecret,
- ClientAssertion: d.ClientAssertion,
- ClientAssertionType: d.ClientAssertionType,
- }
-}
-
-func (s *OidcService) verifyClientCredentialsInternal(ctx context.Context, tx *gorm.DB, input ClientAuthCredentials, allowPublicClientsWithoutAuth bool) (client *model.OidcClient, err error) {
- if input.ClientID == "" {
- return nil, &common.OidcMissingClientCredentialsError{}
- }
-
- // Load the OIDC client's configuration
- err = tx.
- WithContext(ctx).
- First(&client, "id = ?", input.ClientID).
- Error
- if errors.Is(err, gorm.ErrRecordNotFound) {
- slog.WarnContext(ctx, "Client not found", slog.String("client", input.ClientID))
- return nil, &common.OidcClientNotFoundError{}
- } else if err != nil {
- return nil, err
- }
-
- // Validate credentials based on the authentication method
- switch {
- // First, if we have a client secret, we validate it unless client is marked as public
- case input.ClientSecret != "" && !client.IsPublic:
- err = bcrypt.CompareHashAndPassword([]byte(client.Secret), []byte(input.ClientSecret))
- if err != nil {
- return nil, &common.OidcClientSecretInvalidError{}
- }
- return client, nil
-
- // Next, check if we want to use client assertions from federated identities
- case input.ClientAssertionType == ClientAssertionTypeJWTBearer && input.ClientAssertion != "":
- err = s.verifyClientAssertionFromFederatedIdentities(ctx, client, input)
- if err != nil {
- slog.WarnContext(ctx, "Invalid assertion for client", slog.String("client", client.ID), slog.Any("error", err))
- return nil, &common.OidcClientAssertionInvalidError{}
- }
- return client, nil
-
- // There's no credentials
- // This is allowed only if the client is public
- case client.IsPublic && allowPublicClientsWithoutAuth:
- return client, nil
-
- // If we're here, we have no credentials AND the client is not public, so credentials are required
- default:
- return nil, &common.OidcMissingClientCredentialsError{}
- }
-}
-
-func (s *OidcService) jwkSetForURL(ctx context.Context, url string) (set jwk.Set, err error) {
- // Check if we have already registered the URL
- if !s.jwkCache.IsRegistered(ctx, url) {
- // We set a timeout because otherwise Register will keep trying in case of errors
- registerCtx, registerCancel := context.WithTimeout(ctx, 15*time.Second)
- defer registerCancel()
-
- registerOptions := []jwk.RegisterOption{
- jwk.WithMaxInterval(24 * time.Hour),
- jwk.WithMinInterval(15 * time.Minute),
- jwk.WithWaitReady(true),
- }
- if 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)
- }
- }
-
- jwks, err := s.jwkCache.CachedSet(url)
- if err != nil {
- return nil, fmt.Errorf("failed to get cached JWK set: %w", err)
- }
-
- return jwks, nil
-}
-
-func (s *OidcService) verifyClientAssertionFromFederatedIdentities(ctx context.Context, client *model.OidcClient, input ClientAuthCredentials) error {
- // First, parse the assertion JWT, without validating it, to check the issuer
- assertion := []byte(input.ClientAssertion)
- insecureToken, err := jwt.ParseInsecure(assertion)
- if err != nil {
- return fmt.Errorf("failed to parse client assertion JWT: %w", err)
- }
-
- issuer, _ := insecureToken.Issuer()
- if issuer == "" {
- return errors.New("client assertion does not contain an issuer claim")
- }
-
- // Ensure that this client is federated with the one that issued the token
- ocfi, ok := client.Credentials.FederatedIdentityForIssuer(issuer)
- if !ok {
- return fmt.Errorf("client assertion is not from an allowed issuer: %s", issuer)
- }
-
- // Get the JWK set for the issuer
- jwksURL := ocfi.JWKS
- if jwksURL == "" {
- // Default URL is from the issuer
- if strings.HasSuffix(issuer, "/") {
- jwksURL = issuer + ".well-known/jwks.json"
- } else {
- jwksURL = issuer + "/.well-known/jwks.json"
- }
- }
- jwks, err := s.jwkSetForURL(ctx, jwksURL)
- if err != nil {
- return fmt.Errorf("failed to get JWK set for issuer '%s': %w", issuer, err)
- }
-
- // Set default audience and subject if missing
- audience := ocfi.Audience
- if audience == "" {
- // Default to the Pocket ID's URL
- audience = common.EnvConfig.AppURL
- }
- subject := ocfi.Subject
- if subject == "" {
- // Default to the client ID, per RFC 7523
- subject = client.ID
- }
-
- // Now re-parse the token with proper validation
- // (Note: we don't use jwt.WithIssuer() because that would be redundant)
- _, err = jwt.Parse(assertion,
- jwt.WithValidate(true),
-
- jwt.WithAcceptableSkew(clockSkew),
- jwt.WithKeySet(jwks, jws.WithInferAlgorithmFromKey(true), jws.WithUseDefault(true)),
- jwt.WithAudience(audience),
- jwt.WithSubject(subject),
- )
- if err != nil {
- return fmt.Errorf("client assertion could not be verified: %w", err)
- }
-
- // If we're here, the assertion is valid
- return nil
-}
-
func (s *OidcService) GetClientPreview(ctx context.Context, clientID string, userID string, scopes []string, authenticationMethod string) (*dto.OidcClientPreviewDto, error) {
- tx := s.db.Begin()
- defer func() {
- tx.Rollback()
- }()
-
- client, err := s.getClientInternal(ctx, clientID, tx, false)
+ client, err := s.getClientInternal(ctx, clientID, s.db, false)
if err != nil {
return nil, err
}
var user model.User
- err = tx.
+ err = s.db.
WithContext(ctx).
Preload("UserGroups").
First(&user, "id = ?", userID).
@@ -2163,136 +654,21 @@ func (s *OidcService) GetClientPreview(ctx context.Context, clientID string, use
return nil, err
}
- if !IsUserGroupAllowedToAuthorize(user, client) {
+ if !oidc.IsUserGroupAllowedToAuthorize(user, client) {
return nil, &common.OidcAccessDeniedError{}
}
- userClaims, err := s.getUserClaims(ctx, &user, scopes, tx)
+ preview, err := s.previewBuilder.BuildClientPreview(ctx, client, userID, scopes, authenticationMethod)
if err != nil {
return nil, err
}
-
- // Commit the transaction before signing tokens to avoid locking the database for longer
- err = tx.Commit().Error
- if err != nil {
- return nil, err
- }
-
- idToken, _, err := s.jwtService.BuildIDToken(userClaims, clientID, "", authenticationMethod)
- if err != nil {
- return nil, err
- }
-
- accessToken, err := s.jwtService.BuildOAuthAccessToken(user, clientID, authenticationMethod)
- if err != nil {
- return nil, err
- }
-
- idTokenPayload, err := utils.GetClaimsFromToken(idToken)
- if err != nil {
- return nil, err
- }
-
- accessTokenPayload, err := utils.GetClaimsFromToken(accessToken)
- if err != nil {
- return nil, err
- }
-
return &dto.OidcClientPreviewDto{
- IdToken: idTokenPayload,
- AccessToken: accessTokenPayload,
- UserInfo: userClaims,
+ IdToken: preview.IDToken,
+ AccessToken: preview.AccessToken,
+ UserInfo: preview.UserInfo,
}, nil
}
-func (s *OidcService) GetUserClaimsForClient(ctx context.Context, userID string, clientID string) (map[string]any, error) {
- return s.getUserClaimsForClientInternal(ctx, userID, clientID, s.db)
-}
-
-func (s *OidcService) getUserClaimsForClientInternal(ctx context.Context, userID string, clientID string, tx *gorm.DB) (map[string]any, error) {
- var authorizedOidcClient model.UserAuthorizedOidcClient
- err := tx.
- WithContext(ctx).
- Preload("User.UserGroups").
- First(&authorizedOidcClient, "user_id = ? AND client_id = ?", userID, clientID).
- Error
- if err != nil {
- return nil, err
- }
-
- return s.getUserClaims(ctx, &authorizedOidcClient.User, authorizedOidcClient.Scopes(), tx)
-}
-
-func (s *OidcService) getUserClaims(ctx context.Context, user *model.User, scopes []string, tx *gorm.DB) (map[string]any, error) {
- claims := make(map[string]any, 10)
-
- claims["sub"] = user.ID
- if slices.Contains(scopes, "email") {
- claims["email"] = user.Email
- claims["email_verified"] = user.EmailVerified
- }
-
- if slices.Contains(scopes, "groups") {
- userGroups := make([]string, len(user.UserGroups))
- for i, group := range user.UserGroups {
- userGroups[i] = group.Name
- }
- claims["groups"] = userGroups
- }
-
- if slices.Contains(scopes, "profile") {
- // Add custom claims
- customClaims, err := s.customClaimService.GetCustomClaimsForUserWithUserGroups(ctx, user.ID, tx)
- if err != nil {
- return nil, err
- }
-
- for _, customClaim := range customClaims {
- // The value of the custom claim can be a JSON object or a string
- var jsonValue any
- err := json.Unmarshal([]byte(customClaim.Value), &jsonValue)
- if err == nil {
- // It's JSON, so we store it as an object
- claims[customClaim.Key] = jsonValue
- } else {
- // Marshaling failed, so we store it as a string
- claims[customClaim.Key] = customClaim.Value
- }
- }
-
- // Add profile claims
- claims["given_name"] = user.FirstName
- claims["family_name"] = user.LastName
- claims["name"] = user.FullName()
- claims["display_name"] = user.DisplayName
-
- claims["preferred_username"] = user.Username
- claims["picture"] = common.EnvConfig.AppURL + "/api/users/" + user.ID + "/profile-picture.png"
- }
-
- if slices.Contains(scopes, "email") {
- claims["email"] = user.Email
- }
-
- return claims, nil
-}
-
-func (s *OidcService) IsClientAccessibleToUser(ctx context.Context, clientID string, userID string) (bool, error) {
- var user model.User
- err := s.db.WithContext(ctx).Preload("UserGroups").First(&user, "id = ?", userID).Error
- if err != nil {
- return false, err
- }
-
- var client model.OidcClient
- err = s.db.WithContext(ctx).Preload("AllowedUserGroups").First(&client, "id = ?", clientID).Error
- if err != nil {
- return false, err
- }
-
- return IsUserGroupAllowedToAuthorize(user, client), nil
-}
-
var errLogoTooLarge = errors.New("logo is too large")
func httpClientWithCheckRedirect(source *http.Client, checkRedirect func(req *http.Request, via []*http.Request) error) *http.Client {
@@ -2471,11 +847,3 @@ func (s *OidcService) GetClientScimServiceProvider(ctx context.Context, clientID
return provider, nil
}
-
-// parsePromptParameter parses the OIDC prompt parameter which is a space-delimited list of values
-func parsePromptParameter(prompt string) []string {
- if prompt == "" {
- return []string{}
- }
- return strings.Fields(prompt)
-}
diff --git a/backend/internal/service/oidc_service_test.go b/backend/internal/service/oidc_service_test.go
index 7323759f..ab6470c5 100644
--- a/backend/internal/service/oidc_service_test.go
+++ b/backend/internal/service/oidc_service_test.go
@@ -1,823 +1,21 @@
package service
import (
- "context"
- "crypto/ecdsa"
- "crypto/elliptic"
- "crypto/rand"
- "crypto/sha256"
- "encoding/base64"
- "encoding/json"
"io"
"net/http"
- "slices"
"strconv"
"strings"
"testing"
- "time"
- "github.com/lestrrat-go/jwx/v3/jwa"
- "github.com/lestrrat-go/jwx/v3/jwk"
- "github.com/lestrrat-go/jwx/v3/jwt"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
- "gorm.io/gorm"
"github.com/pocket-id/pocket-id/backend/internal/common"
- "github.com/pocket-id/pocket-id/backend/internal/dto"
"github.com/pocket-id/pocket-id/backend/internal/model"
- datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
"github.com/pocket-id/pocket-id/backend/internal/storage"
testutils "github.com/pocket-id/pocket-id/backend/internal/utils/testing"
)
-// generateTestECDSAKey creates an ECDSA key for testing
-func generateTestECDSAKey(t *testing.T) (jwk.Key, []byte) {
- t.Helper()
-
- privateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
- require.NoError(t, err)
-
- privateJwk, err := jwk.Import(privateKey)
- require.NoError(t, err)
-
- err = privateJwk.Set(jwk.KeyIDKey, "test-key-1")
- require.NoError(t, err)
- err = privateJwk.Set(jwk.AlgorithmKey, "ES256")
- require.NoError(t, err)
- err = privateJwk.Set("use", "sig")
- require.NoError(t, err)
-
- publicJwk, err := jwk.PublicKeyOf(privateJwk)
- require.NoError(t, err)
-
- // Create a JWK Set with the public key
- jwkSet := jwk.NewSet()
- err = jwkSet.AddKey(publicJwk)
- require.NoError(t, err)
- jwkSetJSON, err := json.Marshal(jwkSet)
- require.NoError(t, err)
-
- return privateJwk, jwkSetJSON
-}
-
-func TestOidcService_jwkSetForURL(t *testing.T) {
- // Generate a test key for JWKS
- _, jwkSetJSON1 := generateTestECDSAKey(t)
- _, jwkSetJSON2 := generateTestECDSAKey(t)
-
- // Create a mock HTTP client with responses for different URLs
- const (
- url1 = "https://example.com/.well-known/jwks.json"
- url2 = "https://other-issuer.com/jwks"
- )
- mockResponses := map[string]*http.Response{
- //nolint:bodyclose
- url1: testutils.NewMockResponse(http.StatusOK, string(jwkSetJSON1)),
- //nolint:bodyclose
- url2: testutils.NewMockResponse(http.StatusOK, string(jwkSetJSON2)),
- }
- httpClient := &http.Client{
- Transport: &testutils.MockRoundTripper{
- Responses: mockResponses,
- },
- }
-
- // Create the OidcService with our mock client
- s := &OidcService{
- httpClient: httpClient,
- }
-
- var err error
- s.jwkCache, err = s.getJWKCache(t.Context())
- require.NoError(t, err)
-
- t.Run("Fetches and caches JWK set", func(t *testing.T) {
- jwks, err := s.jwkSetForURL(t.Context(), url1)
- require.NoError(t, err)
- require.NotNil(t, jwks)
-
- // Verify the JWK set contains our key
- require.Equal(t, 1, jwks.Len())
- })
-
- t.Run("Fails with invalid URL", func(t *testing.T) {
- ctx, cancel := context.WithTimeout(t.Context(), 2*time.Second)
- defer cancel()
- _, err := s.jwkSetForURL(ctx, "https://bad-url.com")
- require.Error(t, err)
- require.ErrorIs(t, err, context.DeadlineExceeded)
- })
-
- t.Run("Safe for concurrent use", func(t *testing.T) {
- const concurrency = 20
-
- // Channel to collect errors
- errChan := make(chan error, concurrency)
-
- // Start concurrent requests
- for range concurrency {
- go func() {
- jwks, err := s.jwkSetForURL(t.Context(), url2)
- if err != nil {
- errChan <- err
- return
- }
-
- // Verify the JWK set is valid
- if jwks == nil || jwks.Len() != 1 {
- errChan <- assert.AnError
- return
- }
-
- errChan <- nil
- }()
- }
-
- // Check for errors
- for range concurrency {
- assert.NoError(t, <-errChan, "Concurrent JWK set fetching should not produce errors")
- }
- })
-}
-
-func TestOidcService_verifyClientCredentialsInternal(t *testing.T) {
- const (
- federatedClientIssuer = "https://external-idp.com"
- federatedClientAudience = "https://pocket-id.com"
- federatedClientIssuerDefaults = "https://external-idp-defaults.com/"
- )
-
- var err error
- // Create a test database
- db := testutils.NewDatabaseForTest(t)
- common.EnvConfig.EncryptionKey = []byte("0123456789abcdef0123456789abcdef")
-
- // Create two JWKs for testing
- privateJWK, jwkSetJSON := generateTestECDSAKey(t)
- require.NoError(t, err)
- privateJWKDefaults, jwkSetJSONDefaults := generateTestECDSAKey(t)
- require.NoError(t, err)
-
- // Create a mock config and JwtService to test complete a token creation process
- mockConfig := NewTestAppConfigService(&model.AppConfig{
- SessionDuration: model.AppConfigVariable{Value: "60"}, // 60 minutes
- })
- mockJwtService, err := NewJwtService(t.Context(), db, mockConfig)
- require.NoError(t, err)
-
- // Create a mock HTTP client with custom transport to return the JWKS
- httpClient := &http.Client{
- Transport: &testutils.MockRoundTripper{
- Responses: map[string]*http.Response{
- //nolint:bodyclose
- federatedClientIssuer + "/jwks.json": testutils.NewMockResponse(http.StatusOK, string(jwkSetJSON)),
- //nolint:bodyclose
- federatedClientIssuerDefaults + ".well-known/jwks.json": testutils.NewMockResponse(http.StatusOK, string(jwkSetJSONDefaults)),
- },
- },
- }
-
- // Init the OidcService
- s := &OidcService{
- db: db,
- jwtService: mockJwtService,
- appConfigService: mockConfig,
- httpClient: httpClient,
- }
- s.jwkCache, err = s.getJWKCache(t.Context())
- require.NoError(t, err)
-
- // Create the test clients
- // 1. Confidential client
- confidentialClient, err := s.CreateClient(t.Context(), dto.OidcClientCreateDto{
- OidcClientUpdateDto: dto.OidcClientUpdateDto{
- Name: "Confidential Client",
- CallbackURLs: []string{"https://example.com/callback"},
- },
- }, "test-user-id")
- require.NoError(t, err)
-
- // Create a client secret for the confidential client
- confidentialSecret, err := s.CreateClientSecret(t.Context(), confidentialClient.ID)
- require.NoError(t, err)
-
- // 2. Public client
- publicClient, err := s.CreateClient(t.Context(), dto.OidcClientCreateDto{
- OidcClientUpdateDto: dto.OidcClientUpdateDto{
- Name: "Public Client",
- CallbackURLs: []string{"https://example.com/callback"},
- IsPublic: true,
- },
- }, "test-user-id")
- require.NoError(t, err)
-
- // 3. Confidential client with federated identity
- federatedClient, err := s.CreateClient(t.Context(), dto.OidcClientCreateDto{
- OidcClientUpdateDto: dto.OidcClientUpdateDto{
- Name: "Federated Client",
- CallbackURLs: []string{"https://example.com/callback"},
- },
- }, "test-user-id")
- require.NoError(t, err)
-
- federatedClient, err = s.UpdateClient(t.Context(), federatedClient.ID, dto.OidcClientUpdateDto{
- Name: federatedClient.Name,
- CallbackURLs: federatedClient.CallbackURLs,
- Credentials: dto.OidcClientCredentialsDto{
- FederatedIdentities: []dto.OidcClientFederatedIdentityDto{
- {
- Issuer: federatedClientIssuer,
- Audience: federatedClientAudience,
- Subject: federatedClient.ID,
- JWKS: federatedClientIssuer + "/jwks.json",
- },
- {
- Issuer: "federated-issuer-2",
- Audience: federatedClientAudience,
- Subject: "my-federated-client",
- JWKS: federatedClientIssuer + "/jwks.json",
- },
- {Issuer: federatedClientIssuerDefaults},
- },
- },
- })
- require.NoError(t, err)
-
- // Test cases for confidential client (using client secret)
- t.Run("Confidential client", func(t *testing.T) {
- t.Run("Succeeds with valid secret", func(t *testing.T) {
- // Test with valid client credentials
- client, err := s.verifyClientCredentialsInternal(t.Context(), s.db, ClientAuthCredentials{
- ClientID: confidentialClient.ID,
- ClientSecret: confidentialSecret,
- }, true)
- require.NoError(t, err)
- require.NotNil(t, client)
- assert.Equal(t, confidentialClient.ID, client.ID)
- })
-
- t.Run("Fails with invalid secret", func(t *testing.T) {
- // Test with invalid client secret
- client, err := s.verifyClientCredentialsInternal(t.Context(), s.db, ClientAuthCredentials{
- ClientID: confidentialClient.ID,
- ClientSecret: "invalid-secret",
- }, true)
- require.Error(t, err)
- require.ErrorIs(t, err, &common.OidcClientSecretInvalidError{})
- assert.Nil(t, client)
- })
-
- t.Run("Fails with missing secret", func(t *testing.T) {
- // Test with missing client secret
- client, err := s.verifyClientCredentialsInternal(t.Context(), s.db, ClientAuthCredentials{
- ClientID: confidentialClient.ID,
- }, true)
- require.Error(t, err)
- require.ErrorIs(t, err, &common.OidcMissingClientCredentialsError{})
- assert.Nil(t, client)
- })
- })
-
- // Test cases for public client
- t.Run("Public client", func(t *testing.T) {
- t.Run("Succeeds with no credentials", func(t *testing.T) {
- // Public clients don't require client secret
- client, err := s.verifyClientCredentialsInternal(t.Context(), s.db, ClientAuthCredentials{
- ClientID: publicClient.ID,
- }, true)
- require.NoError(t, err)
- require.NotNil(t, client)
- assert.Equal(t, publicClient.ID, client.ID)
- })
-
- t.Run("Fails with no credentials if allowPublicClientsWithoutAuth is false", func(t *testing.T) {
- // Public clients don't require client secret
- client, err := s.verifyClientCredentialsInternal(t.Context(), s.db, ClientAuthCredentials{
- ClientID: publicClient.ID,
- }, false)
- require.Error(t, err)
- require.ErrorIs(t, err, &common.OidcMissingClientCredentialsError{})
- assert.Nil(t, client)
- })
- })
-
- // Test cases for federated client using JWT assertion
- t.Run("Federated client", func(t *testing.T) {
- t.Run("Succeeds with valid JWT", func(t *testing.T) {
- // Create JWT for federated identity
- token, err := jwt.NewBuilder().
- Issuer(federatedClientIssuer).
- Audience([]string{federatedClientAudience}).
- Subject(federatedClient.ID).
- IssuedAt(time.Now()).
- Expiration(time.Now().Add(10 * time.Minute)).
- Build()
- require.NoError(t, err)
- signedToken, err := jwt.Sign(token, jwt.WithKey(jwa.ES256(), privateJWK))
- require.NoError(t, err)
-
- // Test with valid JWT assertion
- client, err := s.verifyClientCredentialsInternal(t.Context(), s.db, ClientAuthCredentials{
- ClientID: federatedClient.ID,
- ClientAssertionType: ClientAssertionTypeJWTBearer,
- ClientAssertion: string(signedToken),
- }, true)
- require.NoError(t, err)
- require.NotNil(t, client)
- assert.Equal(t, federatedClient.ID, client.ID)
- })
-
- t.Run("Fails with malformed JWT", func(t *testing.T) {
- // Test with invalid JWT assertion (just a random string)
- client, err := s.verifyClientCredentialsInternal(t.Context(), s.db, ClientAuthCredentials{
- ClientID: federatedClient.ID,
- ClientAssertionType: ClientAssertionTypeJWTBearer,
- ClientAssertion: "invalid.jwt.token",
- }, true)
- require.Error(t, err)
- require.ErrorIs(t, err, &common.OidcClientAssertionInvalidError{})
- assert.Nil(t, client)
- })
-
- testBadJWT := func(builderFn func(builder *jwt.Builder)) func(t *testing.T) {
- return func(t *testing.T) {
- // Populate all claims with valid values
- builder := jwt.NewBuilder().
- Issuer(federatedClientIssuer).
- Audience([]string{federatedClientAudience}).
- Subject(federatedClient.ID).
- IssuedAt(time.Now()).
- Expiration(time.Now().Add(10 * time.Minute))
-
- // Call builderFn to override the claims
- builderFn(builder)
-
- token, err := builder.Build()
- require.NoError(t, err)
- signedToken, err := jwt.Sign(token, jwt.WithKey(jwa.ES256(), privateJWK))
- require.NoError(t, err)
-
- // Test with invalid JWT assertion
- client, err := s.verifyClientCredentialsInternal(t.Context(), s.db, ClientAuthCredentials{
- ClientID: federatedClient.ID,
- ClientAssertionType: ClientAssertionTypeJWTBearer,
- ClientAssertion: string(signedToken),
- }, true)
- require.Error(t, err)
- require.ErrorIs(t, err, &common.OidcClientAssertionInvalidError{})
- require.Nil(t, client)
- }
- }
-
- t.Run("Fails with expired JWT", testBadJWT(func(builder *jwt.Builder) {
- builder.Expiration(time.Now().Add(-30 * time.Minute))
- }))
-
- t.Run("Fails with wrong issuer in JWT", testBadJWT(func(builder *jwt.Builder) {
- builder.Issuer("https://bad-issuer.com")
- }))
-
- t.Run("Fails with wrong audience in JWT", testBadJWT(func(builder *jwt.Builder) {
- builder.Audience([]string{"bad-audience"})
- }))
-
- t.Run("Fails with wrong subject in JWT", testBadJWT(func(builder *jwt.Builder) {
- builder.Subject("bad-subject")
- }))
-
- t.Run("Uses default values for audience and subject", func(t *testing.T) {
- // Create JWT for federated identity
- token, err := jwt.NewBuilder().
- Issuer(federatedClientIssuerDefaults).
- Audience([]string{common.EnvConfig.AppURL}).
- Subject(federatedClient.ID).
- IssuedAt(time.Now()).
- Expiration(time.Now().Add(10 * time.Minute)).
- Build()
- require.NoError(t, err)
- signedToken, err := jwt.Sign(token, jwt.WithKey(jwa.ES256(), privateJWKDefaults))
- require.NoError(t, err)
-
- // Test with valid JWT assertion
- client, err := s.verifyClientCredentialsInternal(t.Context(), s.db, ClientAuthCredentials{
- ClientID: federatedClient.ID,
- ClientAssertionType: ClientAssertionTypeJWTBearer,
- ClientAssertion: string(signedToken),
- }, true)
- require.NoError(t, err)
- require.NotNil(t, client)
- assert.Equal(t, federatedClient.ID, client.ID)
- })
- })
-
- t.Run("Complete token creation flow", func(t *testing.T) {
- t.Run("Client Credentials flow", func(t *testing.T) {
- t.Run("Succeeds with valid secret", func(t *testing.T) {
- // Generate a token
- input := dto.OidcCreateTokensDto{
- ClientID: confidentialClient.ID,
- ClientSecret: confidentialSecret,
- }
- token, err := s.createTokenFromClientCredentials(t.Context(), input)
- require.NoError(t, err)
- require.NotNil(t, token)
-
- // Verify the token
- claims, err := s.jwtService.VerifyOAuthAccessToken(token.AccessToken)
- require.NoError(t, err, "Failed to verify generated token")
-
- // Check the claims
- subject, ok := claims.Subject()
- _ = assert.True(t, ok, "User ID not found in token") &&
- assert.Equal(t, "client-"+confidentialClient.ID, subject, "Token subject should match confidential client ID with prefix")
- audience, ok := claims.Audience()
- _ = assert.True(t, ok, "Audience not found in token") &&
- assert.Equal(t, []string{confidentialClient.ID}, audience, "Audience should contain confidential client ID")
- })
-
- t.Run("Fails with invalid secret", func(t *testing.T) {
- input := dto.OidcCreateTokensDto{
- ClientID: confidentialClient.ID,
- ClientSecret: "invalid-secret",
- }
- _, err := s.createTokenFromClientCredentials(t.Context(), input)
- require.Error(t, err)
- require.ErrorIs(t, err, &common.OidcClientSecretInvalidError{})
- })
-
- t.Run("Fails without client secret for public clients", func(t *testing.T) {
- input := dto.OidcCreateTokensDto{
- ClientID: publicClient.ID,
- }
- _, err := s.createTokenFromClientCredentials(t.Context(), input)
- require.Error(t, err)
- require.ErrorIs(t, err, &common.OidcMissingClientCredentialsError{})
- })
-
- t.Run("Succeeds with valid assertion", func(t *testing.T) {
- // Create JWT for federated identity
- token, err := jwt.NewBuilder().
- Issuer(federatedClientIssuer).
- Audience([]string{federatedClientAudience}).
- Subject(federatedClient.ID).
- IssuedAt(time.Now()).
- Expiration(time.Now().Add(10 * time.Minute)).
- Build()
- require.NoError(t, err)
- signedToken, err := jwt.Sign(token, jwt.WithKey(jwa.ES256(), privateJWK))
- require.NoError(t, err)
-
- // Generate a token
- input := dto.OidcCreateTokensDto{
- ClientID: federatedClient.ID,
- ClientAssertion: string(signedToken),
- ClientAssertionType: ClientAssertionTypeJWTBearer,
- }
- createdToken, err := s.createTokenFromClientCredentials(t.Context(), input)
- require.NoError(t, err)
- require.NotNil(t, token)
-
- // Verify the token
- claims, err := s.jwtService.VerifyOAuthAccessToken(createdToken.AccessToken)
- require.NoError(t, err, "Failed to verify generated token")
-
- // Check the claims
- subject, ok := claims.Subject()
- _ = assert.True(t, ok, "User ID not found in token") &&
- assert.Equal(t, "client-"+federatedClient.ID, subject, "Token subject should match federated client ID with prefix")
- audience, ok := claims.Audience()
- _ = assert.True(t, ok, "Audience not found in token") &&
- assert.Equal(t, []string{federatedClient.ID}, audience, "Audience should contain the federated client ID")
- })
-
- t.Run("Succeeds with valid assertion and custom subject", func(t *testing.T) {
- // Create JWT for federated identity
- token, err := jwt.NewBuilder().
- Issuer("federated-issuer-2").
- Audience([]string{federatedClientAudience}).
- Subject("my-federated-client").
- IssuedAt(time.Now()).
- Expiration(time.Now().Add(10 * time.Minute)).
- Build()
- require.NoError(t, err)
- signedToken, err := jwt.Sign(token, jwt.WithKey(jwa.ES256(), privateJWK))
- require.NoError(t, err)
-
- // Generate a token
- input := dto.OidcCreateTokensDto{
- ClientID: federatedClient.ID,
- ClientAssertion: string(signedToken),
- ClientAssertionType: ClientAssertionTypeJWTBearer,
- }
- createdToken, err := s.createTokenFromClientCredentials(t.Context(), input)
- require.NoError(t, err)
- require.NotNil(t, token)
-
- // Verify the token
- claims, err := s.jwtService.VerifyOAuthAccessToken(createdToken.AccessToken)
- require.NoError(t, err, "Failed to verify generated token")
-
- // Check the claims
- subject, ok := claims.Subject()
- _ = assert.True(t, ok, "User ID not found in token") &&
- assert.Equal(t, "client-"+federatedClient.ID, subject, "Token subject should match federated client ID with prefix")
- audience, ok := claims.Audience()
- _ = assert.True(t, ok, "Audience not found in token") &&
- assert.Equal(t, []string{federatedClient.ID}, audience, "Audience should contain the federated client ID")
- })
-
- t.Run("Fails with invalid assertion", func(t *testing.T) {
- input := dto.OidcCreateTokensDto{
- ClientID: confidentialClient.ID,
- ClientAssertion: "invalid.jwt.token",
- ClientAssertionType: ClientAssertionTypeJWTBearer,
- }
- _, err := s.createTokenFromClientCredentials(t.Context(), input)
- require.Error(t, err)
- require.ErrorIs(t, err, &common.OidcClientAssertionInvalidError{})
- })
-
- t.Run("Succeeds with custom resource", func(t *testing.T) {
- // Generate a token
- input := dto.OidcCreateTokensDto{
- ClientID: confidentialClient.ID,
- ClientSecret: confidentialSecret,
- Resource: "https://example.com/",
- }
- token, err := s.createTokenFromClientCredentials(t.Context(), input)
- require.NoError(t, err)
- require.NotNil(t, token)
-
- // Verify the token
- claims, err := s.jwtService.VerifyOAuthAccessToken(token.AccessToken)
- require.NoError(t, err, "Failed to verify generated token")
-
- // Check the claims
- subject, ok := claims.Subject()
- _ = assert.True(t, ok, "User ID not found in token") &&
- assert.Equal(t, "client-"+confidentialClient.ID, subject, "Token subject should match confidential client ID with prefix")
- audience, ok := claims.Audience()
- _ = assert.True(t, ok, "Audience not found in token") &&
- assert.Equal(t, []string{input.Resource}, audience, "Audience should contain the resource provided in request")
- })
- })
- })
-}
-
-func TestOidcServiceRefreshTokenAuthorizationState(t *testing.T) {
- newFixture := func(t *testing.T, isGroupRestricted bool) (*OidcService, *gorm.DB, model.User, model.OidcClient, string, string, *model.UserGroup) {
- t.Helper()
-
- db := testutils.NewDatabaseForTest(t)
- common.EnvConfig.EncryptionKey = []byte("0123456789abcdef0123456789abcdef")
-
- mockConfig := NewTestAppConfigService(&model.AppConfig{
- SessionDuration: model.AppConfigVariable{Value: "60"},
- })
- jwtService, err := NewJwtService(t.Context(), db, mockConfig)
- require.NoError(t, err)
-
- service := &OidcService{
- db: db,
- jwtService: jwtService,
- appConfigService: mockConfig,
- }
-
- user := model.User{
- Username: "refresh-token-user",
- Email: new("refresh-token-user@example.com"),
- EmailVerified: true,
- FirstName: "Refresh",
- LastName: "User",
- }
- require.NoError(t, db.Create(&user).Error)
-
- client, err := service.CreateClient(t.Context(), dto.OidcClientCreateDto{
- OidcClientUpdateDto: dto.OidcClientUpdateDto{
- Name: "Refresh Token Client",
- CallbackURLs: []string{"https://example.com/callback"},
- IsGroupRestricted: isGroupRestricted,
- },
- }, user.ID)
- require.NoError(t, err)
-
- clientSecret, err := service.CreateClientSecret(t.Context(), client.ID)
- require.NoError(t, err)
-
- var userGroup *model.UserGroup
- if isGroupRestricted {
- group := model.UserGroup{
- FriendlyName: "Allowed Group",
- Name: "allowed-group",
- }
- require.NoError(t, db.Create(&group).Error)
- require.NoError(t, db.Model(&user).Association("UserGroups").Append(&group))
- require.NoError(t, db.Model(&client).Association("AllowedUserGroups").Append(&group))
- userGroup = &group
- }
-
- scope := "openid profile email groups"
- require.NoError(t, db.Create(&model.UserAuthorizedOidcClient{
- UserID: user.ID,
- ClientID: client.ID,
- Scope: scope,
- }).Error)
-
- refreshToken, err := service.createRefreshToken(t.Context(), client.ID, user.ID, scope, AuthenticationMethodPhishingResistant, "03f94e54-53c4-42f8-afe5-918ffd97a30e", db)
- require.NoError(t, err)
-
- return service, db, user, client, clientSecret, refreshToken, userGroup
- }
-
- refreshInput := func(client model.OidcClient, clientSecret string, refreshToken string) dto.OidcCreateTokensDto {
- return dto.OidcCreateTokensDto{
- GrantType: GrantTypeRefreshToken,
- RefreshToken: refreshToken,
- ClientID: client.ID,
- ClientSecret: clientSecret,
- }
- }
-
- t.Run("rejects refresh token after authorization revocation", func(t *testing.T) {
- service, db, user, client, clientSecret, refreshToken, _ := newFixture(t, false)
-
- err := service.RevokeAuthorizedClient(t.Context(), user.ID, client.ID)
- require.NoError(t, err)
-
- var refreshTokenCount int64
- require.NoError(t, db.Model(&model.OidcRefreshToken{}).
- Where("user_id = ? AND client_id = ?", user.ID, client.ID).
- Count(&refreshTokenCount).Error)
- assert.Zero(t, refreshTokenCount)
-
- _, err = service.createTokenFromRefreshToken(t.Context(), refreshInput(client, clientSecret, refreshToken))
- require.Error(t, err)
- require.ErrorIs(t, err, &common.OidcInvalidRefreshTokenError{})
- })
-
- t.Run("rejects and deletes stale refresh token without authorization record", func(t *testing.T) {
- service, db, user, client, clientSecret, refreshToken, _ := newFixture(t, false)
-
- require.NoError(t, db.
- Where("user_id = ? AND client_id = ?", user.ID, client.ID).
- Delete(&model.UserAuthorizedOidcClient{}).Error)
-
- _, err := service.createTokenFromRefreshToken(t.Context(), refreshInput(client, clientSecret, refreshToken))
- require.Error(t, err)
- require.ErrorIs(t, err, &common.OidcInvalidRefreshTokenError{})
-
- var refreshTokenCount int64
- require.NoError(t, db.Model(&model.OidcRefreshToken{}).
- Where("user_id = ? AND client_id = ?", user.ID, client.ID).
- Count(&refreshTokenCount).Error)
- assert.Zero(t, refreshTokenCount)
- })
-
- t.Run("rejects refresh token for disabled user", func(t *testing.T) {
- service, db, user, client, clientSecret, refreshToken, _ := newFixture(t, false)
-
- require.NoError(t, db.Model(&model.User{}).
- Where("id = ?", user.ID).
- Update("disabled", true).Error)
-
- _, err := service.createTokenFromRefreshToken(t.Context(), refreshInput(client, clientSecret, refreshToken))
- require.Error(t, err)
- require.ErrorIs(t, err, &common.OidcInvalidRefreshTokenError{})
- })
-
- t.Run("rejects refresh token after user leaves allowed group", func(t *testing.T) {
- service, db, user, client, clientSecret, refreshToken, userGroup := newFixture(t, true)
- require.NotNil(t, userGroup)
-
- require.NoError(t, db.Model(&user).Association("UserGroups").Delete(userGroup))
-
- _, err := service.createTokenFromRefreshToken(t.Context(), refreshInput(client, clientSecret, refreshToken))
- require.Error(t, err)
- require.ErrorIs(t, err, &common.OidcAccessDeniedError{})
- })
-}
-
-func TestOidcServiceAuthenticationMethodsPersistence(t *testing.T) {
- mockConfig := NewTestAppConfigService(&model.AppConfig{
- SessionDuration: model.AppConfigVariable{Value: "60"},
- })
- jwtService, db, _ := setupJwtService(t, mockConfig)
- service := &OidcService{
- db: db,
- jwtService: jwtService,
- }
- authenticationMethod := AuthenticationMethodPhishingResistant
-
- t.Run("stores authentication method on authorization codes", func(t *testing.T) {
- code, err := service.createAuthorizationCode(
- t.Context(),
- "amr-client",
- "amr-user",
- "openid profile",
- authenticationMethod,
- "",
- "",
- "",
- db,
- )
- require.NoError(t, err)
-
- var authorizationCode model.OidcAuthorizationCode
- require.NoError(t, db.First(&authorizationCode, "code = ?", code).Error)
- assert.Equal(t, authenticationMethod, authorizationCode.AuthenticationMethod)
- })
-
- t.Run("stores authentication methods on refresh tokens", func(t *testing.T) {
- _, err := service.createRefreshToken(t.Context(), "amr-client", "amr-user", "openid profile", authenticationMethod, "03f94e54-53c4-42f8-afe5-918ffd97a30e", db)
- require.NoError(t, err)
-
- var refreshToken model.OidcRefreshToken
- require.NoError(t, db.First(&refreshToken, "client_id = ? AND user_id = ?", "amr-client", "amr-user").Error)
- assert.Equal(t, authenticationMethod, refreshToken.AuthenticationMethod)
- })
-}
-
-func TestValidateCodeVerifier_Plain(t *testing.T) {
- require.False(t, validateCodeVerifier("", "", false))
- require.False(t, validateCodeVerifier("", "", true))
-
- t.Run("plain", func(t *testing.T) {
- require.False(t, validateCodeVerifier("", "challenge", false))
- require.False(t, validateCodeVerifier("verifier", "", false))
- require.True(t, validateCodeVerifier("plainVerifier", "plainVerifier", false))
- require.False(t, validateCodeVerifier("plainVerifier", "otherVerifier", false))
- })
-
- t.Run("SHA 256", func(t *testing.T) {
- codeVerifier := "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"
- hash := sha256.Sum256([]byte(codeVerifier))
- codeChallenge := base64.RawURLEncoding.EncodeToString(hash[:])
-
- require.True(t, validateCodeVerifier(codeVerifier, codeChallenge, true))
- require.False(t, validateCodeVerifier("wrongVerifier", codeChallenge, true))
- require.False(t, validateCodeVerifier(codeVerifier, "!", true))
-
- // Invalid base64
- require.False(t, validateCodeVerifier("NOT!VALID", codeChallenge, true))
- })
-}
-
-func TestCodeChallengeMethodIsSha256(t *testing.T) {
- tests := []struct {
- name string
- method string
- wantSha256 bool
- wantErr bool
- }{
- {
- name: "omitted defaults to plain",
- method: "",
- wantSha256: false,
- },
- {
- name: "plain",
- method: "plain",
- wantSha256: false,
- },
- {
- name: "plain case insensitive",
- method: "PLAIN",
- wantSha256: false,
- },
- {
- name: "s256",
- method: "S256",
- wantSha256: true,
- },
- {
- name: "s256 case insensitive",
- method: "s256",
- wantSha256: true,
- },
- {
- name: "unknown method",
- method: "S384",
- wantErr: true,
- },
- }
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- got, err := codeChallengeMethodIsSha256(tt.method)
- if tt.wantErr {
- require.Error(t, err)
- var invalidRequest *common.OidcInvalidRequestError
- require.ErrorAs(t, err, &invalidRequest)
- return
- }
-
- require.NoError(t, err)
- assert.Equal(t, tt.wantSha256, got)
- })
- }
-}
-
func TestOidcService_updateClientLogoType(t *testing.T) {
// Create a test database
db := testutils.NewDatabaseForTest(t)
@@ -1251,165 +449,3 @@ func TestOidcService_downloadAndSaveLogoFromURL(t *testing.T) {
require.ErrorContains(t, err, "failed to look up client")
})
}
-
-func TestOidcService_ValidateEndSessionDeletesMatchingRefreshToken(t *testing.T) {
- db := testutils.NewDatabaseForTest(t)
- common.EnvConfig.EncryptionKey = []byte("0123456789abcdef0123456789abcdef")
- mockConfig := NewTestAppConfigService(&model.AppConfig{
- SessionDuration: model.AppConfigVariable{Value: "60"},
- })
- mockJwtService, err := NewJwtService(t.Context(), db, mockConfig)
- require.NoError(t, err)
-
- oidcService := &OidcService{
- db: db,
- jwtService: mockJwtService,
- }
-
- userID := "test-user-123"
- clientID := "test-client-456"
- otherClientID := "other-client-789"
- otherIDTokenJti := "ac653f42-4781-49f2-bc7c-cc44503c3a1a" //nolint:gosec
- userEmail := "test@example.com"
-
- user := model.User{
- Base: model.Base{ID: userID},
- Email: &userEmail,
- }
- require.NoError(t, db.Create(&user).Error)
-
- client := model.OidcClient{
- Base: model.Base{ID: clientID},
- Name: "Test Client",
- LogoutCallbackURLs: []string{"https://example.com/logout"},
- }
- require.NoError(t, db.Create(&client).Error)
-
- otherClient := model.OidcClient{
- Base: model.Base{ID: otherClientID},
- Name: "Other Client",
- LogoutCallbackURLs: []string{"https://other.example.com/logout"},
- }
- require.NoError(t, db.Create(&otherClient).Error)
-
- require.NoError(t, db.Create(&model.UserAuthorizedOidcClient{
- UserID: userID,
- ClientID: clientID,
- }).Error)
-
- userClaims := map[string]any{
- "sub": userID,
- "name": "Test User",
- "email": userEmail,
- }
- idToken, idTokenJti, err := mockJwtService.GenerateIDToken(userClaims, clientID, "", "")
- require.NoError(t, err)
-
- refreshTokens := []model.OidcRefreshToken{
- {
- Token: "matching-refresh-token",
- UserID: userID,
- ClientID: clientID,
- IdTokenJti: &idTokenJti,
- ExpiresAt: datatype.DateTime(time.Now().Add(time.Hour)),
- Scope: "openid profile",
- },
- {
- Token: "same-client-different-session",
- UserID: userID,
- ClientID: clientID,
- IdTokenJti: &otherIDTokenJti,
- ExpiresAt: datatype.DateTime(time.Now().Add(time.Hour)),
- Scope: "openid profile",
- },
- {
- Token: "other-client-same-jti",
- UserID: userID,
- ClientID: otherClientID,
- IdTokenJti: &idTokenJti,
- ExpiresAt: datatype.DateTime(time.Now().Add(time.Hour)),
- Scope: "openid profile",
- },
- {
- Token: "legacy-refresh-token",
- UserID: userID,
- ClientID: clientID,
- ExpiresAt: datatype.DateTime(time.Now().Add(time.Hour)),
- Scope: "openid profile",
- },
- }
- require.NoError(t, db.Create(&refreshTokens).Error)
-
- callbackURL, err := oidcService.ValidateEndSession(t.Context(), dto.OidcLogoutDto{
- IdTokenHint: idToken,
- ClientId: clientID,
- PostLogoutRedirectUri: "https://example.com/logout",
- }, userID)
- require.NoError(t, err)
- assert.Equal(t, "https://example.com/logout", callbackURL)
-
- var remainingTokens []model.OidcRefreshToken
- require.NoError(t, db.Order("token").Find(&remainingTokens).Error)
- remainingTokenValues := make([]string, len(remainingTokens))
- for i, token := range remainingTokens {
- remainingTokenValues[i] = token.Token
- }
- assert.ElementsMatch(t, []string{
- "legacy-refresh-token",
- "other-client-same-jti",
- "same-client-different-session",
- }, remainingTokenValues)
-}
-
-// Tests for prompt parameter parsing and handling
-func TestParsePromptParameter(t *testing.T) {
- t.Run("empty prompt returns empty slice", func(t *testing.T) {
- result := parsePromptParameter("")
- assert.Equal(t, []string{}, result)
- })
-
- t.Run("single prompt value", func(t *testing.T) {
- result := parsePromptParameter("none")
- assert.Equal(t, []string{"none"}, result)
- })
-
- t.Run("multiple prompt values space-delimited", func(t *testing.T) {
- result := parsePromptParameter("login consent")
- assert.Equal(t, []string{"login", "consent"}, result)
- })
-
- t.Run("multiple prompt values with extra spaces", func(t *testing.T) {
- result := parsePromptParameter(" none login ")
- assert.Equal(t, []string{"none", "login"}, result)
- })
-}
-
-func TestPromptParameterConflicts(t *testing.T) {
- tests := []struct {
- name string
- prompt string
- expectConflict bool
- }{
- {"none alone is valid", "none", false},
- {"login alone is valid", "login", false},
- {"consent alone is valid", "consent", false},
- {"login consent is valid", "login consent", false},
- {"none consent conflicts", "none consent", true},
- {"none login conflicts", "none login", true},
- {"none select_account conflicts", "none select_account", true},
- {"none consent login conflicts", "none consent login", true},
- }
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- values := parsePromptParameter(tt.prompt)
- 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)
- })
- }
-}
diff --git a/backend/internal/service/scim_service.go b/backend/internal/service/scim_service.go
index f21f8be7..3d81b894 100644
--- a/backend/internal/service/scim_service.go
+++ b/backend/internal/service/scim_service.go
@@ -20,6 +20,7 @@ import (
"github.com/pocket-id/pocket-id/backend/internal/dto"
"github.com/pocket-id/pocket-id/backend/internal/model"
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
+ "github.com/pocket-id/pocket-id/backend/internal/oidc"
"github.com/pocket-id/pocket-id/backend/internal/utils"
"gorm.io/gorm"
)
@@ -364,7 +365,7 @@ func (s *ScimService) syncUser(ctx context.Context,
userResource *dto.ScimUser,
) (scimSyncAction, *dto.ScimUser, error) {
// If user is not allowed for the client, delete it from SCIM provider
- if userResource != nil && !IsUserGroupAllowedToAuthorize(user, provider.OidcClient) {
+ if userResource != nil && !oidc.IsUserGroupAllowedToAuthorize(user, provider.OidcClient) {
return scimActionDeleted, nil, s.deleteScimResource(ctx, provider, fmt.Sprintf("/Users/%s", url.PathEscape(userResource.ID)))
}
diff --git a/backend/internal/service/webauthn_service.go b/backend/internal/service/webauthn_service.go
index 18e8ac29..ce6c7e04 100644
--- a/backend/internal/service/webauthn_service.go
+++ b/backend/internal/service/webauthn_service.go
@@ -479,19 +479,20 @@ func (s *WebAuthnService) CreateReauthenticationTokenWithWebauthn(ctx context.Co
return token, nil
}
-func (s *WebAuthnService) ConsumeReauthenticationToken(ctx context.Context, tx *gorm.DB, token string, userID string) error {
+func (s *WebAuthnService) ConsumeReauthenticationToken(ctx context.Context, tx *gorm.DB, token string, userID string) (time.Time, error) {
hashedToken := utils.CreateSha256Hash(token)
+ var reauthToken model.ReauthenticationToken
result := tx.WithContext(ctx).
Clauses(clause.Returning{}).
- Delete(&model.ReauthenticationToken{}, "token = ? AND user_id = ? AND expires_at > ?", hashedToken, userID, datatype.DateTime(time.Now()))
+ Delete(&reauthToken, "token = ? AND user_id = ? AND expires_at > ?", hashedToken, userID, datatype.DateTime(time.Now()))
if result.Error != nil {
- return result.Error
+ return time.Time{}, result.Error
}
if result.RowsAffected == 0 {
- return &common.ReauthenticationRequiredError{}
+ return time.Time{}, &common.ReauthenticationRequiredError{}
}
- return nil
+ return reauthToken.CreatedAt.UTC(), nil
}
func (s *WebAuthnService) createReauthenticationToken(ctx context.Context, tx *gorm.DB, userID string) (string, error) {
diff --git a/backend/internal/service/webauthn_service_test.go b/backend/internal/service/webauthn_service_test.go
index 79ab0572..41553f67 100644
--- a/backend/internal/service/webauthn_service_test.go
+++ b/backend/internal/service/webauthn_service_test.go
@@ -2,12 +2,15 @@ package service
import (
"testing"
+ "time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/pocket-id/pocket-id/backend/internal/common"
"github.com/pocket-id/pocket-id/backend/internal/model"
+ datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
+ "github.com/pocket-id/pocket-id/backend/internal/utils"
)
func TestCreateReauthenticationTokenWithAccessToken(t *testing.T) {
@@ -66,3 +69,37 @@ func TestCreateReauthenticationTokenWithAccessToken(t *testing.T) {
assert.ErrorAs(t, err, new(*common.ReauthenticationRequiredError))
})
}
+
+func TestConsumeReauthenticationTokenReturnsTokenCreationTime(t *testing.T) {
+ mockConfig := NewTestAppConfigService(&model.AppConfig{
+ SessionDuration: model.AppConfigVariable{Value: "60"},
+ })
+ jwtService, db, _ := setupJwtService(t, mockConfig)
+ service := &WebAuthnService{
+ db: db,
+ jwtService: jwtService,
+ }
+
+ const (
+ userID = "reauth-user"
+ token = "reauthentication-token"
+ )
+ require.NoError(t, db.Create(&model.User{
+ Base: model.Base{ID: userID},
+ }).Error)
+ require.NoError(t, db.Create(&model.ReauthenticationToken{
+ Token: utils.CreateSha256Hash(token),
+ ExpiresAt: datatype.DateTime(time.Now().Add(time.Minute)),
+ UserID: userID,
+ }).Error)
+
+ var storedToken model.ReauthenticationToken
+ require.NoError(t, db.First(&storedToken, "user_id = ?", userID).Error)
+
+ tx := db.Begin()
+ reauthenticatedAt, err := service.ConsumeReauthenticationToken(t.Context(), tx, token, userID)
+ require.NoError(t, err)
+ require.NoError(t, tx.Commit().Error)
+
+ require.Equal(t, storedToken.CreatedAt.UTC(), reauthenticatedAt)
+}
diff --git a/backend/internal/utils/callback_url_util.go b/backend/internal/utils/callback_url_util.go
index 7fa44a97..d92af89d 100644
--- a/backend/internal/utils/callback_url_util.go
+++ b/backend/internal/utils/callback_url_util.go
@@ -1,6 +1,7 @@
package utils
import (
+ "errors"
"log/slog"
"net"
"net/url"
@@ -19,12 +20,73 @@ func ValidateCallbackURLPattern(pattern string) error {
}
pattern, _, _ = strings.Cut(pattern, "#")
+ if err := validateCallbackURLPatternURL(pattern); err != nil {
+ return err
+ }
+
pattern = normalizeToURLPatternStandard(pattern)
_, err := urlpattern.New(pattern, "", nil)
return err
}
+func validateCallbackURLPatternURL(pattern string) error {
+ parseablePattern := callbackURLPatternForURLParse(pattern)
+ u, err := url.Parse(parseablePattern)
+ if err != nil {
+ return err
+ }
+ if u.Scheme == "" {
+ return errors.New("callback URL pattern must include a scheme")
+ }
+
+ switch strings.ToLower(u.Scheme) {
+ case "javascript", "data":
+ return errors.New("callback URL pattern scheme is not allowed")
+ default:
+ return nil
+ }
+}
+
+func callbackURLPatternForURLParse(pattern string) string {
+ if strings.HasPrefix(pattern, "*://") {
+ pattern = "https://" + strings.TrimPrefix(pattern, "*://")
+ }
+
+ scheme, rest, ok := strings.Cut(pattern, "://")
+ if !ok {
+ return pattern
+ }
+
+ authority := rest
+ suffix := ""
+ if i := strings.IndexAny(rest, "/?#"); i >= 0 {
+ authority = rest[:i]
+ suffix = rest[i:]
+ }
+
+ userinfo := ""
+ hostport := authority
+ if i := strings.LastIndex(authority, "@"); i >= 0 {
+ userinfo = authority[:i+1]
+ hostport = authority[i+1:]
+ }
+
+ if strings.HasPrefix(hostport, "[") {
+ end := strings.Index(hostport, "]")
+ if end == -1 {
+ return pattern
+ }
+ if len(hostport) > end+1 && hostport[end+1] == ':' && strings.Contains(hostport[end+2:], "*") {
+ hostport = hostport[:end+2] + "443"
+ }
+ } else if i := strings.LastIndex(hostport, ":"); i >= 0 && strings.Contains(hostport[i+1:], "*") {
+ hostport = hostport[:i+1] + "443"
+ }
+
+ return scheme + "://" + userinfo + hostport + suffix
+}
+
// GetCallbackURLFromList returns the first callback URL that matches the input callback URL.
func GetCallbackURLFromList(urls []string, inputCallbackURL string) (callbackURL string, err error) {
// Special case for Loopback Interface Redirection. Quoting from RFC 8252 section 7.3:
diff --git a/backend/internal/utils/callback_url_util_test.go b/backend/internal/utils/callback_url_util_test.go
index a6eb1625..251d3ae2 100644
--- a/backend/internal/utils/callback_url_util_test.go
+++ b/backend/internal/utils/callback_url_util_test.go
@@ -58,6 +58,21 @@ func TestValidateCallbackURLPattern(t *testing.T) {
pattern: "https://exa[mple.com/callback",
shouldError: true,
},
+ {
+ name: "malformed IPv6 host",
+ pattern: "http://[::1",
+ shouldError: true,
+ },
+ {
+ name: "javascript scheme",
+ pattern: "javascript:alert(1)",
+ shouldError: true,
+ },
+ {
+ name: "data scheme",
+ pattern: "data:text/html;base64,PGgxPkhlbGxvPC9oMT4=",
+ shouldError: true,
+ },
}
for _, tt := range tests {
diff --git a/backend/internal/utils/cookie/add_cookie.go b/backend/internal/utils/cookie/add_cookie.go
index 8c33d781..c2b8ab74 100644
--- a/backend/internal/utils/cookie/add_cookie.go
+++ b/backend/internal/utils/cookie/add_cookie.go
@@ -17,3 +17,7 @@ func AddSessionIdCookie(c *gin.Context, maxAgeInSeconds int, sessionID string) {
func AddDeviceTokenCookie(c *gin.Context, deviceToken string) {
c.SetCookie(DeviceTokenCookieName, deviceToken, int(15*time.Minute.Seconds()), "/api/one-time-access-token", "", true, true)
}
+
+func AddReauthenticationTokenCookie(c *gin.Context, reauthenticationToken string) {
+ c.SetCookie(ReauthenticationTokenCookieName, reauthenticationToken, int(3*time.Minute.Seconds()), "/", "", true, true)
+}
diff --git a/backend/internal/utils/cookie/cookie_names.go b/backend/internal/utils/cookie/cookie_names.go
index 73acdf13..9465701a 100644
--- a/backend/internal/utils/cookie/cookie_names.go
+++ b/backend/internal/utils/cookie/cookie_names.go
@@ -8,12 +8,14 @@ import (
var AccessTokenCookieName = "__Host-access_token"
var SessionIdCookieName = "__Host-session"
-var DeviceTokenCookieName = "__Secure-device_token" //nolint:gosec
+var DeviceTokenCookieName = "__Secure-device_token" //nolint:gosec
+var ReauthenticationTokenCookieName = "__Secure-reauthentication_token" //nolint:gosec
func init() {
if strings.HasPrefix(common.EnvConfig.AppURL, "http://") {
AccessTokenCookieName = "access_token"
SessionIdCookieName = "session"
DeviceTokenCookieName = "device_token"
+ ReauthenticationTokenCookieName = "reauthentication_token"
}
}
diff --git a/backend/internal/utils/csp.go b/backend/internal/utils/csp.go
new file mode 100644
index 00000000..0f3d7bd2
--- /dev/null
+++ b/backend/internal/utils/csp.go
@@ -0,0 +1,67 @@
+package utils
+
+import (
+ "crypto/rand"
+ "encoding/base64"
+ "strings"
+
+ "github.com/gin-gonic/gin"
+)
+
+const cspNonceContextKey = "csp_nonce"
+
+// GetCSPNonce returns the CSP nonce generated for this request, if any.
+func GetCSPNonce(c *gin.Context) string {
+ if v, ok := c.Get(cspNonceContextKey); ok {
+ if s, ok := v.(string); ok {
+ return s
+ }
+ }
+ return ""
+}
+
+// SetCSPNonce stores a per-request CSP nonce so handlers can reference it in
+// Content-Security-Policy headers they emit themselves.
+func SetCSPNonce(c *gin.Context, nonce string) {
+ c.Set(cspNonceContextKey, nonce)
+}
+
+func BuildCSP(nonce string, formActionExtra ...string) string {
+ formAction := "'self'"
+ scriptSrc := "script-src 'self'"
+ if nonce != "" {
+ scriptSrc += " 'nonce-" + nonce + "'"
+ }
+
+ 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'; " +
+ scriptSrc
+}
+
+// GenerateCSPNonce returns a random base64 nonce for use in a CSP header.
+func GenerateCSPNonce() string {
+ b := make([]byte, 16)
+ if _, err := rand.Read(b); err != nil {
+ return "" // if generation fails, return empty; policy will omit nonce
+ }
+ return base64.RawURLEncoding.EncodeToString(b)
+}
diff --git a/backend/resources/migrations/postgres/20260607120000_oauth2_storage.down.sql b/backend/resources/migrations/postgres/20260607120000_oauth2_storage.down.sql
new file mode 100644
index 00000000..87ddc9b5
--- /dev/null
+++ b/backend/resources/migrations/postgres/20260607120000_oauth2_storage.down.sql
@@ -0,0 +1,83 @@
+CREATE TABLE oidc_authorization_codes
+(
+ id UUID NOT NULL PRIMARY KEY,
+ created_at TIMESTAMPTZ,
+ code VARCHAR(255) NOT NULL UNIQUE,
+ scope TEXT NOT NULL,
+ nonce VARCHAR(255),
+ expires_at TIMESTAMPTZ NOT NULL,
+ user_id UUID NOT NULL REFERENCES users ON DELETE CASCADE,
+ client_id TEXT NOT NULL REFERENCES oidc_clients (id) ON DELETE CASCADE,
+ code_challenge VARCHAR(255),
+ code_challenge_method_sha256 BOOLEAN,
+ authentication_method TEXT NOT NULL DEFAULT ''
+);
+
+CREATE INDEX idx_oidc_authorization_codes_expires_at ON oidc_authorization_codes (expires_at);
+
+CREATE TABLE oidc_refresh_tokens (
+ id UUID NOT NULL PRIMARY KEY,
+ created_at TIMESTAMPTZ,
+ token VARCHAR(255) NOT NULL UNIQUE,
+ expires_at TIMESTAMPTZ NOT NULL,
+ scope TEXT NOT NULL,
+ user_id UUID NOT NULL REFERENCES users ON DELETE CASCADE,
+ client_id TEXT NOT NULL REFERENCES oidc_clients ON DELETE CASCADE,
+ authentication_method TEXT NOT NULL DEFAULT '',
+ id_token_jti UUID
+);
+
+CREATE INDEX idx_oidc_refresh_tokens_expires_at ON oidc_refresh_tokens (expires_at);
+CREATE INDEX idx_oidc_refresh_tokens_id_token_jti
+ ON oidc_refresh_tokens(user_id, client_id, id_token_jti);
+
+CREATE TABLE oidc_device_codes
+(
+ id UUID NOT NULL PRIMARY KEY,
+ created_at TIMESTAMPTZ,
+ device_code TEXT NOT NULL UNIQUE,
+ user_code TEXT NOT NULL UNIQUE,
+ scope TEXT NOT NULL,
+ expires_at TIMESTAMPTZ NOT NULL,
+ is_authorized BOOLEAN NOT NULL DEFAULT FALSE,
+ user_id UUID REFERENCES users ON DELETE CASCADE,
+ client_id TEXT NOT NULL REFERENCES oidc_clients ON DELETE CASCADE,
+ authentication_method TEXT NOT NULL DEFAULT '',
+ nonce VARCHAR(255)
+);
+
+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 user_authorized_oidc_clients ADD COLUMN scope_text TEXT;
+UPDATE user_authorized_oidc_clients
+SET scope_text = CASE
+ WHEN scope IS NULL THEN NULL
+ WHEN jsonb_typeof(scope) = 'array' THEN (
+ SELECT string_agg(scope_value.value, ' ')
+ FROM jsonb_array_elements_text(scope) AS scope_value(value)
+ )
+ ELSE scope #>> '{}'
+END;
+ALTER TABLE user_authorized_oidc_clients DROP COLUMN scope;
+ALTER TABLE user_authorized_oidc_clients RENAME COLUMN scope_text TO scope;
+
+DROP INDEX IF EXISTS idx_interaction_sessions_client_id;
+DROP INDEX IF EXISTS idx_interaction_sessions_user_id;
+DROP TABLE IF EXISTS interaction_sessions;
+
+DROP INDEX IF EXISTS idx_oauth2_jtis_expires_at;
+DROP TABLE IF EXISTS oauth2_jtis;
+
+DROP INDEX IF EXISTS idx_oauth2_sessions_expires_at;
+DROP INDEX IF EXISTS idx_oauth2_sessions_kind_request;
+DROP INDEX IF EXISTS idx_oauth2_sessions_kind_key;
+DROP TABLE IF EXISTS oauth2_sessions;
diff --git a/backend/resources/migrations/postgres/20260607120000_oauth2_storage.up.sql b/backend/resources/migrations/postgres/20260607120000_oauth2_storage.up.sql
new file mode 100644
index 00000000..bbfbc6b2
--- /dev/null
+++ b/backend/resources/migrations/postgres/20260607120000_oauth2_storage.up.sql
@@ -0,0 +1,63 @@
+CREATE TABLE oauth2_sessions (
+ id UUID NOT NULL PRIMARY KEY,
+ created_at TIMESTAMPTZ NOT NULL,
+ kind TEXT NOT NULL,
+ key TEXT NOT NULL,
+ request_id TEXT NOT NULL,
+ access_token_signature TEXT NOT NULL DEFAULT '',
+ active BOOLEAN NOT NULL DEFAULT TRUE,
+ request_data JSONB NOT NULL,
+ expires_at TIMESTAMPTZ
+);
+
+CREATE UNIQUE INDEX idx_oauth2_sessions_kind_key ON oauth2_sessions (kind, key);
+CREATE INDEX idx_oauth2_sessions_kind_request ON oauth2_sessions (kind, request_id);
+CREATE INDEX idx_oauth2_sessions_expires_at ON oauth2_sessions (expires_at);
+
+CREATE TABLE oauth2_jtis (
+ id UUID NOT NULL PRIMARY KEY,
+ created_at TIMESTAMPTZ NOT NULL,
+ jti TEXT NOT NULL UNIQUE,
+ expires_at TIMESTAMPTZ NOT NULL
+);
+
+CREATE INDEX idx_oauth2_jtis_expires_at ON oauth2_jtis (expires_at);
+
+CREATE TABLE interaction_sessions
+(
+ id UUID NOT NULL PRIMARY KEY,
+ created_at TIMESTAMPTZ NOT NULL,
+
+ consent_required BOOLEAN NOT NULL DEFAULT FALSE,
+ reauthentication_required BOOLEAN NOT NULL DEFAULT FALSE,
+ authentication_required BOOLEAN NOT NULL DEFAULT FALSE,
+ account_selection_required BOOLEAN NOT NULL DEFAULT FALSE,
+
+ scopes JSONB NOT NULL DEFAULT '[]',
+ client_id TEXT NOT NULL REFERENCES oidc_clients (id) ON DELETE CASCADE,
+ user_id UUID REFERENCES users (id) ON DELETE CASCADE,
+ requested_at TIMESTAMPTZ NOT NULL,
+ reauthenticated_at TIMESTAMPTZ,
+ parameters JSONB NOT NULL DEFAULT '{}'
+);
+
+CREATE INDEX idx_interaction_sessions_client_id
+ ON interaction_sessions (client_id);
+CREATE INDEX idx_interaction_sessions_user_id
+ ON interaction_sessions (user_id);
+
+-- Convert scope from string to json
+ALTER TABLE user_authorized_oidc_clients
+ ALTER COLUMN scope TYPE jsonb USING (
+ CASE
+ WHEN scope IS NULL OR btrim(scope) = '' THEN '[]'::jsonb
+ ELSE to_jsonb(array_remove(regexp_split_to_array(scope, '[[:space:]]+'), ''))
+ END
+ ),
+ ALTER COLUMN scope SET DEFAULT '[]'::jsonb,
+ ALTER COLUMN scope SET NOT NULL;
+
+DROP TABLE IF EXISTS oidc_pushed_authorization_requests;
+DROP TABLE IF EXISTS oidc_device_codes;
+DROP TABLE IF EXISTS oidc_refresh_tokens;
+DROP TABLE IF EXISTS oidc_authorization_codes;
diff --git a/backend/resources/migrations/sqlite/20260607120000_oauth2_storage.down.sql b/backend/resources/migrations/sqlite/20260607120000_oauth2_storage.down.sql
new file mode 100644
index 00000000..a99e2667
--- /dev/null
+++ b/backend/resources/migrations/sqlite/20260607120000_oauth2_storage.down.sql
@@ -0,0 +1,95 @@
+CREATE TABLE oidc_authorization_codes
+(
+ id TEXT NOT NULL PRIMARY KEY,
+ created_at INTEGER,
+ code TEXT NOT NULL UNIQUE,
+ scope TEXT NOT NULL,
+ nonce TEXT,
+ expires_at INTEGER NOT NULL,
+ user_id TEXT NOT NULL REFERENCES users ON DELETE CASCADE,
+ client_id TEXT NOT NULL REFERENCES oidc_clients ON DELETE CASCADE,
+ code_challenge TEXT,
+ code_challenge_method_sha256 NUMERIC,
+ authentication_method TEXT NOT NULL DEFAULT ''
+);
+
+CREATE INDEX idx_oidc_authorization_codes_expires_at ON oidc_authorization_codes (expires_at);
+
+CREATE TABLE oidc_refresh_tokens (
+ id TEXT NOT NULL PRIMARY KEY,
+ created_at INTEGER,
+ token TEXT NOT NULL UNIQUE,
+ expires_at INTEGER NOT NULL,
+ scope TEXT NOT NULL,
+ user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ client_id TEXT NOT NULL REFERENCES oidc_clients(id) ON DELETE CASCADE,
+ authentication_method TEXT NOT NULL DEFAULT '',
+ id_token_jti TEXT
+);
+
+CREATE INDEX idx_oidc_refresh_tokens_expires_at ON oidc_refresh_tokens (expires_at);
+CREATE INDEX idx_oidc_refresh_tokens_id_token_jti
+ ON oidc_refresh_tokens(user_id, client_id, id_token_jti);
+
+CREATE TABLE oidc_device_codes
+(
+ id TEXT NOT NULL PRIMARY KEY,
+ created_at INTEGER,
+ device_code TEXT NOT NULL UNIQUE,
+ user_code TEXT NOT NULL UNIQUE,
+ scope TEXT NOT NULL,
+ expires_at INTEGER NOT NULL,
+ is_authorized BOOLEAN NOT NULL DEFAULT FALSE,
+ user_id TEXT REFERENCES users ON DELETE CASCADE,
+ client_id TEXT NOT NULL REFERENCES oidc_clients ON DELETE CASCADE,
+ authentication_method TEXT NOT NULL DEFAULT '',
+ nonce TEXT
+);
+
+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);
+
+CREATE TABLE user_authorized_oidc_clients_new (
+ scope TEXT,
+ user_id TEXT NOT NULL REFERENCES users ON DELETE CASCADE,
+ client_id TEXT NOT NULL REFERENCES oidc_clients ON DELETE CASCADE,
+ last_used_at DATETIME NOT NULL,
+ PRIMARY KEY (user_id, client_id)
+);
+
+INSERT INTO user_authorized_oidc_clients_new (scope, user_id, client_id, last_used_at)
+SELECT CASE
+ WHEN scope IS NULL THEN NULL
+ WHEN json_valid(CAST(scope AS TEXT)) AND json_type(CAST(scope AS TEXT)) = 'array' THEN (
+ SELECT group_concat(value, ' ')
+ FROM json_each(CAST(scope AS TEXT))
+ )
+ ELSE CAST(scope AS TEXT)
+ END,
+ user_id,
+ client_id,
+ last_used_at
+FROM user_authorized_oidc_clients;
+
+DROP TABLE user_authorized_oidc_clients;
+ALTER TABLE user_authorized_oidc_clients_new RENAME TO user_authorized_oidc_clients;
+
+DROP INDEX IF EXISTS idx_interaction_sessions_client_id;
+DROP INDEX IF EXISTS idx_interaction_sessions_user_id;
+DROP TABLE IF EXISTS interaction_sessions;
+
+DROP INDEX IF EXISTS idx_oauth2_jtis_expires_at;
+DROP TABLE IF EXISTS oauth2_jtis;
+
+DROP INDEX IF EXISTS idx_oauth2_sessions_expires_at;
+DROP INDEX IF EXISTS idx_oauth2_sessions_kind_request;
+DROP INDEX IF EXISTS idx_oauth2_sessions_kind_key;
+DROP TABLE IF EXISTS oauth2_sessions;
diff --git a/backend/resources/migrations/sqlite/20260607120000_oauth2_storage.up.sql b/backend/resources/migrations/sqlite/20260607120000_oauth2_storage.up.sql
new file mode 100644
index 00000000..fe51d449
--- /dev/null
+++ b/backend/resources/migrations/sqlite/20260607120000_oauth2_storage.up.sql
@@ -0,0 +1,80 @@
+CREATE TABLE oauth2_sessions (
+ id TEXT NOT NULL PRIMARY KEY,
+ created_at INTEGER NOT NULL,
+ kind TEXT NOT NULL,
+ key TEXT NOT NULL,
+ request_id TEXT NOT NULL,
+ access_token_signature TEXT NOT NULL DEFAULT '',
+ active BOOLEAN NOT NULL DEFAULT TRUE,
+ request_data TEXT NOT NULL,
+ expires_at INTEGER
+);
+
+CREATE UNIQUE INDEX idx_oauth2_sessions_kind_key ON oauth2_sessions (kind, key);
+CREATE INDEX idx_oauth2_sessions_kind_request ON oauth2_sessions (kind, request_id);
+CREATE INDEX idx_oauth2_sessions_expires_at ON oauth2_sessions (expires_at);
+
+CREATE TABLE oauth2_jtis (
+ id TEXT NOT NULL PRIMARY KEY,
+ created_at INTEGER NOT NULL,
+ jti TEXT NOT NULL UNIQUE,
+ expires_at INTEGER NOT NULL
+);
+
+CREATE INDEX idx_oauth2_jtis_expires_at ON oauth2_jtis (expires_at);
+
+CREATE TABLE interaction_sessions (
+ id TEXT NOT NULL PRIMARY KEY,
+ created_at INTEGER NOT NULL,
+ consent_required BOOLEAN NOT NULL DEFAULT FALSE,
+ reauthentication_required BOOLEAN NOT NULL DEFAULT FALSE,
+ authentication_required BOOLEAN NOT NULL DEFAULT FALSE,
+ account_selection_required BOOLEAN NOT NULL DEFAULT FALSE,
+ scopes TEXT NOT NULL DEFAULT '[]',
+ client_id TEXT NOT NULL REFERENCES oidc_clients(id) ON DELETE CASCADE,
+ user_id TEXT REFERENCES users(id) ON DELETE CASCADE,
+ requested_at INTEGER NOT NULL,
+ reauthenticated_at INTEGER,
+ parameters TEXT NOT NULL DEFAULT '{}'
+);
+
+CREATE INDEX idx_interaction_sessions_client_id ON interaction_sessions (client_id);
+CREATE INDEX idx_interaction_sessions_user_id ON interaction_sessions (user_id);
+
+CREATE TABLE user_authorized_oidc_clients_new (
+ scope BLOB NOT NULL DEFAULT X'5B5D',
+ user_id TEXT NOT NULL REFERENCES users ON DELETE CASCADE,
+ client_id TEXT NOT NULL REFERENCES oidc_clients ON DELETE CASCADE,
+ last_used_at DATETIME NOT NULL,
+ PRIMARY KEY (user_id, client_id)
+);
+
+-- Convert scope from string to json
+INSERT INTO user_authorized_oidc_clients_new (scope, user_id, client_id, last_used_at)
+SELECT CAST(CASE
+ WHEN scope IS NULL OR trim(CAST(scope AS TEXT)) = '' THEN '[]'
+ ELSE (
+ WITH RECURSIVE split(value, rest) AS (
+ SELECT '', trim(CAST(scope AS TEXT)) || ' '
+ UNION ALL
+ SELECT substr(rest, 0, instr(rest, ' ')), ltrim(substr(rest, instr(rest, ' ') + 1))
+ FROM split
+ WHERE rest <> ''
+ )
+ SELECT json_group_array(value)
+ FROM split
+ WHERE value <> ''
+ )
+ END AS BLOB),
+ user_id,
+ client_id,
+ last_used_at
+FROM user_authorized_oidc_clients;
+
+DROP TABLE user_authorized_oidc_clients;
+ALTER TABLE user_authorized_oidc_clients_new RENAME TO user_authorized_oidc_clients;
+
+DROP TABLE IF EXISTS oidc_pushed_authorization_requests;
+DROP TABLE IF EXISTS oidc_device_codes;
+DROP TABLE IF EXISTS oidc_refresh_tokens;
+DROP TABLE IF EXISTS oidc_authorization_codes;
diff --git a/frontend/messages/en.json b/frontend/messages/en.json
index 3af1742b..ea6ed82b 100644
--- a/frontend/messages/en.json
+++ b/frontend/messages/en.json
@@ -55,7 +55,6 @@
"sign_in_to": "Sign in to {name}",
"account_selection_signin_confirmation": "Do you want to use the following account to continue to {name}?",
"use_a_different_account": "Use a different account",
- "client_not_found": "Client not found",
"client_wants_to_access_the_following_information": "{client} 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 {client} with your {appName} account?",
"email": "Email",
@@ -290,7 +289,7 @@
"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.",
+ "requires_pushed_authorization_requests_description": "Requires clients to use the PAR endpoint to pre-register authorization parameters before initiating the flow.",
"name_logo": "{name} logo",
"change_logo": "Change Logo",
"upload_logo": "Upload Logo",
@@ -529,5 +528,7 @@
"email_verification_sent": "Verification email sent successfully.",
"emails_verified_by_default": "Emails verified by default",
"emails_verified_by_default_description": "When enabled, users' email addresses will be marked as verified by default upon signup or when their email address is changed.",
- "user_has_no_passkeys_yet": "This user has no passkeys yet."
+ "user_has_no_passkeys_yet": "This user has no passkeys yet.",
+ "replay_protection": "Replay Protection",
+ "replay_protection_description": "If enabled the provided token can only be used once. If your provider uses the same token multiple times, you may need to disable this option."
}
diff --git a/frontend/src/lib/components/header/header.svelte b/frontend/src/lib/components/header/header.svelte
index be887162..f9f4e4de 100644
--- a/frontend/src/lib/components/header/header.svelte
+++ b/frontend/src/lib/components/header/header.svelte
@@ -8,7 +8,7 @@
import ModeSwitcher from './mode-switcher.svelte';
const authUrls = [
- /^\/authorize$/,
+ /^\/interaction$/,
/^\/device$/,
/^\/login(?:\/.*)?$/,
/^\/logout$/,
diff --git a/frontend/src/lib/components/scope-list.svelte b/frontend/src/lib/components/scope-list.svelte
index 9260ec0d..fadeb6f3 100644
--- a/frontend/src/lib/components/scope-list.svelte
+++ b/frontend/src/lib/components/scope-list.svelte
@@ -4,21 +4,21 @@
import { LucideMail, LucideUser, LucideUsers } from '@lucide/svelte';
import ScopeItem from './scope-item.svelte';
- let { scope }: { scope: string } = $props();
+ let { scopes }: { scopes: string[] } = $props();
{m.client_not_found()}
- {errorMessage}.
-
-
-
- {primaryName}
-
- {$userStore.email}
-
-
- {m.sign_in_to({ name: client.name })}
-
- {#if errorMessage}
-
{m.the_device_has_been_authorized()}
+ {:else if reauthenticationRequired && deviceInfo?.client} +
+
+ {#if errorMessage}
+ {errorMessage}.
+ {:else if currentStep == 'select_account' && $userStore}
+
+ {primaryName} +
+ {#if fullName && $userStore.email} ++ {$userStore.email} +
+ {/if} +
+