refactor: use fosite for OAuth 2.0 logic (#1520)

This commit is contained in:
Elias Schneider
2026-06-22 18:42:02 +02:00
committed by GitHub
parent dbbe2a403a
commit 8158452b37
100 changed files with 8721 additions and 5718 deletions

View File

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

View File

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

View File

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

View File

@@ -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

View File

@@ -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=

View File

@@ -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.

View File

@@ -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)
}

View File

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

View File

@@ -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 }

View File

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

View File

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

View File

@@ -1,264 +0,0 @@
package controller
import (
"context"
"encoding/base64"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"time"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/pocket-id/pocket-id/backend/internal/common"
"github.com/pocket-id/pocket-id/backend/internal/dto"
"github.com/pocket-id/pocket-id/backend/internal/service"
)
func TestCreateTokensHandler(t *testing.T) {
createTestContext := func(t *testing.T, rawURL string, form url.Values, authHeader string, noCT bool) (*gin.Context, *httptest.ResponseRecorder) {
t.Helper()
mode := gin.Mode()
gin.SetMode(gin.TestMode)
t.Cleanup(func() { gin.SetMode(mode) })
recorder := httptest.NewRecorder()
c, _ := gin.CreateTestContext(recorder)
req, err := http.NewRequestWithContext(t.Context(), http.MethodPost, rawURL, strings.NewReader(form.Encode()))
require.NoError(t, err)
if !noCT {
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
}
if authHeader != "" {
req.Header.Set("Authorization", authHeader)
}
c.Request = req
return c, recorder
}
t.Run("Ignores Query String Parameters For Binding", func(t *testing.T) {
oc := &OidcController{}
c, _ := createTestContext(
t,
"http://example.com/oidc/token?grant_type=refresh_token&refresh_token=query-value",
url.Values{},
"",
false,
)
oc.createTokensHandler(c)
require.Len(t, c.Errors, 1)
assert.Contains(t, c.Errors[0].Err.Error(), "GrantType")
})
t.Run("Missing Authorization Code", func(t *testing.T) {
oc := &OidcController{}
c, _ := createTestContext(
t,
"http://example.com/oidc/token",
url.Values{
"grant_type": {service.GrantTypeAuthorizationCode},
},
"",
false,
)
oc.createTokensHandler(c)
require.Len(t, c.Errors, 1)
var missingCodeErr *common.OidcMissingAuthorizationCodeError
require.ErrorAs(t, c.Errors[0].Err, &missingCodeErr)
})
t.Run("Missing Refresh Token", func(t *testing.T) {
oc := &OidcController{}
c, _ := createTestContext(
t,
"http://example.com/oidc/token",
url.Values{
"grant_type": {service.GrantTypeRefreshToken},
},
"",
false,
)
oc.createTokensHandler(c)
require.Len(t, c.Errors, 1)
var missingRefreshErr *common.OidcMissingRefreshTokenError
require.ErrorAs(t, c.Errors[0].Err, &missingRefreshErr)
})
t.Run("Uses Basic Auth Credentials When Body Credentials Missing", func(t *testing.T) {
var capturedInput dto.OidcCreateTokensDto
oc := &OidcController{
createTokens: func(_ context.Context, input dto.OidcCreateTokensDto) (service.CreatedTokens, error) {
capturedInput = input
return service.CreatedTokens{
AccessToken: "access-token",
IdToken: "id-token",
RefreshToken: "refresh-token",
ExpiresIn: 2 * time.Minute,
}, nil
},
}
basicAuth := "Basic " + base64.StdEncoding.EncodeToString([]byte("client-id:client-secret"))
c, recorder := createTestContext(
t,
"http://example.com/oidc/token",
url.Values{
"grant_type": {service.GrantTypeRefreshToken},
"refresh_token": {"input-refresh-token"},
},
basicAuth,
false,
)
oc.createTokensHandler(c)
require.Empty(t, c.Errors)
assert.Equal(t, "client-id", capturedInput.ClientID)
assert.Equal(t, "client-secret", capturedInput.ClientSecret)
assert.Equal(t, "input-refresh-token", capturedInput.RefreshToken)
require.Equal(t, http.StatusOK, recorder.Code)
var response dto.OidcTokenResponseDto
require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &response))
assert.Equal(t, "access-token", response.AccessToken)
assert.Equal(t, "Bearer", response.TokenType)
assert.Equal(t, "id-token", response.IdToken)
assert.Equal(t, "refresh-token", response.RefreshToken)
assert.Equal(t, 120, response.ExpiresIn)
})
t.Run("Uses Basic Auth Secret When Body Has Only Client ID (PKCE)", func(t *testing.T) {
// Some OIDC libraries (e.g. jumbojett/openid-connect-php) combine client_secret_basic with PKCE by sending client_id in the body alongside code_verifier while keeping the secret only in the Authorization header
// RFC 6749 §2.3.1 permits this; the body client_id must not block the Basic auth fallback for the secret.
var capturedInput dto.OidcCreateTokensDto
oc := &OidcController{
createTokens: func(_ context.Context, input dto.OidcCreateTokensDto) (service.CreatedTokens, error) {
capturedInput = input
return service.CreatedTokens{
AccessToken: "access-token",
IdToken: "id-token",
RefreshToken: "refresh-token",
ExpiresIn: 2 * time.Minute,
}, nil
},
}
basicAuth := "Basic " + base64.StdEncoding.EncodeToString([]byte("client-id:client-secret"))
c, recorder := createTestContext(
t,
"http://example.com/oidc/token",
url.Values{
"grant_type": {service.GrantTypeRefreshToken},
"refresh_token": {"input-refresh-token"},
"client_id": {"client-id"},
},
basicAuth,
false,
)
oc.createTokensHandler(c)
require.Empty(t, c.Errors)
assert.Equal(t, "client-id", capturedInput.ClientID)
assert.Equal(t, "client-secret", capturedInput.ClientSecret)
require.Equal(t, http.StatusOK, recorder.Code)
})
t.Run("Maps Authorization Pending Error", func(t *testing.T) {
oc := &OidcController{
createTokens: func(context.Context, dto.OidcCreateTokensDto) (service.CreatedTokens, error) {
return service.CreatedTokens{}, &common.OidcAuthorizationPendingError{}
},
}
c, recorder := createTestContext(
t,
"http://example.com/oidc/token",
url.Values{
"grant_type": {service.GrantTypeRefreshToken},
"refresh_token": {"input-refresh-token"},
},
"",
false,
)
oc.createTokensHandler(c)
require.Empty(t, c.Errors)
require.Equal(t, http.StatusBadRequest, recorder.Code)
var response map[string]string
require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &response))
assert.Equal(t, "authorization_pending", response["error"])
})
t.Run("Maps Slow Down Error", func(t *testing.T) {
oc := &OidcController{
createTokens: func(context.Context, dto.OidcCreateTokensDto) (service.CreatedTokens, error) {
return service.CreatedTokens{}, &common.OidcSlowDownError{}
},
}
c, recorder := createTestContext(
t,
"http://example.com/oidc/token",
url.Values{
"grant_type": {service.GrantTypeRefreshToken},
"refresh_token": {"input-refresh-token"},
},
"",
false,
)
oc.createTokensHandler(c)
require.Empty(t, c.Errors)
require.Equal(t, http.StatusBadRequest, recorder.Code)
var response map[string]string
require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &response))
assert.Equal(t, "slow_down", response["error"])
})
t.Run("Returns Generic Service Error In Context", func(t *testing.T) {
expectedErr := errors.New("boom")
oc := &OidcController{
createTokens: func(context.Context, dto.OidcCreateTokensDto) (service.CreatedTokens, error) {
return service.CreatedTokens{}, expectedErr
},
}
c, _ := createTestContext(
t,
"http://example.com/oidc/token",
url.Values{
"grant_type": {service.GrantTypeRefreshToken},
"refresh_token": {"input-refresh-token"},
},
"",
false,
)
oc.createTokensHandler(c)
require.Len(t, c.Errors, 1)
assert.ErrorIs(t, c.Errors[0].Err, expectedErr)
})
}

View File

@@ -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)
}

View File

@@ -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,
}

View File

@@ -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 {

View File

@@ -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
}

View File

@@ -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))
})
}
}

View File

@@ -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.

View File

@@ -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
}

View File

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

View File

@@ -1,11 +1,8 @@
package middleware
import (
"crypto/rand"
"encoding/base64"
"strings"
"github.com/gin-gonic/gin"
"github.com/pocket-id/pocket-id/backend/internal/utils"
)
// CspMiddleware sets a Content Security Policy header and, when possible,
@@ -16,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...)
}

View File

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

View File

@@ -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)
}

View File

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

View File

@@ -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)
}

View File

@@ -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
}

View File

@@ -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
}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,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)
}

View File

@@ -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,
}
}

View File

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

View File

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

View File

@@ -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
}

View File

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

View File

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

View File

@@ -0,0 +1,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)
})
}

View File

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

View File

@@ -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())
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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"])
}

View File

@@ -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)
}

View File

@@ -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)
}

View File

@@ -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)
}

View File

@@ -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)
}

View File

@@ -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
}
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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)
}

View File

@@ -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
})
}

View File

@@ -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)
}

View File

@@ -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,
}
}

View File

@@ -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)
}

View File

@@ -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"])
})
}

View File

@@ -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
}

View File

@@ -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)
}

View File

@@ -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"`)
})
}

View File

@@ -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()

View File

@@ -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
}

View File

@@ -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()

File diff suppressed because it is too large Load Diff

View File

@@ -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)
})
}
}

View File

@@ -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)))
}

View File

@@ -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) {

View File

@@ -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)
}

View File

@@ -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:

View File

@@ -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 {

View File

@@ -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)
}

View File

@@ -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"
}
}

View File

@@ -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)
}

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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 <b>{name}</b>?",
"use_a_different_account": "Use a different account",
"client_not_found": "Client not found",
"client_wants_to_access_the_following_information": "<b>{client}</b> wants to access the following information:",
"do_you_want_to_sign_in_to_client_with_your_app_name_account": "Do you want to sign in to <b>{client}</b> with your {appName} account?",
"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."
}

View File

@@ -8,7 +8,7 @@
import ModeSwitcher from './mode-switcher.svelte';
const authUrls = [
/^\/authorize$/,
/^\/interaction$/,
/^\/device$/,
/^\/login(?:\/.*)?$/,
/^\/logout$/,

View File

@@ -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();
</script>
<Item.Group data-testid="scopes">
{#if scope!.includes('email')}
{#if scopes.includes('email')}
<ScopeItem icon={LucideMail} name={m.email()} description={m.view_your_email_address()} />
{/if}
{#if scope!.includes('profile')}
{#if scopes.includes('profile')}
<ScopeItem
icon={LucideUser}
name={m.profile()}
description={m.view_your_profile_information()}
/>
{/if}
{#if scope!.includes('groups')}
{#if scopes.includes('groups')}
<ScopeItem
icon={LucideUsers}
name={m.groups()}

View File

@@ -1,80 +1,33 @@
import type { ListRequestOptions, Paginated } from '$lib/types/list-request.type';
import type {
AccessibleOidcClient,
AuthorizeCallbackResponse,
AuthorizeResponse,
CompleteInteractionResponse,
InteractionSession,
InteractionStep,
OidcClient,
OidcClientCreate,
OidcClientMetaData,
OidcClientUpdate,
OidcClientWithAllowedUserGroups,
OidcClientWithAllowedUserGroupsCount,
OidcDeviceCodeInfo,
OidcAuthorizeRequestInfo
OidcDeviceCodeInfo
} from '$lib/types/oidc.type';
import type { ScimServiceProvider } from '$lib/types/scim.type';
import { cachedOidcClientLogo } from '$lib/utils/cached-image-util';
import APIService from './api-service';
class OidcService extends APIService {
authorize = async (
clientId: string,
scope: string,
callbackURL: string,
nonce?: string,
codeChallenge?: string,
codeChallengeMethod?: string,
reauthenticationToken?: string,
responseMode?: string,
prompt?: string,
requestURI?: string
) => {
const res = await this.api.post('/oidc/authorize', {
scope,
nonce,
callbackURL,
clientId,
codeChallenge,
codeChallengeMethod,
reauthenticationToken,
responseMode,
prompt,
requestURI
});
return res.data as AuthorizeResponse;
getAuthorizeInteraction = async (id: string) => {
const { data } = await this.api.get<InteractionSession>(`/oidc/interactions/${id}`);
return data;
};
resolveAuthorizeCallbackURL = async (
clientId: string,
callbackURL: string,
requestURI?: string
) => {
const res = await this.api.post('/oidc/authorize/callback-url', {
clientId,
callbackURL,
requestURI
});
return res.data as AuthorizeCallbackResponse;
};
isAuthorizationRequired = async (clientId: string, scope: string, requestURI?: string) => {
const res = await this.api.post('/oidc/authorization-required', {
scope,
clientId,
requestURI
});
return res.data as { authorizationRequired: boolean; scope: string };
};
getParRequestInfo = async (clientId: string, requestURI: string) => {
const res = await this.api.get('/oidc/par-request-info', {
params: { client_id: clientId, request_uri: requestURI }
});
return res.data as OidcAuthorizeRequestInfo;
completeAuthorizeInteractionStep = async (id: string, step: InteractionStep) => {
const { data } = await this.api.post<CompleteInteractionResponse>(
`/oidc/interactions/${id}/complete`,
{ step }
);
return data;
};
listClients = async (options?: ListRequestOptions) => {

View File

@@ -1,8 +1,9 @@
import userStore from '$lib/stores/user-store';
import type { Passkey } from '$lib/types/passkey.type';
import type { User } from '$lib/types/user.type';
import APIService from './api-service';
import userStore from '$lib/stores/user-store';
import type { AuthenticationResponseJSON, RegistrationResponseJSON } from '@simplewebauthn/browser';
import APIService from './api-service';
class WebAuthnService extends APIService {
getRegistrationOptions = async () => (await this.api.get(`/webauthn/register/start`)).data;
@@ -30,8 +31,7 @@ class WebAuthnService extends APIService {
};
reauthenticate = async (body?: AuthenticationResponseJSON) => {
const res = await this.api.post('/webauthn/reauthenticate', body);
return res.data.reauthenticationToken as string;
await this.api.post('/webauthn/reauthenticate', body);
};
}

View File

@@ -14,6 +14,7 @@ export type OidcClientFederatedIdentity = {
subject?: string;
audience?: string;
jwks?: string | undefined;
replayProtection: boolean;
};
export type OidcClientCredentials = {
@@ -57,32 +58,27 @@ export type OidcClientCreateWithLogo = OidcClientCreate & {
};
export type OidcDeviceCodeInfo = {
scope: string;
scope: string[];
authorizationRequired: boolean;
reauthenticationRequired: boolean;
client: OidcClientMetaData;
};
export type AuthorizeResponse = {
code?: string;
callbackURL?: string;
issuer?: string;
error?: string;
requiresRedirect?: boolean;
};
export type AuthorizeCallbackResponse = {
callbackURL: string;
};
export type AccessibleOidcClient = OidcClientMetaData & {
lastUsedAt: Date | null;
};
export type OidcAuthorizeRequestInfo = {
scope: string;
redirectURI: string;
state?: string;
nonce?: string;
responseMode?: string;
prompt?: string;
export type InteractionStep = 'authenticate' | 'select_account' | 'reauthenticate' | 'consent';
export type InteractionSession = {
id: string;
scopes: string[];
client: OidcClientMetaData;
currentStep?: InteractionStep;
requiredSteps: InteractionStep[];
};
export type CompleteInteractionResponse = {
interaction?: InteractionSession;
redirectUrl?: string;
};

View File

@@ -127,7 +127,7 @@ export function createForm<T extends z.ZodType<any, any>>(schema: T, initialValu
return fieldSchema.minLength !== null && fieldSchema.minLength > 0;
}
// Handle unions like callbackUrlSchema
// Handle unions
if (fieldSchema instanceof z.ZodUnion) {
return !fieldSchema.def.options.some((o: any) => {
return o.def.type == 'optional';

View File

@@ -18,7 +18,7 @@ export function getAuthRedirectPath(url: URL, user: User | null) {
const isPublicPath =
path.startsWith('/lc/') ||
['/authorize', '/login/alternative/code', '/device', '/health', '/healthz'].includes(path);
['/interaction', '/login/alternative/code', '/device', '/health', '/healthz'].includes(path);
const isAdminPath = path == '/settings/admin' || path.startsWith('/settings/admin/');

View File

@@ -14,6 +14,9 @@ export const callbackUrlSchema = z
.nonempty()
.refine(
(val) => {
if (/^(javascript|data):/i.test(val)) {
return false;
}
if (val.includes('*')) {
return true;
}

View File

@@ -1,374 +0,0 @@
<script lang="ts">
import { invalidateAll } from '$app/navigation';
import FormattedMessage from '$lib/components/formatted-message.svelte';
import SignInWrapper from '$lib/components/login-wrapper.svelte';
import ScopeList from '$lib/components/scope-list.svelte';
import * as Avatar from '$lib/components/ui/avatar';
import { Button } from '$lib/components/ui/button';
import * as Card from '$lib/components/ui/card';
import { m } from '$lib/paraglide/messages';
import OidcService from '$lib/services/oidc-service';
import WebAuthnService from '$lib/services/webauthn-service';
import appConfigStore from '$lib/stores/application-configuration-store';
import userStore from '$lib/stores/user-store';
import { cachedProfilePicture } from '$lib/utils/cached-image-util';
import { getAxiosErrorMessage, getWebauthnErrorMessage } from '$lib/utils/error-util';
import { startAuthentication, type AuthenticationResponseJSON } from '@simplewebauthn/browser';
import { onMount } from 'svelte';
import { slide } from 'svelte/transition';
import type { PageProps } from './$types';
import ClientProviderImages from './components/client-provider-images.svelte';
const webauthnService = new WebAuthnService();
const oidService = new OidcService();
let { data }: PageProps = $props();
let {
client,
callbackURL,
nonce,
codeChallenge,
codeChallengeMethod,
authorizeState,
prompt,
responseMode,
requestURI
} = data;
let scope = $state(data.scope);
let isLoading = $state(false);
let success = $state(false);
let errorMessage: string | null = $state(null);
let authorizationRequired = $state(false);
let authorizationConfirmed = $state(false);
let accountSelectionRequired = $state(false);
let userSignedInAt: Date | undefined;
const fullName = $derived.by(() => {
if (!$userStore) {
return '';
}
if ($userStore.displayName) {
return $userStore.displayName;
}
return [$userStore.firstName, $userStore.lastName].filter(Boolean).join(' ').trim();
});
const primaryName = $derived(fullName || $userStore?.email || '');
// Parse prompt parameter once (space-delimited per OIDC spec)
const promptValues = prompt ? prompt.split(' ') : [];
const hasPromptNone = promptValues.includes('none');
const hasPromptConsent = promptValues.includes('consent');
const hasPromptLogin = promptValues.includes('login');
const hasPromptSelectAccount = promptValues.includes('select_account');
onMount(() => {
void handleInitialAuthorization();
});
async function handleInitialAuthorization() {
// Conflicting prompt values - none can't be combined with any interactive prompt
if (hasPromptNone && (hasPromptConsent || hasPromptLogin || hasPromptSelectAccount)) {
await redirectWithError('interaction_required');
return;
}
// If prompt=none and user is not signed in, redirect immediately with login_required
if (hasPromptNone && !$userStore) {
await redirectWithError('login_required');
return;
}
// prompt=select_account: if the user is already signed in, pause so they can
// confirm the current account before proceeding. If they're not signed in,
// the normal login flow below is selection enough.
if (hasPromptSelectAccount && $userStore) {
accountSelectionRequired = true;
return;
}
if ($userStore) {
await authorize();
}
}
async function useDifferentAccount() {
try {
await webauthnService.logout();
} finally {
await invalidateAll();
}
}
async function authorize() {
isLoading = true;
let authResponse: AuthenticationResponseJSON | undefined;
try {
if (!$userStore?.id) {
const loginOptions = await webauthnService.getLoginOptions();
authResponse = await startAuthentication({ optionsJSON: loginOptions });
const user = await webauthnService.finishLogin(authResponse);
userStore.setUser(user);
userSignedInAt = new Date();
}
if (!authorizationConfirmed) {
const authRequired = await oidService.isAuthorizationRequired(
client!.id,
scope,
requestURI
);
authorizationRequired = authRequired.authorizationRequired;
if (requestURI) {
scope = authRequired.scope;
}
// If prompt=consent, always show consent UI
if (hasPromptConsent) {
authorizationRequired = true;
}
// If prompt=none and consent required, redirect with error
if (hasPromptNone && authorizationRequired) {
await redirectWithError('consent_required');
return;
}
if (authorizationRequired) {
isLoading = false;
authorizationConfirmed = true;
return;
}
}
let reauthToken: string | undefined;
if (client?.requiresReauthentication || hasPromptLogin) {
let authResponse;
const signedInRecently =
userSignedInAt && userSignedInAt.getTime() > Date.now() - 60 * 1000;
if (!signedInRecently) {
const loginOptions = await webauthnService.getLoginOptions();
authResponse = await startAuthentication({ optionsJSON: loginOptions });
}
reauthToken = await webauthnService.reauthenticate(authResponse);
}
const result = await oidService.authorize(
client!.id,
scope,
callbackURL,
nonce,
codeChallenge,
codeChallengeMethod,
reauthToken,
responseMode,
prompt,
requestURI
);
// Check if backend returned a redirect error
if (result.requiresRedirect && result.error) {
if (hasPromptNone) {
await redirectWithError(result.error, result.callbackURL);
} else {
errorMessage = result.error;
isLoading = false;
}
return;
}
onSuccess(result.code!, result.callbackURL!, result.issuer!);
} catch (e) {
errorMessage = getWebauthnErrorMessage(e);
isLoading = false;
}
}
async function redirectWithError(error: string, validatedCallbackURL?: string) {
isLoading = true;
try {
const safeCallbackURL =
validatedCallbackURL ||
(await oidService.resolveAuthorizeCallbackURL(client!.id, callbackURL, requestURI))
.callbackURL;
const redirectURL = new URL(safeCallbackURL);
if (redirectURL.protocol == 'javascript:' || redirectURL.protocol == 'data:') {
throw new Error('Invalid redirect URL protocol');
}
window.location.href = createRedirectURL(safeCallbackURL, {
error,
state: authorizeState
});
} catch (e) {
errorMessage = getAxiosErrorMessage(e);
isLoading = false;
}
}
function onSuccess(code: string, callbackURL: string, issuer: string) {
const redirectURL = new URL(callbackURL);
if (redirectURL.protocol == 'javascript:' || redirectURL.protocol == 'data:') {
throw new Error('Invalid redirect URL protocol');
}
success = true;
setTimeout(() => {
if (responseMode === 'form_post') {
// Create a hidden form and submit it via POST
const form = document.createElement('form');
form.method = 'POST';
form.action = callbackURL;
// Add code parameter
const codeInput = document.createElement('input');
codeInput.type = 'hidden';
codeInput.name = 'code';
codeInput.value = code;
form.appendChild(codeInput);
// Add state parameter
if (authorizeState) {
const stateInput = document.createElement('input');
stateInput.type = 'hidden';
stateInput.name = 'state';
stateInput.value = authorizeState;
form.appendChild(stateInput);
}
// Add issuer parameter
const issInput = document.createElement('input');
issInput.type = 'hidden';
issInput.name = 'iss';
issInput.value = issuer;
form.appendChild(issInput);
document.body.appendChild(form);
form.submit();
} else {
window.location.href = createRedirectURL(callbackURL, {
code,
state: authorizeState,
iss: issuer
});
}
}, 1000);
}
function createRedirectURL(url: string, params: Record<string, string>) {
const redirectURL = new URL(url);
const responseParams =
responseMode === 'fragment'
? new URLSearchParams(redirectURL.hash.slice(1))
: redirectURL.searchParams;
for (const [key, value] of Object.entries(params)) {
if (value) {
responseParams.set(key, value);
}
}
if (responseMode === 'fragment') {
redirectURL.hash = responseParams.toString();
}
return redirectURL.toString();
}
</script>
<svelte:head>
<title>{m.sign_in_to({ name: client.name })}</title>
</svelte:head>
{#if client == null}
<p>{m.client_not_found()}</p>
{:else}
<SignInWrapper showAlternativeSignInMethodButton={$userStore == null}>
<ClientProviderImages {client} {success} error={!!errorMessage} />
<h1 class="font-gloock mt-5 text-3xl font-bold sm:text-4xl">
{m.sign_in_to({ name: client.name })}
</h1>
{#if errorMessage}
<p class="text-muted-foreground mt-2 mb-10">
{errorMessage}.
</p>
{/if}
{#if authorizationRequired}
<div class="w-full max-w-md" transition:slide={{ duration: 300 }}>
<Card.Root class="mt-6 mb-10">
<Card.Header>
<p class="text-muted-foreground text-start">
<FormattedMessage
m={m.client_wants_to_access_the_following_information({ client: client.name })}
/>
</p>
</Card.Header>
<Card.Content>
<ScopeList {scope} />
</Card.Content>
</Card.Root>
</div>
{:else if accountSelectionRequired && $userStore && !errorMessage}
<div transition:slide={{ duration: 300 }} class="flex flex-col items-center">
<p class="text-muted-foreground mt-2 mb-8">
<FormattedMessage m={m.account_selection_signin_confirmation({ name: client.name })} />
</p>
<Card.Root class="mb-2 py-4 w-sm" data-testid="account-selection">
<Card.Content class="flex items-center gap-4">
<Avatar.Root class="size-11 shrink-0">
<Avatar.Image src={cachedProfilePicture.getUrl($userStore.id)} />
</Avatar.Root>
<div class="flex min-w-0 flex-col text-start">
<p class="truncate text-base leading-tight font-medium">
{primaryName}
</p>
{#if fullName && $userStore.email}
<p class="text-muted-foreground mt-1 truncate text-sm leading-tight">
{$userStore.email}
</p>
{/if}
</div>
</Card.Content>
</Card.Root>
<div class="mb-10 flex justify-center">
<button
type="button"
class="text-muted-foreground text-xs transition-colors hover:underline"
onclick={useDifferentAccount}
>
{m.use_a_different_account()}
</button>
</div>
</div>
{:else if !authorizationRequired && !errorMessage}
<p class="text-muted-foreground mt-2 mb-10">
<FormattedMessage
m={m.do_you_want_to_sign_in_to_client_with_your_app_name_account({
client: client.name,
appName: $appConfigStore.appName
})}
/>
</p>
{/if}
<!-- Flex flow is reversed so the sign in button, which has auto-focus, is the first one in the DOM, for a11y -->
<div class="flex w-full max-w-md flex-row-reverse gap-2">
{#if !errorMessage}
<Button class="flex-1" {isLoading} onclick={authorize} autofocus={true}>
{m.sign_in()}
</Button>
{:else}
<Button class="flex-1" onclick={() => (errorMessage = null)}>
{m.try_again()}
</Button>
{/if}
<Button href={document.referrer || '/'} class="flex-1" variant="secondary">
{m.cancel()}
</Button>
</div>
</SignInWrapper>
{/if}

View File

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

View File

@@ -8,9 +8,10 @@
import { m } from '$lib/paraglide/messages';
import OIDCService from '$lib/services/oidc-service';
import WebAuthnService from '$lib/services/webauthn-service';
import appConfigStore from '$lib/stores/application-configuration-store';
import userStore from '$lib/stores/user-store';
import type { OidcDeviceCodeInfo } from '$lib/types/oidc.type';
import { getAxiosErrorMessage } from '$lib/utils/error-util';
import { getWebauthnErrorMessage } from '$lib/utils/error-util';
import { preventDefault } from '$lib/utils/event-util';
import { startAuthentication } from '@simplewebauthn/browser';
import { onMount } from 'svelte';
@@ -29,6 +30,8 @@
let success = $state(false);
let errorMessage: string | null = $state(null);
let authorizationRequired = $state(false);
let reauthenticationRequired = $state(false);
let reauthenticated = $state(false);
onMount(() => {
if (data.code && $userStore) {
@@ -56,15 +59,36 @@
return;
}
if (info.reauthenticationRequired && !reauthenticationRequired && !authorizationRequired) {
reauthenticationRequired = true;
isLoading = false;
return;
}
if (info.reauthenticationRequired && !reauthenticated) {
await reauthenticate();
reauthenticated = true;
}
await oidcService.verifyDeviceCode(userCode);
success = true;
} catch (e) {
errorMessage = getAxiosErrorMessage(e);
errorMessage = getWebauthnErrorMessage(e);
} finally {
isLoading = false;
}
}
async function reauthenticate() {
try {
await webauthnService.reauthenticate();
} catch {
const loginOptions = await webauthnService.getLoginOptions();
const authResponse = await startAuthentication({ optionsJSON: loginOptions });
await webauthnService.reauthenticate(authResponse);
}
}
</script>
<svelte:head>
@@ -86,6 +110,15 @@
</p>
{:else if success}
<p class="text-muted-foreground mt-2">{m.the_device_has_been_authorized()}</p>
{:else if reauthenticationRequired && deviceInfo?.client}
<p class="text-muted-foreground mt-2">
<FormattedMessage
m={m.do_you_want_to_sign_in_to_client_with_your_app_name_account({
client: deviceInfo.client.name,
appName: $appConfigStore.appName
})}
/>
</p>
{:else if authorizationRequired}
<div class="w-full max-w-[450px]" transition:slide={{ duration: 300 }}>
<Card.Root class="mt-6">
@@ -99,7 +132,7 @@
</p>
</Card.Header>
<Card.Content data-testid="scopes">
<ScopeList scope={deviceInfo!.scope} />
<ScopeList scopes={deviceInfo!.scope} />
</Card.Content>
</Card.Root>
</div>

View File

@@ -1,7 +1,7 @@
import type { PageLoad } from './$types';
export const load: PageLoad = async ({ url }) => {
const code = url.searchParams.get('code');
const code = url.searchParams.get('user_code');
return {
code

View File

@@ -0,0 +1,197 @@
<script lang="ts">
import { invalidateAll } from '$app/navigation';
import FormattedMessage from '$lib/components/formatted-message.svelte';
import SignInWrapper from '$lib/components/login-wrapper.svelte';
import ScopeList from '$lib/components/scope-list.svelte';
import * as Avatar from '$lib/components/ui/avatar';
import { Button } from '$lib/components/ui/button';
import * as Card from '$lib/components/ui/card';
import { m } from '$lib/paraglide/messages';
import OidcService from '$lib/services/oidc-service';
import WebAuthnService from '$lib/services/webauthn-service';
import appConfigStore from '$lib/stores/application-configuration-store';
import userStore from '$lib/stores/user-store';
import type { InteractionStep } from '$lib/types/oidc.type';
import { cachedProfilePicture } from '$lib/utils/cached-image-util';
import { getWebauthnErrorMessage } from '$lib/utils/error-util';
import { startAuthentication } from '@simplewebauthn/browser';
import { slide } from 'svelte/transition';
import ClientProviderImages from '../authorize/components/client-provider-images.svelte';
import type { PageProps } from './$types';
const webauthnService = new WebAuthnService();
const oidcService = new OidcService();
let { data }: PageProps = $props();
let interactionSession = $state(data.interactionSession);
let isLoading = $state(false);
let success = $state(false);
let errorMessage: string | null = $state(null);
let currentStep = $derived(interactionSession.currentStep);
const fullName = $derived.by(() => {
if (!$userStore) {
return '';
}
if ($userStore.displayName) {
return $userStore.displayName;
}
return [$userStore.firstName, $userStore.lastName].filter(Boolean).join(' ').trim();
});
const primaryName = $derived(fullName || $userStore?.email || '');
async function useDifferentAccount() {
await webauthnService.logout();
await completeInteraction('select_account', true);
userStore.clearUser();
await invalidateAll();
}
async function handlePipeline() {
isLoading = true;
errorMessage = null;
try {
if (!$userStore) {
await authenticate();
}
if (currentStep === 'reauthenticate') {
await reauthenticate();
}
await completeInteraction(currentStep!);
} catch (e) {
success = false;
errorMessage = getWebauthnErrorMessage(e);
} finally {
isLoading = false;
}
}
async function authenticate() {
const loginOptions = await webauthnService.getLoginOptions();
const authResponse = await startAuthentication({ optionsJSON: loginOptions });
const user = await webauthnService.finishLogin(authResponse);
await userStore.setUser(user);
await invalidateAll();
}
async function reauthenticate() {
try {
await webauthnService.reauthenticate();
} catch {
const loginOptions = await webauthnService.getLoginOptions();
const authResponse = await startAuthentication({ optionsJSON: loginOptions });
await webauthnService.reauthenticate(authResponse);
}
}
async function completeInteraction(step: InteractionStep, skipRedirect = false) {
const result = await oidcService.completeAuthorizeInteractionStep(interactionSession.id, step);
if (result.interaction) {
interactionSession = result.interaction;
if (interactionSession.currentStep == 'reauthenticate') {
await handlePipeline();
}
return;
}
if (result.redirectUrl && !skipRedirect) {
success = true;
await new Promise((r) => setTimeout(r, 800));
window.location.href = result.redirectUrl;
}
}
</script>
<svelte:head>
<title>{m.sign_in_to({ name: interactionSession.client.name })}</title>
</svelte:head>
<SignInWrapper showAlternativeSignInMethodButton={$userStore == null}>
<ClientProviderImages client={interactionSession.client} {success} error={!!errorMessage} />
<h1 class="font-gloock mt-5 text-3xl font-bold sm:text-4xl">
{m.sign_in_to({ name: interactionSession.client.name })}
</h1>
<p class="text-muted-foreground mt-2 mb-10">
{#if errorMessage}
{errorMessage}.
{:else if currentStep == 'select_account' && $userStore}
<FormattedMessage
m={m.account_selection_signin_confirmation({ name: interactionSession.client.name })}
/>
{:else}
<FormattedMessage
m={m.do_you_want_to_sign_in_to_client_with_your_app_name_account({
client: interactionSession.client.name,
appName: $appConfigStore.appName
})}
/>
{/if}
</p>
{#if !$userStore || errorMessage}
<!-- Return nothing -->
{:else if currentStep === 'select_account'}
<div
transition:slide={{ duration: 300 }}
class="flex flex-col items-center"
data-testid="account-selection"
>
{#if $userStore}
<Card.Root class="mb-2 py-4 w-sm">
<Card.Content class="flex items-center gap-4">
<Avatar.Root class="size-11 shrink-0">
<Avatar.Image src={cachedProfilePicture.getUrl($userStore.id)} />
</Avatar.Root>
<div class="flex min-w-0 flex-col text-start">
<p class="truncate text-base leading-tight font-medium">
{primaryName}
</p>
{#if fullName && $userStore.email}
<p class="text-muted-foreground mt-1 truncate text-sm leading-tight">
{$userStore.email}
</p>
{/if}
</div>
</Card.Content>
</Card.Root>
{/if}
<div class="mb-10 flex justify-center">
<button
type="button"
class="text-muted-foreground text-xs transition-colors hover:underline"
onclick={useDifferentAccount}
>
{m.use_a_different_account()}
</button>
</div>
</div>
{:else if currentStep === 'consent'}
<div class="w-full max-w-md" transition:slide={{ duration: 300 }}>
<Card.Root class="mt-6 mb-10">
<Card.Header>
<p class="text-muted-foreground text-start">
<FormattedMessage
m={m.client_wants_to_access_the_following_information({
client: interactionSession.client.name
})}
/>
</p>
</Card.Header>
<Card.Content>
<ScopeList scopes={interactionSession.scopes} />
</Card.Content>
</Card.Root>
</div>
{/if}
<div class="flex w-full max-w-md flex-row-reverse gap-2">
<Button class="flex-1" {isLoading} onclick={handlePipeline} autofocus={true}>
{errorMessage ? m.try_again() : m.sign_in()}
</Button>
<Button class="flex-1" variant="secondary" href={document.referrer || '/'}>
{m.cancel()}
</Button>
</div>
</SignInWrapper>

View File

@@ -0,0 +1,16 @@
import OidcService from '$lib/services/oidc-service';
import { error } from '@sveltejs/kit';
import type { PageLoad } from './$types';
export const load: PageLoad = async ({ url }) => {
const interactionSessionId = url.searchParams.get('interaction');
if (!interactionSessionId) {
error(400, 'Missing authorize interaction');
}
const oidcService = new OidcService();
const interactionSession = await oidcService.getAuthorizeInteraction(interactionSessionId);
return {
interactionSession
};
};

View File

@@ -1,5 +1,6 @@
<script lang="ts">
import FormInput from '$lib/components/form/form-input.svelte';
import SwitchWithLabel from '$lib/components/form/switch-with-label.svelte';
import { Button } from '$lib/components/ui/button';
import * as Field from '$lib/components/ui/field';
import { Input } from '$lib/components/ui/input';
@@ -30,7 +31,8 @@
issuer: '',
subject: '',
audience: '',
jwks: ''
jwks: '',
replayProtection: true
}
];
}
@@ -39,10 +41,10 @@
federatedIdentities = federatedIdentities.filter((_, i) => i !== index);
}
function updateFederatedIdentity(
function updateFederatedIdentity<K extends keyof OidcClientFederatedIdentity>(
index: number,
field: keyof OidcClientFederatedIdentity,
value: string
field: K,
value: OidcClientFederatedIdentity[K]
) {
federatedIdentities[index] = {
...federatedIdentities[index],
@@ -80,7 +82,7 @@
{/if}
</div>
<div class="grid grid-cols-1 gap-3 md:grid-cols-2">
<div class="grid grid-cols-1 gap-5 md:grid-cols-2">
<Field.Field>
<Field.Label required for="issuer-{i}">Issuer</Field.Label>
<Input
@@ -136,6 +138,13 @@
<Field.Error>{getFieldError(i, 'jwks')}</Field.Error>
{/if}
</Field.Field>
<SwitchWithLabel
id="replay-protection-{i}"
label={m.replay_protection()}
description={m.replay_protection_description()}
checked={identity.replayProtection}
onCheckedChange={(checked) => updateFederatedIdentity(i, 'replayProtection', checked)}
/>
</div>
</div>
{/each}

View File

@@ -86,7 +86,8 @@
issuer: z.url(),
subject: z.string().optional(),
audience: z.string().optional(),
jwks: z.url().optional().or(z.literal(''))
jwks: z.url().optional().or(z.literal('')),
replayProtection: z.boolean().default(true)
})
)
})
@@ -208,7 +209,6 @@
onCheckedChange={(v) => {
if (v) {
$inputs.pkceEnabled.value = true;
$inputs.requiresPushedAuthorizationRequests.value = false;
}
}}
bind:checked={$inputs.isPublic.value}
@@ -278,7 +278,6 @@
id="requires-par"
label={m.requires_pushed_authorization_requests()}
description={m.requires_pushed_authorization_requests_description()}
disabled={$inputs.isPublic.value}
bind:checked={$inputs.requiresPushedAuthorizationRequests.value}
/>
{#if mode == 'create'}

View File

@@ -41,6 +41,9 @@ export default defineConfig((mode) => {
},
'/.well-known': {
target: process.env.DEVELOPMENT_BACKEND_URL || 'http://localhost:1411'
},
'^/authorize(?:\\?.*)?$': {
target: process.env.DEVELOPMENT_BACKEND_URL || 'http://localhost:1411'
}
}
}

View File

@@ -28,27 +28,27 @@ export const oidcClients = {
nextcloud: {
id: '3654a746-35d4-4321-ac61-0bdcff2b4055',
name: 'Nextcloud',
callbackUrl: 'http://nextcloud/auth/callback',
logoutCallbackUrl: 'http://nextcloud/auth/logout/callback',
callbackUrl: 'http://nextcloud.localhost/auth/callback',
logoutCallbackUrl: 'http://nextcloud.localhost/auth/logout/callback',
secret: 'w2mUeZISmEvIDMEDvpY0PnxQIpj1m3zY',
launchURL: 'https://nextcloud.local'
},
immich: {
id: '606c7782-f2b1-49e5-8ea9-26eb1b06d018',
name: 'Immich',
callbackUrl: 'http://immich/auth/callback',
callbackUrl: 'http://immich.localhost/auth/callback',
secret: 'PYjrE9u4v9GVqXKi52eur0eb2Ci4kc0x'
},
tailscale: {
id: '7c21a609-96b5-4011-9900-272b8d31a9d1',
name: 'Tailscale',
callbackUrl: 'http://tailscale/auth/callback',
callbackUrl: 'http://tailscale.localhost/auth/callback',
secret: 'n4VfQeXlTzA6yKpWbR9uJcMdSx2qH0Lo'
},
federated: {
id: 'c48232ff-ff65-45ed-ae96-7afa8a9b443b',
name: 'Federated',
callbackUrl: 'http://federated/auth/callback',
callbackUrl: 'http://federated.localhost/auth/callback',
federatedJWT: {
issuer: 'https://external-idp.local',
audience: 'api://PocketID',
@@ -59,19 +59,19 @@ export const oidcClients = {
scim: {
id: 'c46d2090-37a0-4f2b-8748-6aa53b0c1afa',
name: 'SCIM Client',
callbackUrl: 'http://scim.client/auth/callback',
callbackUrl: 'http://scimclient.localhost/auth/callback',
secret: 'nQbiuMRG7FpdK2EnDd5MBivWQeKFXohn'
},
pingvinShare: {
name: 'Pingvin Share',
callbackUrl: 'http://pingvin.share/auth/callback',
secondCallbackUrl: 'http://pingvin.share/auth/callback2',
callbackUrl: 'http://pingvin-share.localhost/auth/callback',
secondCallbackUrl: 'http://pingvin-share.localhost/auth/callback2',
launchURL: 'https://pingvin-share.local'
},
parClient: {
id: 'a1b2c3d4-e5f6-7890-abcd-ef0000000001',
name: 'PAR Test Client',
callbackUrl: 'http://par-client/auth/callback',
callbackUrl: 'http://par-client.localhost/auth/callback',
secret: 'w2mUeZISmEvIDMEDvpY0PnxQIpj1m3zY'
}
};

View File

@@ -1,6 +1,6 @@
{
"provider": "sqlite",
"version": 20260601154900,
"version": 20260607120000,
"tableOrder": ["users", "user_groups", "oidc_clients", "signup_tokens"],
"tables": {
"api_keys": [
@@ -39,37 +39,9 @@
"value": "7d/5hl7diJ2rnFL14hEAQf9tzpu29aqXQ8jpJ2iqqKUNFZpdOkEpud0CmRv4H3r8yyk2u/Gqqj9klSy58DJkYXGF5PAYgLyoBIb7L3JXWRbxg4cQ3QJCug13l2OTmpAKoVc+rmX8c3j3h1sNqyJ+7Ql5sS0jSeyiYgIsFNCdnK5alBDyvtcpe/QDpklmP4JCeVpvmf2rLGplk3g5UO5ydJ8UiDXxfDmi+gF6NKJvrGnnah8Ar3G/x88z+tTJtp0DIQFwxXwUM2XZqzEVGm8K2r0w5o9/Keh6bBBaiuH2C78ZOaijGV3DovhR+e9J0cYUYGwT42MZMx9fSWQ/lvWGGnf+Uq3MXJfjWSREfhkp8KTQwR9F7+dnVJWswOEk7jPR8I7hCWTMxJyvaFX3wgAXIVmhrgXZQQbYOqTt56IoqUl0xOJku8dA8opg2UcLlmmuOh6+hfkXKsiiS/H/9c1BVIGj1fCOiT6IePh4wKKSTbwJnPD5EKmdJpgTsUpjcDnXQKY4ReO0UpdRdKxwRDDLeQuG6j+ljGxR9GPudCU9Nmci6rFVI6n5LWYkQxBA1O73RpmXRZPDzntDfpXMEonkmSvOoxaCK2Id7CRKMdqvR0kEouwnhk5WSFtsfi3sA0pkXzPFxwZeWM8vFtbffZOZzXaOhxCOfcj1NClZohlZhyc4jvkxmrpY7PSaAzih0AmHI7y0LYFi6fZu/K4EheVa1+KF55nWZ8ARikHMWKAKkyExkTak7xyN884TDmzURRaPlQg4jzQte5WMNjAG/hlHibdMBNvgwiYd49ZxteJ8ABdbiXVRl+2JGbdjl2ubpQZwOn7bJKlqO56bIwsZ+e4+pXsuOGdBahkHrUjtMEmH3DZbGc6CJLbcmdhdpApLQRRcLAazxJhzAwJ47FRYsHsj57LnYNvmcKdIxw8rxCdLUuzz95uw0T3ankEO5J9sjem+HMEuKdwXK1UcuOn2rjR8Sd/BuvQmeso27dFbPXqXYNS90Ml45YyTvcKSiopD181oZR703TFUSpR7dsiqROMr+p/2jN9h6a8WbQ8xpksyclaQByY/M77AssbXnG6wfhRsntNIINCZLbBnjXOyz6ZHIC5K4tSTdcnWaiYPeRPQmnw9UUvHAcNU2yMWsy0eU377yDS0WstTxOdQutTdkczl8kv5Lo26JiEK7mSIuRK19ffF9Zz8FG8+eKv5zdyIPjyQRDYBysUoDv5huKe2eoxJu/MWS2Pql/ZtUGeD6Ozm3mCvh0vQ9ceagBkY6Ocm3du0ziAKP29Ri0mjg4DizVorbLzsh+EQH/s2Pi9MnjUZDlEmuLl2Xfp7/w4j/8u0N0tVR70VDFuGdKpTjFY3vS8EJrPtyMTM51x1D9rb8gIql8aR/rJw4YF+huxg1mv5n6+tGVqg5msbPmF12eJijP4lkmaRwIpLW5pJTtaDkUj7uOeu1mm4k+Dt5nh0/0jPHzrv6bcTCcbV7UjMHDoTXXqEpFAAJ66rHR7zdAJu+YKsnTIZyLmOpcowq7LL8G9qTvV0OSpyQWUIavRSgbDHFqEqRs+JU94jAzkq8nCY5MTd9m5sIv9InfdT3k+pwpsE/FKge8nghFLtbUrafGkzTky8SE2druvVcIvbfXMfLIKRUYjJgnWc0gQzF5J6pzXM7D2r/RG6JDzASqjlbURq6v9bhNerlOVdMujWKEEVcKWIzlbt4RkihRjM8AUqIZQOyicGQ+4yfIjAHw5viuABONYs3OIWULnFqJxdvS9rNKhfxSjIq9cfqyzevq2xrRoMXEonobh6M3bD2Vang8OAeVeD1OXWPERi4pepCYFS9RJ/Xa/UWxptsqSNuGcb3fAzQSmLpXLGdWRoKXvSe7EYgc0bGcLOjSTu5RURKo+EF9i4KT9EJauf6VXw5dTf/CCIJRXE1bWzXhSCFYntohYhX2ldOCDYpi/jFBC6Vtkw0ud3/xq8Nmhd5gUk+SpngByCZH3Pm3H+jvlbMpiqkDkm1v74hDX13Xhrcw2eWyuqKBVoRCCniUvwpYNbGvBfjC6Hcizv0Aybciwj+4nybt5EPoEUm6S6Gs7fG7QpPdvrzpAxX70MlmdkF/gwyuhbEeJhLK+WL7qAsN5CvHPzVbsIf90x+nGTtMJPgpxVr0tJMj+vprXV4WxutfARBiOnqe58MhA857sd+MzKBgKnoLOBRTiC3qc/0/ULwbG2HCCD7nmwzz7M4nUuMvo8rgS7z0BF68OClT8X3JwSXbL5Wg=="
}
],
"oidc_authorization_codes": [
{
"client_id": "3654a746-35d4-4321-ac61-0bdcff2b4055",
"authentication_method": "phr",
"code": "auth-code",
"code_challenge": null,
"code_challenge_method_sha256": null,
"created_at": "2025-11-25T12:39:02Z",
"expires_at": "2025-11-25T13:39:02Z",
"id": "6bdd221e-d9f7-4e3d-92c0-4be125802ba2",
"nonce": "nonce",
"scope": "openid profile",
"user_id": "f4b89dc2-62fb-46bf-9f5f-c34f4eafe93e"
},
{
"client_id": "c48232ff-ff65-45ed-ae96-7afa8a9b443b",
"authentication_method": "phr",
"code": "federated",
"code_challenge": null,
"code_challenge_method_sha256": null,
"created_at": "2025-11-25T12:39:02Z",
"expires_at": "2025-11-25T13:39:02Z",
"id": "37e914bd-ff2c-4653-8cd8-550f0213e430",
"nonce": "nonce",
"scope": "openid profile",
"user_id": "1cd19686-f9a6-43f4-a41f-14a0bf5b4036"
}
],
"oidc_clients": [
{
"callback_urls": "WyJodHRwOi8vbmV4dGNsb3VkL2F1dGgvY2FsbGJhY2siXQ==",
"callback_urls": "WyJodHRwOi8vbmV4dGNsb3VkLmxvY2FsaG9zdC9hdXRoL2NhbGxiYWNrIl0=",
"created_at": "2025-11-25T12:39:02Z",
"created_by_id": "f4b89dc2-62fb-46bf-9f5f-c34f4eafe93e",
"credentials": "e30=",
@@ -79,7 +51,7 @@
"is_group_restricted": false,
"is_public": false,
"launch_url": "https://nextcloud.local",
"logout_callback_urls": "WyJodHRwOi8vbmV4dGNsb3VkL2F1dGgvbG9nb3V0L2NhbGxiYWNrIl0=",
"logout_callback_urls": "WyJodHRwOi8vbmV4dGNsb3VkLmxvY2FsaG9zdC9hdXRoL2xvZ291dC9jYWxsYmFjayJd",
"name": "Nextcloud",
"pkce_enabled": false,
"requires_pushed_authorization_requests": false,
@@ -87,7 +59,7 @@
"secret": "$2a$10$9dypwot8nGuCjT6wQWWpJOckZfRprhe2EkwpKizxS/fpVHrOLEJHC"
},
{
"callback_urls": "WyJodHRwOi8vaW1taWNoL2F1dGgvY2FsbGJhY2siXQ==",
"callback_urls": "WyJodHRwOi8vaW1taWNoLmxvY2FsaG9zdC9hdXRoL2NhbGxiYWNrIl0=",
"created_at": "2025-11-25T12:39:02Z",
"created_by_id": "1cd19686-f9a6-43f4-a41f-14a0bf5b4036",
"credentials": "e30=",
@@ -105,7 +77,7 @@
"secret": "$2a$10$Ak.FP8riD1ssy2AGGbG.gOpnp/rBpymd74j0nxNMtW0GG1Lb4gzxe"
},
{
"callback_urls": "WyJodHRwOi8vdGFpbHNjYWxlL2F1dGgvY2FsbGJhY2siXQ==",
"callback_urls": "WyJodHRwOi8vdGFpbHNjYWxlLmxvY2FsaG9zdC9hdXRoL2NhbGxiYWNrIl0=",
"created_at": "2025-11-25T12:39:02Z",
"created_by_id": "f4b89dc2-62fb-46bf-9f5f-c34f4eafe93e",
"credentials": "e30=",
@@ -115,7 +87,7 @@
"is_group_restricted": true,
"is_public": false,
"launch_url": null,
"logout_callback_urls": "WyJodHRwOi8vdGFpbHNjYWxlL2F1dGgvbG9nb3V0L2NhbGxiYWNrIl0=",
"logout_callback_urls": "WyJodHRwOi8vdGFpbHNjYWxlLmxvY2FsaG9zdC9hdXRoL2xvZ291dC9jYWxsYmFjayJd",
"name": "Tailscale",
"pkce_enabled": false,
"requires_pushed_authorization_requests": false,
@@ -123,7 +95,7 @@
"secret": "$2a$10$xcRReBsvkI1XI6FG8xu/pOgzeF00bH5Wy4d/NThwcdi3ZBpVq/B9a"
},
{
"callback_urls": "WyJodHRwOi8vZmVkZXJhdGVkL2F1dGgvY2FsbGJhY2siXQ==",
"callback_urls": "WyJodHRwOi8vZmVkZXJhdGVkLmxvY2FsaG9zdC9hdXRoL2NhbGxiYWNrIl0=",
"created_at": "2025-11-25T12:39:02Z",
"created_by_id": "1cd19686-f9a6-43f4-a41f-14a0bf5b4036",
"credentials": "eyJmZWRlcmF0ZWRJZGVudGl0aWVzIjpbeyJpc3N1ZXIiOiJodHRwczovL2V4dGVybmFsLWlkcC5sb2NhbCIsInN1YmplY3QiOiJjNDgyMzJmZi1mZjY1LTQ1ZWQtYWU5Ni03YWZhOGE5YjQ0M2IiLCJhdWRpZW5jZSI6ImFwaTovL1BvY2tldElEIiwiandrcyI6Imh0dHA6Ly9sb2NhbGhvc3Q6MTQxMS9hcGkvZXh0ZXJuYWxpZHAvandrcy5qc29uIn1dfQ==",
@@ -141,7 +113,7 @@
"secret": "$2a$10$Ak.FP8riD1ssy2AGGbG.gOpnp/rBpymd74j0nxNMtW0GG1Lb4gzxe"
},
{
"callback_urls": "WyJodHRwOi8vc2NpbWNsaWVudC9hdXRoL2NhbGxiYWNrIl0=",
"callback_urls": "WyJodHRwOi8vc2NpbWNsaWVudC5sb2NhbGhvc3QvYXV0aC9jYWxsYmFjayJd",
"created_at": "2025-11-25T12:39:02Z",
"created_by_id": "f4b89dc2-62fb-46bf-9f5f-c34f4eafe93e",
"dark_image_type": null,
@@ -158,7 +130,7 @@
"secret": "$2a$10$h4wfa8gI7zavDAxwzSq1sOwYU4e8DwK1XZ8ZweNnY5KzlJ3Iz.qdK"
},
{
"callback_urls": "WyJodHRwOi8vcGFyLWNsaWVudC9hdXRoL2NhbGxiYWNrIl0=",
"callback_urls": "WyJodHRwOi8vcGFyLWNsaWVudC5sb2NhbGhvc3QvYXV0aC9jYWxsYmFjayJd",
"created_at": "2025-11-25T12:39:02Z",
"created_by_id": "f4b89dc2-62fb-46bf-9f5f-c34f4eafe93e",
"credentials": "e30=",
@@ -190,19 +162,6 @@
"user_group_id": "c7ae7c01-28a3-4f3c-9572-1ee734ea8368"
}
],
"oidc_refresh_tokens": [
{
"client_id": "3654a746-35d4-4321-ac61-0bdcff2b4055",
"authentication_method": "phr",
"id_token_jti": "dd75f6f6-ce0a-44b7-a645-7de390ccd2fa",
"created_at": "2025-11-25T12:39:02Z",
"expires_at": "2025-11-26T12:39:02Z",
"id": "4928604e-e689-410c-9b25-5b9b6db9e46e",
"scope": "openid profile email",
"token": "fef6e2e37eb990f0bd7abd48a41d530c54b6a1f139b556e35e62475e6f4cb38d",
"user_id": "f4b89dc2-62fb-46bf-9f5f-c34f4eafe93e"
}
],
"one_time_access_tokens": [
{
"created_at": "2025-11-25T12:39:02Z",
@@ -273,25 +232,25 @@
{
"client_id": "3654a746-35d4-4321-ac61-0bdcff2b4055",
"last_used_at": "2025-08-01T13:00:00Z",
"scope": "openid profile email",
"scope": "WyJvcGVuaWQiLCJwcm9maWxlIiwiZW1haWwiXQ==",
"user_id": "f4b89dc2-62fb-46bf-9f5f-c34f4eafe93e"
},
{
"client_id": "7c21a609-96b5-4011-9900-272b8d31a9d1",
"last_used_at": "2025-08-10T14:00:00Z",
"scope": "openid profile email",
"scope": "WyJvcGVuaWQiLCJwcm9maWxlIiwiZW1haWwiXQ==",
"user_id": "f4b89dc2-62fb-46bf-9f5f-c34f4eafe93e"
},
{
"client_id": "a1b2c3d4-e5f6-7890-abcd-ef0000000001",
"last_used_at": "2024-01-01T00:00:00Z",
"scope": "openid profile email",
"scope": "WyJvcGVuaWQiLCJwcm9maWxlIiwiZW1haWwiXQ==",
"user_id": "f4b89dc2-62fb-46bf-9f5f-c34f4eafe93e"
},
{
"client_id": "c48232ff-ff65-45ed-ae96-7afa8a9b443b",
"last_used_at": "2025-08-12T12:00:00Z",
"scope": "openid profile email",
"scope": "WyJvcGVuaWQiLCJwcm9maWxlIiwiZW1haWwiXQ==",
"user_id": "1cd19686-f9a6-43f4-a41f-14a0bf5b4036"
}
],

View File

@@ -1,12 +1,29 @@
import test, { expect, type Page, type Request } from '@playwright/test';
import test, { expect, type APIRequestContext, type Page, type Request } from '@playwright/test';
import { oidcClients, refreshTokens, users } from '../data';
import { cleanupBackend } from '../utils/cleanup.util';
import { generateIdToken, generateOauthAccessToken } from '../utils/jwt.util';
import { generateIdToken } from '../utils/jwt.util';
import * as oidcUtil from '../utils/oidc.util';
import passkeyUtil from '../utils/passkey.util';
test.beforeEach(async () => await cleanupBackend());
async function generateSeededOauthAccessToken(
request: APIRequestContext,
userId: string,
clientId: string,
expired = false
) {
return request
.post('/api/test/accesstoken', {
data: {
client: clientId,
expired,
user: userId
}
})
.then((r) => r.text());
}
test('Authorize existing client', async ({ page }) => {
const oidcClient = oidcClients.nextcloud;
const urlParams = createUrlParams(oidcClient);
@@ -59,6 +76,7 @@ test('Authorize new client while not signed in', async ({ page }) => {
test('Authorize new client fails with user group not allowed', async ({ page }) => {
const oidcClient = oidcClients.immich;
const urlParams = createUrlParams(oidcClient);
await page.context().clearCookies();
await page.goto(`/authorize?${urlParams.toString()}`);
@@ -67,11 +85,15 @@ test('Authorize new client fails with user group not allowed', async ({ page })
await expectScopes(page, ['Email', 'Profile']);
await page.getByRole('button', { name: 'Sign in' }).click();
await expect(page.getByRole('paragraph').first()).toHaveText(
"You're not allowed to access this service."
const callbackUrl = await expectCallbackRedirect(page, oidcClient.callbackUrl, () =>
page.getByRole('button', { name: 'Sign in' }).click()
);
expect(callbackUrl.searchParams.get('error')).toBe('access_denied');
expect(callbackUrl.searchParams.get('error_description')).toContain(
'You are not allowed to access this service.'
);
expect(callbackUrl.searchParams.get('state')).toBe(urlParams.get('state'));
});
function createUrlParams(oidcClient: { id: string; callbackUrl: string }) {
@@ -117,6 +139,60 @@ test('End session with id token hint redirects to callback URL', async ({ page }
);
});
test('End session with id token hint redirects to callback URL without UI session', async ({
page
}) => {
const client = oidcClients.nextcloud;
const idToken = await generateIdToken(
'fe81c12a-7336-4aee-bebc-d901a873bf48',
users.tim,
client.id
);
await page.context().clearCookies();
const callbackUrl = await expectCallbackRedirect(page, client.logoutCallbackUrl, () =>
page.goto(
`/api/oidc/end-session?id_token_hint=${idToken}&post_logout_redirect_uri=${client.logoutCallbackUrl}&state=logout-state`
)
);
expect(callbackUrl.searchParams.get('state')).toBe('logout-state');
});
test('End session ignores an unregistered post_logout_redirect_uri', async ({ page }) => {
// A post_logout_redirect_uri the client never registered must not be honored, otherwise
// RP-initiated logout becomes an open redirect. The handler falls back to the in-app
// logout confirmation instead of bouncing the user to the attacker URL.
const client = oidcClients.nextcloud;
const idToken = await generateIdToken(
'fe81c12a-7336-4aee-bebc-d901a873bf48',
users.tim,
client.id
);
await page.goto(
`/api/oidc/end-session?id_token_hint=${idToken}&post_logout_redirect_uri=http://evil.localhost/steal`
);
await expect(page).toHaveURL('/logout');
});
test('End session rejects an id token hint with a mismatched client_id', async ({ page }) => {
const client = oidcClients.nextcloud;
const idToken = await generateIdToken(
'fe81c12a-7336-4aee-bebc-d901a873bf48',
users.tim,
client.id
);
// The explicit client_id contradicts the audience baked into the id_token_hint, so the
// logout must not be auto-confirmed against another client's callback.
await page.goto(
`/api/oidc/end-session?id_token_hint=${idToken}&client_id=${oidcClients.immich.id}`
);
await expect(page).toHaveURL('/logout');
});
test('Successfully refresh tokens with valid refresh token', async ({ request }) => {
const { token, clientId, userId } = refreshTokens.filter((token) => !token.expired)[0];
const clientSecret = 'w2mUeZISmEvIDMEDvpY0PnxQIpj1m3zY';
@@ -150,8 +226,9 @@ test('Successfully refresh tokens with valid refresh token', async ({ request })
expect(tokenData.access_token).toBeDefined();
expect(tokenData.refresh_token).toBeDefined();
expect(tokenData.id_token).toBeDefined();
expect(tokenData.token_type).toBe('Bearer');
expect(tokenData.expires_in).toBe(3600);
expect(tokenData.token_type).toBe('bearer');
expect(tokenData.expires_in).toBeGreaterThanOrEqual(3598);
expect(tokenData.expires_in).toBeLessThanOrEqual(3600);
// The new refresh token should be different from the old one
expect(tokenData.refresh_token).not.toBe(token);
@@ -264,7 +341,11 @@ test('Using refresh token invalidates it for future use', async ({ request }) =>
test.describe('Introspection endpoint', () => {
test('fails without client credentials', async ({ request }) => {
const validAccessToken = await generateOauthAccessToken(users.tim, oidcClients.nextcloud.id);
const validAccessToken = await generateSeededOauthAccessToken(
request,
users.tim.id,
oidcClients.nextcloud.id
);
const introspectionResponse = await request.post('/api/oidc/introspect', {
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
@@ -273,12 +354,15 @@ test.describe('Introspection endpoint', () => {
token: validAccessToken
}
});
expect(introspectionResponse.status()).toBe(400);
expect(introspectionResponse.status()).toBe(401);
});
test('succeeds with client credentials', async ({ request, baseURL }) => {
const validAccessToken = await generateOauthAccessToken(users.tim, oidcClients.nextcloud.id);
const validAccessToken = await generateSeededOauthAccessToken(
request,
users.tim.id,
oidcClients.nextcloud.id
);
const introspectionResponse = await request.post('/api/oidc/introspect', {
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
@@ -292,18 +376,20 @@ test.describe('Introspection endpoint', () => {
token: validAccessToken
}
});
expect(introspectionResponse.status()).toBe(200);
const introspectionBody = await introspectionResponse.json();
expect(introspectionBody.active).toBe(true);
expect(introspectionBody.token_type).toBe('access_token');
expect(introspectionBody.iss).toBe(baseURL);
expect(introspectionBody.sub).toBe(users.tim.id);
expect(introspectionBody.aud).toStrictEqual([oidcClients.nextcloud.id]);
});
test('succeeds with federated client credentials', async ({ page, request, baseURL }) => {
const validAccessToken = await generateOauthAccessToken(users.tim, oidcClients.federated.id);
const validAccessToken = await generateSeededOauthAccessToken(
request,
users.tim.id,
oidcClients.federated.id
);
const clientAssertion = await oidcUtil.getClientAssertion(
page,
oidcClients.federated.federatedJWT
@@ -322,14 +408,17 @@ test.describe('Introspection endpoint', () => {
expect(introspectionResponse.status()).toBe(200);
const introspectionBody = await introspectionResponse.json();
expect(introspectionBody.active).toBe(true);
expect(introspectionBody.token_type).toBe('access_token');
expect(introspectionBody.iss).toBe(baseURL);
expect(introspectionBody.sub).toBe(users.tim.id);
expect(introspectionBody.aud).toStrictEqual([oidcClients.federated.id]);
});
test('fails with client credentials for wrong app', async ({ request }) => {
const validAccessToken = await generateOauthAccessToken(users.tim, oidcClients.nextcloud.id);
const validAccessToken = await generateSeededOauthAccessToken(
request,
users.tim.id,
oidcClients.nextcloud.id
);
const introspectionResponse = await request.post('/api/oidc/introspect', {
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
@@ -342,11 +431,16 @@ test.describe('Introspection endpoint', () => {
}
});
expect(introspectionResponse.status()).toBe(400);
const introspectionBody = await introspectionResponse.json();
expect(introspectionBody.active).toBe(false);
});
test('fails with federated credentials for wrong app', async ({ page, request }) => {
const validAccessToken = await generateOauthAccessToken(users.tim, oidcClients.nextcloud.id);
const validAccessToken = await generateSeededOauthAccessToken(
request,
users.tim.id,
oidcClients.nextcloud.id
);
const clientAssertion = await oidcUtil.getClientAssertion(
page,
oidcClients.federated.federatedJWT
@@ -362,7 +456,9 @@ test.describe('Introspection endpoint', () => {
}
});
expect(introspectionResponse.status()).toBe(400);
expect(introspectionResponse.status()).toBe(200);
const introspectionBody = await introspectionResponse.json();
expect(introspectionBody.active).toBe(false);
});
test('non-expired refresh_token can be verified', async ({ request }) => {
@@ -396,7 +492,6 @@ test.describe('Introspection endpoint', () => {
expect(introspectionResponse.status()).toBe(200);
const introspectionBody = await introspectionResponse.json();
expect(introspectionBody.active).toBe(true);
expect(introspectionBody.token_type).toBe('refresh_token');
});
test('expired refresh_token can be verified', async ({ request }) => {
@@ -433,21 +528,65 @@ test.describe('Introspection endpoint', () => {
});
test("expired access_token can't be verified", async ({ request }) => {
const expiredAccessToken = await generateOauthAccessToken(
users.tim,
const expiredAccessToken = await generateSeededOauthAccessToken(
request,
users.tim.id,
oidcClients.nextcloud.id,
true
);
const introspectionResponse = await request.post('/api/oidc/introspect', {
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
'Content-Type': 'application/x-www-form-urlencoded',
Authorization:
'Basic ' +
Buffer.from(`${oidcClients.nextcloud.id}:${oidcClients.nextcloud.secret}`).toString(
'base64'
)
},
form: {
token: expiredAccessToken
}
});
expect(introspectionResponse.status()).toBe(400);
expect(introspectionResponse.status()).toBe(200);
const introspectionBody = await introspectionResponse.json();
expect(introspectionBody.active).toBe(false);
});
});
test.describe('Userinfo endpoint', () => {
test('returns the claims for the granted scopes', async ({ request, baseURL }) => {
const accessToken = await generateSeededOauthAccessToken(
request,
users.tim.id,
oidcClients.nextcloud.id
);
const res = await request.get('/api/oidc/userinfo', {
headers: { Authorization: 'Bearer ' + accessToken }
});
expect(res.status()).toBe(200);
const body = await res.json();
expect(body.sub).toBe(users.tim.id);
expect(body.email).toBe(users.tim.email);
expect(body.given_name).toBe(users.tim.firstname);
expect(body.family_name).toBe(users.tim.lastname);
expect(body.preferred_username).toBe(users.tim.username);
// The picture claim must point at this instance, not leak an arbitrary host.
expect(body.picture).toBe(`${baseURL}/api/users/${users.tim.id}/profile-picture.png`);
});
test('fails without an access token', async ({ request }) => {
const res = await request.get('/api/oidc/userinfo');
expect(res.status()).toBe(401);
});
test('fails with an invalid access token', async ({ request }) => {
const res = await request.get('/api/oidc/userinfo', {
headers: { Authorization: 'Bearer not-a-real-token' }
});
expect(res.status()).not.toBe(200);
});
});
@@ -455,7 +594,7 @@ test('Authorize new client with device authorization flow', async ({ page }) =>
const client = oidcClients.immich;
const userCode = await oidcUtil.getUserCode(page, client.id, client.secret);
await page.goto(`/device?code=${userCode}`);
await page.goto(`/device?user_code=${userCode}`);
await expectScopes(page, ['Email', 'Profile']);
@@ -473,7 +612,7 @@ test('Authorize new client with device authorization flow while not signed in',
const client = oidcClients.immich;
const userCode = await oidcUtil.getUserCode(page, client.id, client.secret);
await page.goto(`/device?code=${userCode}`);
await page.goto(`/device?user_code=${userCode}`);
await (await passkeyUtil.init(page)).addPasskey();
await page.getByRole('button', { name: 'Authorize' }).click();
@@ -491,7 +630,7 @@ test('Authorize existing client with device authorization flow', async ({ page }
const client = oidcClients.nextcloud;
const userCode = await oidcUtil.getUserCode(page, client.id, client.secret);
await page.goto(`/device?code=${userCode}`);
await page.goto(`/device?user_code=${userCode}`);
await expect(
page.getByRole('paragraph').filter({ hasText: 'The device has been authorized.' })
@@ -505,7 +644,7 @@ test('Authorize existing client with device authorization flow while not signed
const client = oidcClients.nextcloud;
const userCode = await oidcUtil.getUserCode(page, client.id, client.secret);
await page.goto(`/device?code=${userCode}`);
await page.goto(`/device?user_code=${userCode}`);
await (await passkeyUtil.init(page)).addPasskey();
await page.getByRole('button', { name: 'Authorize' }).click();
@@ -515,8 +654,53 @@ test('Authorize existing client with device authorization flow while not signed
).toBeVisible();
});
test('Device authorization flow forces reauthentication when client requires it', async ({
page,
request
}) => {
const client = oidcClients.nextcloud;
await request.put(`/api/oidc/clients/${client.id}`, {
data: {
name: client.name,
callbackURLs: [client.callbackUrl],
logoutCallbackURLs: [client.logoutCallbackUrl],
isPublic: false,
pkceEnabled: false,
requiresReauthentication: true,
requiresPushedAuthorizationRequests: false,
credentials: { federatedIdentities: [] },
isGroupRestricted: false
}
});
const userCode = await oidcUtil.getUserCode(page, client.id, client.secret);
const directVerify = await page.request.post(`/api/oidc/device/verify?code=${userCode}`);
expect(directVerify.status()).toBe(401);
let reauthCalled = false;
await page.route('/api/webauthn/login/start', async (route) => {
reauthCalled = true;
await route.continue();
});
await page.route('/api/webauthn/reauthenticate', async (route) => {
reauthCalled = true;
await route.continue();
});
await (await passkeyUtil.init(page)).addPasskey();
await page.goto(`/device?user_code=${userCode}`);
await expect(page.getByText('Do you want to sign in to Nextcloud')).toBeVisible();
await page.getByRole('button', { name: 'Authorize' }).click();
await expect(
page.getByRole('paragraph').filter({ hasText: 'The device has been authorized.' })
).toBeVisible();
expect(reauthCalled).toBe(true);
});
test('Authorize client with device authorization flow with invalid code', async ({ page }) => {
await page.goto('/device?code=invalid-code');
await page.goto('/device?user_code=invalid-code');
await expect(
page.getByRole('paragraph').filter({ hasText: 'Invalid device code.' })
@@ -530,7 +714,7 @@ test('Authorize new client with device authorization with user group not allowed
const client = oidcClients.immich;
const userCode = await oidcUtil.getUserCode(page, client.id, client.secret);
await page.goto(`/device?code=${userCode}`);
await page.goto(`/device?user_code=${userCode}`);
await (await passkeyUtil.init(page)).addPasskey('craig');
await page.getByRole('button', { name: 'Authorize' }).click();
@@ -556,36 +740,61 @@ test('Federated identity fails with invalid client assertion', async ({ page })
client_assertion: 'not-an-assertion'
});
expect(res?.error).toBe('Invalid client assertion');
expect(res?.error).toBe('invalid_client');
});
test('Authorize existing client with federated identity', async ({ page }) => {
const client = oidcClients.federated;
const clientAssertion = await oidcUtil.getClientAssertion(page, client.federatedJWT);
const urlParams = createUrlParams(client);
const callbackUrl = await expectCallbackRedirect(page, client.callbackUrl, async () => {
await page.goto(`/authorize?${urlParams.toString()}`);
const signInButton = page.getByRole('button', { name: /sign in/i });
await expect(signInButton).toBeVisible();
await signInButton.click();
});
const code = callbackUrl.searchParams.get('code');
expect(code).toBeTruthy();
const res = await oidcUtil.exchangeCode(page, {
client_assertion_type: 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer',
grant_type: 'authorization_code',
redirect_uri: client.callbackUrl,
code: client.accessCodes[0],
code: code!,
client_id: client.id,
client_assertion: clientAssertion
});
expect(res.access_token).not.toBeNull;
expect(res.expires_in).not.toBeNull;
expect(res.token_type).toBe('Bearer');
expect(res.access_token).not.toBeNull();
expect(res.expires_in).not.toBeNull();
expect(res.token_type).toBe('bearer');
});
test('Forces reauthentication when client requires it', async ({ page, request }) => {
let webauthnStartCalled = false;
let reauthCalled = false;
await page.route('/api/webauthn/login/start', async (route) => {
webauthnStartCalled = true;
reauthCalled = true;
await route.continue();
});
await page.route('/api/webauthn/reauthenticate', async (route) => {
reauthCalled = true;
await route.continue();
});
await request.put(`/api/oidc/clients/${oidcClients.nextcloud.id}`, {
data: { ...oidcClients.nextcloud, requiresReauthentication: true }
data: {
name: oidcClients.nextcloud.name,
callbackURLs: [oidcClients.nextcloud.callbackUrl],
logoutCallbackURLs: [oidcClients.nextcloud.logoutCallbackUrl],
isPublic: false,
pkceEnabled: false,
requiresReauthentication: true,
requiresPushedAuthorizationRequests: false,
credentials: { federatedIdentities: [] },
isGroupRestricted: false
}
});
await (await passkeyUtil.init(page)).addPasskey();
@@ -594,9 +803,11 @@ test('Forces reauthentication when client requires it', async ({ page, request }
await expectCallbackRedirect(page, oidcClients.nextcloud.callbackUrl, async () => {
await page.goto(`/authorize?${urlParams.toString()}`);
await expect(page.getByTestId('scopes')).not.toBeVisible();
await expect(page.getByRole('button', { name: /sign in/i })).toBeVisible();
await page.getByRole('button', { name: /sign in/i }).click();
});
expect(webauthnStartCalled).toBe(true);
expect(reauthCalled).toBe(true);
});
test('Authorize existing client while not signed in with response_mode=form_post', async ({
@@ -607,13 +818,12 @@ test('Authorize existing client while not signed in with response_mode=form_post
urlParams.set('response_mode', 'form_post');
await page.context().clearCookies();
const formPostRequestPromise = waitForFormPostRequest(page, oidcClient.callbackUrl);
await page.goto(`/authorize?${urlParams.toString()}`);
await (await passkeyUtil.init(page)).addPasskey();
await page.getByRole('button', { name: 'Sign in' }).click();
await expectFormPostRequest(formPostRequestPromise);
await expectFormPostResponse(page, oidcClient.callbackUrl);
});
test('Authorize existing client with response_mode=form_post', async ({ page }) => {
@@ -621,10 +831,9 @@ test('Authorize existing client with response_mode=form_post', async ({ page })
const urlParams = createUrlParams(oidcClient);
urlParams.set('response_mode', 'form_post');
const formPostRequestPromise = waitForFormPostRequest(page, oidcClient.callbackUrl);
await page.goto(`/authorize?${urlParams.toString()}`);
await expectFormPostRequest(formPostRequestPromise);
await expectFormPostResponse(page, oidcClient.callbackUrl);
});
test('Authorize existing client with response_mode=fragment', async ({ page }) => {
@@ -643,19 +852,27 @@ test('Authorize existing client with response_mode=fragment', async ({ page }) =
expect(fragmentParams.get('iss')).toBeTruthy();
});
function waitForFormPostRequest(page: Page, callbackUrl: string): Promise<Request> {
return page.waitForRequest(
(request) => request.method() === 'POST' && request.url() === callbackUrl
async function expectFormPostResponse(
page: Page,
callbackUrl: string,
expectedState = 'nXx-6Qr-owc1SHBa'
): Promise<URLSearchParams> {
const form = page.locator('form[method="post"]');
await expect(form).toHaveAttribute('action', callbackUrl);
const formData = new URLSearchParams(
await form.locator('input[type="hidden"]').evaluateAll((inputs) => {
const params = new URLSearchParams();
for (const input of inputs) {
params.append(input.name, input.value);
}
return params.toString();
})
);
}
async function expectFormPostRequest(formPostRequestPromise: Promise<Request>) {
const request = await formPostRequestPromise;
const formData = new URLSearchParams(request.postData() ?? '');
expect(formData.get('code')).toBeTruthy();
expect(formData.get('state')).toBe('nXx-6Qr-owc1SHBa');
expect(formData.get('state')).toBe(expectedState);
expect(formData.get('iss')).toBeTruthy();
return formData;
}
test.describe('OIDC prompt parameter', () => {
@@ -691,9 +908,10 @@ test.describe('OIDC prompt parameter', () => {
await route.fulfill({ status: 200, body: 'attacker' });
});
await page.goto(`/authorize?${urlParams.toString()}`);
const response = await page.goto(`/authorize?${urlParams.toString()}`);
await expect(page.getByText(/Invalid callback URL/i)).toBeVisible();
expect(response?.status()).toBe(400);
await expect(page.locator('body')).toContainText('invalid_request');
expect(attackerRedirected).toBe(false);
});
@@ -744,9 +962,10 @@ test.describe('OIDC prompt parameter', () => {
await route.fulfill({ status: 200, body: 'attacker' });
});
await page.goto(`/authorize?${urlParams.toString()}`);
const response = await page.goto(`/authorize?${urlParams.toString()}`);
await expect(page.getByText(/Invalid callback URL/i)).toBeVisible();
expect(response?.status()).toBe(400);
await expect(page.locator('body')).toContainText('invalid_request');
expect(attackerRedirected).toBe(false);
});
@@ -785,11 +1004,17 @@ test.describe('OIDC prompt parameter', () => {
reauthCalled = true;
await route.continue();
});
await page.route('/api/webauthn/reauthenticate', async (route) => {
reauthCalled = true;
await route.continue();
});
await (await passkeyUtil.init(page)).addPasskey();
await expectCallbackRedirect(page, oidcClient.callbackUrl, () =>
page.goto(`/authorize?${urlParams.toString()}`)
);
await expectCallbackRedirect(page, oidcClient.callbackUrl, async () => {
await page.goto(`/authorize?${urlParams.toString()}`);
await expect(page.getByRole('button', { name: /sign in/i })).toBeVisible();
await page.getByRole('button', { name: /sign in/i }).click();
});
expect(reauthCalled).toBe(true);
});
@@ -824,13 +1049,14 @@ test.describe('OIDC prompt parameter', () => {
(await passkeyUtil.init(page)).addPasskey('craig');
await page.getByRole('button', { name: 'Sign In' }).click();
await expect(page.getByText('Do you want to sign in to Nextcloud')).toBeVisible();
await expectCallbackRedirect(page, oidcClient.callbackUrl, () =>
page.getByRole('button', { name: 'Sign In' }).click()
page.getByRole('button', { name: /sign in/i }).click()
);
});
test('prompt=none with prompt=consent returns interaction_required', async ({ page }) => {
test('prompt=none with prompt=consent returns invalid_request', async ({ page }) => {
const oidcClient = oidcClients.nextcloud;
const urlParams = createUrlParams(oidcClient);
urlParams.set('prompt', 'none consent');
@@ -840,11 +1066,11 @@ test.describe('OIDC prompt parameter', () => {
page.goto(`/authorize?${urlParams.toString()}`).then(() => {})
);
expect(redirectUrl.searchParams.get('error')).toBe('interaction_required');
expect(redirectUrl.searchParams.get('error')).toBe('invalid_request');
expect(redirectUrl.searchParams.get('state')).toBe('nXx-6Qr-owc1SHBa');
});
test('prompt=none with prompt=login returns interaction_required', async ({ page }) => {
test('prompt=none with prompt=login returns invalid_request', async ({ page }) => {
const oidcClient = oidcClients.nextcloud;
const urlParams = createUrlParams(oidcClient);
urlParams.set('prompt', 'none login');
@@ -854,11 +1080,11 @@ test.describe('OIDC prompt parameter', () => {
page.goto(`/authorize?${urlParams.toString()}`).then(() => {})
);
expect(redirectUrl.searchParams.get('error')).toBe('interaction_required');
expect(redirectUrl.searchParams.get('error')).toBe('invalid_request');
expect(redirectUrl.searchParams.get('state')).toBe('nXx-6Qr-owc1SHBa');
});
test('prompt=none with prompt=select_account returns interaction_required', async ({ page }) => {
test('prompt=none with prompt=select_account returns invalid_request', async ({ page }) => {
const oidcClient = oidcClients.nextcloud;
const urlParams = createUrlParams(oidcClient);
urlParams.set('prompt', 'none select_account');
@@ -868,20 +1094,23 @@ test.describe('OIDC prompt parameter', () => {
page.goto(`/authorize?${urlParams.toString()}`).then(() => {})
);
expect(redirectUrl.searchParams.get('error')).toBe('interaction_required');
expect(redirectUrl.searchParams.get('error')).toBe('invalid_request');
expect(redirectUrl.searchParams.get('state')).toBe('nXx-6Qr-owc1SHBa');
});
});
async function waitForCallbackURL(page: Page, callbackUrl: string): Promise<URL> {
const expectedUrl = new URL(callbackUrl);
const isCallbackURL = (url: URL) =>
url.origin === expectedUrl.origin && url.pathname === expectedUrl.pathname;
const isCallbackURL = callbackURLMatcher(callbackUrl);
const callbackRequest = await page.waitForRequest((request) =>
isCallbackURL(new URL(request.url()))
);
await page.waitForURL(isCallbackURL, { waitUntil: 'commit' }).catch(() => {});
const redirectURL = await callbackRedirectURL(callbackRequest, isCallbackURL);
if (redirectURL) {
return redirectURL;
}
await page.waitForURL(isCallbackURL, { waitUntil: 'commit', timeout: 5000 }).catch(() => {});
const currentURL = new URL(page.url());
if (isCallbackURL(currentURL)) {
@@ -891,6 +1120,29 @@ async function waitForCallbackURL(page: Page, callbackUrl: string): Promise<URL>
return new URL(callbackRequest.url());
}
function callbackURLMatcher(callbackUrl: string): (url: URL) => boolean {
const expectedUrl = new URL(callbackUrl);
return (url) => url.origin === expectedUrl.origin && url.pathname === expectedUrl.pathname;
}
async function callbackRedirectURL(
request: Request,
isCallbackURL: (url: URL) => boolean
): Promise<URL | null> {
const redirectResponse = await request.redirectedFrom()?.response();
if (!redirectResponse) {
return null;
}
const redirectLocation = redirectResponse.headers().location;
if (!redirectLocation) {
return null;
}
const redirectURL = new URL(redirectLocation, redirectResponse.url());
return isCallbackURL(redirectURL) ? redirectURL : null;
}
async function expectCallbackRedirect(
page: Page,
callbackUrl: string,
@@ -908,14 +1160,14 @@ async function expectCallbackRedirect(
await actionPromise;
return callbackURL;
} finally {
await page.unroute(callbackRouteMatcher);
if (!page.isClosed()) {
await page.unroute(callbackRouteMatcher).catch(() => {});
}
}
}
async function routeCallbackPage(page: Page, callbackUrl: string): Promise<(url: URL) => boolean> {
const expectedUrl = new URL(callbackUrl);
const callbackRouteMatcher = (url: URL) =>
url.origin === expectedUrl.origin && url.pathname === expectedUrl.pathname;
const callbackRouteMatcher = callbackURLMatcher(callbackUrl);
await page.route(callbackRouteMatcher, async (route) => {
await route.fulfill({
@@ -977,34 +1229,10 @@ test.describe('Pushed Authorization Requests (PAR)', () => {
redirect_uri: client.callbackUrl
});
expect(tokenResult.access_token).toBeTruthy();
expect(tokenResult.token_type).toBe('Bearer');
expect(tokenResult.token_type).toBe('bearer');
expect(tokenResult.error).toBeUndefined();
});
test('par-request-info resolves the stored request parameters', async ({ page }) => {
const state = 'par-info-state-9f3a';
const parResult = await oidcUtil.pushAuthorizationRequest(page, {
clientId: client.id,
clientSecret: client.secret,
redirectUri: client.callbackUrl,
scope: 'openid profile',
state,
responseMode: 'form_post'
});
expect(parResult.request_uri).toBeDefined();
const res = await page.request.get('/api/oidc/par-request-info', {
params: { client_id: client.id, request_uri: parResult.request_uri! }
});
expect(res.ok()).toBe(true);
const body = await res.json();
expect(body.scope).toBe('openid profile');
expect(body.redirectURI).toBe(client.callbackUrl);
expect(body.state).toBe(state);
expect(body.responseMode).toBe('form_post');
});
test('PAR full flow carries the resolved state into the callback redirect', async ({ page }) => {
const state = 'par-flow-state-7b21';
const parResult = await oidcUtil.pushAuthorizationRequest(page, {
@@ -1021,11 +1249,9 @@ test.describe('Pushed Authorization Requests (PAR)', () => {
request_uri: parResult.request_uri!
});
const formPostRequestPromise = waitForFormPostRequest(page, client.callbackUrl);
await page.goto(`/authorize?${urlParams.toString()}`);
const request = await formPostRequestPromise;
const formData = new URLSearchParams(request.postData() ?? '');
const formData = await expectFormPostResponse(page, client.callbackUrl, state);
expect(formData.get('code')).toBeTruthy();
expect(formData.get('state')).toBe(state);
});
@@ -1038,7 +1264,7 @@ test.describe('Pushed Authorization Requests (PAR)', () => {
clientId: client.id,
clientSecret: client.secret,
redirectUri: client.callbackUrl,
scope: 'openid profile'
scope: 'openid profile groups'
});
expect(parResult.request_uri).toBeDefined();
@@ -1049,7 +1275,7 @@ test.describe('Pushed Authorization Requests (PAR)', () => {
await page.goto(`/authorize?${urlParams.toString()}`);
// Consent screen with the requested scope (resolved from the PAR) must be shown
await expectScopes(page, ['Profile']);
await expectScopes(page, ['Groups']);
// Confirming proceeds with the authorization
await expectCallbackRedirect(page, client.callbackUrl, () =>
@@ -1076,19 +1302,13 @@ test.describe('Pushed Authorization Requests (PAR)', () => {
);
expect(firstCallbackUrl.searchParams.get('code')).toBeTruthy();
// Second use of the same request_uri should fail
// Use the authorize API directly (requires auth cookie which we have)
const response = await page.request.post('/api/oidc/authorize', {
headers: { 'Content-Type': 'application/json' },
data: {
clientID: client.id,
requestURI: parResult.request_uri
}
});
expect(response.status()).toBe(400);
await page.goto(`/authorize?${urlParams.toString()}`);
await expect(page.getByText('invalid_request_uri')).toBeVisible();
});
test('PAR endpoint rejects request without client credentials', async ({ page }) => {
test('PAR endpoint rejects confidential client request without client credentials', async ({
page
}) => {
const result = await oidcUtil.pushAuthorizationRequest(page, {
clientId: client.id,
// no clientSecret
@@ -1099,8 +1319,8 @@ test.describe('Pushed Authorization Requests (PAR)', () => {
expect(result.request_uri).toBeUndefined();
});
test('PAR endpoint rejects public client', async ({ page }) => {
// The parClient is confidential — test by setting isPublic via admin API first
test('PAR endpoint accepts public client with PKCE', async ({ page }) => {
// The PAR test client is confidential by default, so make it public first.
await page.request.put(`/api/oidc/clients/${client.id}`, {
headers: { 'Content-Type': 'application/json' },
data: {
@@ -1118,12 +1338,13 @@ test.describe('Pushed Authorization Requests (PAR)', () => {
const result = await oidcUtil.pushAuthorizationRequest(page, {
clientId: client.id,
clientSecret: client.secret,
redirectUri: client.callbackUrl
redirectUri: client.callbackUrl,
codeChallenge: 'K2-ltc83acc4h0c9w6ESC_rEMTJ3bww-uCHaoeK1t8U',
codeChallengeMethod: 'S256'
});
expect(result.error).toBe('Pushed authorization requests are not supported for public clients');
expect(result.request_uri).toBeUndefined();
expect(result.error).toBeUndefined();
expect(result.request_uri).toBeDefined();
});
test('PAR endpoint rejects invalid redirect_uri at push time', async ({ page }) => {
@@ -1157,16 +1378,13 @@ test.describe('Pushed Authorization Requests (PAR)', () => {
}
});
// Attempt a normal authorization (without request_uri)
const response = await page.request.post('/api/oidc/authorize', {
headers: { 'Content-Type': 'application/json' },
data: {
clientID: client.id,
scope: 'openid profile',
callbackURL: client.callbackUrl
}
});
expect(response.status()).toBe(400);
const urlParams = createUrlParams(client);
urlParams.set('scope', 'openid profile');
const callbackUrl = await expectCallbackRedirect(page, client.callbackUrl, () =>
page.goto(`/authorize?${urlParams.toString()}`)
);
expect(callbackUrl.searchParams.get('error')).toBeTruthy();
});
test('Admin UI: PAR toggle persists after save', async ({ page }) => {

View File

@@ -7,26 +7,14 @@ export async function interceptCallbackRedirect(
timeoutMs: number = 10000
): Promise<URL> {
const routeMatcher = (url: URL) => url.pathname === callbackPath;
const callbackPromise = page
.waitForRequest((request) => routeMatcher(new URL(request.url())), { timeout: timeoutMs })
.then((request) => new URL(request.url()));
const callbackPromise = new Promise<URL>((resolve, reject) => {
const timer = setTimeout(
() => reject(new Error(`Timed out waiting for redirect to ${callbackPath}`)),
timeoutMs
);
page.route(routeMatcher, async (route) => {
clearTimeout(timer);
resolve(new URL(route.request().url()));
await route.abort();
});
});
try {
await action();
return await callbackPromise;
} finally {
await page.unroute(routeMatcher);
}
const actionPromise = action().catch((error) => error);
const callbackUrl = await callbackPromise;
await actionPromise;
return callbackUrl;
}
export async function getUserCode(
@@ -86,7 +74,8 @@ export async function pushAuthorizationRequest(
const form: Record<string, string> = {
client_id: params.clientId,
response_type: params.responseType ?? 'code',
scope: params.scope ?? 'openid profile email'
scope: params.scope ?? 'openid profile email',
state: params.state ?? 'nXx-6Qr-owc1SHBa'
};
if (params.redirectUri) form.redirect_uri = params.redirectUri;
if (params.clientSecret) form.client_secret = params.clientSecret;